Commit acf6e8b0 authored by Martin Lowe's avatar Martin Lowe 🇨🇦
Browse files

Merge branch 'dev' into 'master'

Dev into master for production release

See merge request !467
parents c8691e31 98993d47
Pipeline #1763 passed with stage
in 0 seconds
......@@ -28,6 +28,7 @@ import org.slf4j.LoggerFactory;
import io.quarkus.runtime.Startup;
import io.smallrye.mutiny.Uni;
import io.smallrye.mutiny.infrastructure.Infrastructure;
import io.undertow.server.handlers.form.MultiPartParserDefinition.FileTooLargeException;
/**
* Imports images from the Eclipse DB OrganizationInformation table into the local imagestore. This will overwrite
......@@ -125,7 +126,7 @@ public class LegacyImageMigration {
void writeImageSafely(Supplier<byte[]> logo, Integer organizationID, String mime, ImageStoreFormats format) {
try {
images.writeImage(logo, Integer.toString(organizationID), mime, Optional.of(format));
} catch (RuntimeException e) {
} catch (FileTooLargeException | RuntimeException e) {
LOGGER.error("Error while writing logo for organization {} with format {}", organizationID, format, e);
}
}
......
......@@ -11,10 +11,9 @@
*/
package org.eclipsefoundation.react.bootstrap;
import java.time.Instant;
import java.time.OffsetDateTime;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
import java.util.Optional;
import java.util.Random;
......@@ -147,8 +146,7 @@ public class DataLoader {
wg.setParticipationLevel(
config.getParticipationLevels().get(r.nextInt(config.getParticipationLevels().size())));
// get a random instance of time
Instant inst = Instant.now().minus(r.nextInt(1000000), ChronoUnit.SECONDS);
wg.setEffectiveDate(new Date(inst.getEpochSecond()));
wg.setEffectiveDate(OffsetDateTime.now().minus(r.nextInt(1000000), ChronoUnit.SECONDS));
wg.setContact(generateContact(form, Optional.empty()));
wg.setForm(form);
wgs.add(wg);
......
......@@ -11,7 +11,7 @@
*/
package org.eclipsefoundation.react.dto;
import java.util.Date;
import java.time.OffsetDateTime;
import java.util.List;
import java.util.Objects;
......@@ -56,7 +56,7 @@ public class FormWorkingGroup extends BareNode implements TargetedClone<FormWork
@NotBlank(message = "Participation level cannot be blank")
private String participationLevel;
@NotNull(message = "Effective date cannot be blank")
private Date effectiveDate;
private OffsetDateTime effectiveDate;
// form entity
@OneToOne(targetEntity = MembershipForm.class)
......@@ -128,14 +128,14 @@ public class FormWorkingGroup extends BareNode implements TargetedClone<FormWork
/**
* @return the effectiveDate
*/
public Date getEffectiveDate() {
public OffsetDateTime getEffectiveDate() {
return effectiveDate;
}
/**
* @param effectiveDate the effectiveDate to set
*/
public void setEffectiveDate(Date effectiveDate) {
public void setEffectiveDate(OffsetDateTime effectiveDate) {
this.effectiveDate = effectiveDate;
}
......
package org.eclipsefoundation.react.model;
import java.util.Date;
import java.time.OffsetDateTime;
import javax.annotation.Nullable;
......@@ -16,7 +16,7 @@ public abstract class FormWorkingGroupData {
@Nullable
public abstract String getFormId();
public abstract String getParticipationLevel();
public abstract Date getEffectiveDate();
public abstract OffsetDateTime getEffectiveDate();
public abstract String getWorkingGroup();
public abstract ContactData getContact();
......@@ -30,7 +30,7 @@ public abstract class FormWorkingGroupData {
public abstract Builder setId(@Nullable String id);
public abstract Builder setFormId(@Nullable String formId);
public abstract Builder setParticipationLevel(String participationLevel);
public abstract Builder setEffectiveDate(Date effectiveDate);
public abstract Builder setEffectiveDate(OffsetDateTime effectiveDate);
public abstract Builder setWorkingGroup(String workingGroup);
public abstract Builder setContact(ContactData contact);
public abstract FormWorkingGroupData build();
......
......@@ -13,7 +13,7 @@ public interface ImageStoreFormat {
String getName();
public enum ImageStoreFormats implements ImageStoreFormat {
SMALL, LARGE, PRINT, WEB;
SMALL, LARGE, PRINT, WEB, WEB_SRC;
@Override
public String getName() {
......
......@@ -91,6 +91,7 @@ import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import io.quarkus.security.Authenticated;
import io.undertow.server.handlers.form.MultiPartParserDefinition.FileTooLargeException;
/**
* Allows for external organizations data to be retrieved and displayed.
......@@ -575,7 +576,7 @@ public class OrganizationResource extends AbstractRESTResource {
@Path("{orgID:\\d+}/logos")
@Consumes(MediaType.MULTIPART_FORM_DATA)
public Response postOrganizationLogoUpdate(@PathParam("orgID") String organizationID,
@MultipartForm OrganizationLogoUpdateRequest request) {
@MultipartForm OrganizationLogoUpdateRequest request) throws FileTooLargeException {
// handle writing and checking image data
ImageStoreFormat format = ImageStoreFormats.getFormat(request.imageFormat);
String extension = ImageFileHelper.convertMimeType(request.imageMIME);
......
/*
* Copyright (C) 2019 Eclipse Foundation and others.
*
* This program and the accompanying materials are made
* available under the terms of the Eclipse Public License 2.0
* which is available at https://www.eclipse.org/legal/epl-2.0/
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.eclipsefoundation.react.resources.mapper;
import javax.ws.rs.core.Response;
import javax.ws.rs.core.Response.Status;
import javax.ws.rs.ext.ExceptionMapper;
import javax.ws.rs.ext.Provider;
import org.eclipsefoundation.core.model.Error;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import io.undertow.server.handlers.form.MultiPartParserDefinition.FileTooLargeException;
/**
*
* @author Martin Lowe
*/
@Provider
public class FileTooLargeExceptionMapper implements ExceptionMapper<FileTooLargeException> {
private static final Logger LOGGER = LoggerFactory.getLogger(FileTooLargeExceptionMapper.class);
@Override
public Response toResponse(FileTooLargeException exception) {
LOGGER.error(exception.getMessage(), exception);
return new Error(Status.REQUEST_ENTITY_TOO_LARGE, "Could not process the given request: " + exception.getMessage()).asResponse();
}
}
......@@ -20,6 +20,7 @@ import org.eclipsefoundation.react.namespace.ImageStoreFormat;
import io.smallrye.config.ConfigMapping;
import io.smallrye.config.WithDefault;
import io.undertow.server.handlers.form.MultiPartParserDefinition.FileTooLargeException;
/**
* Defines writing, retrieval, and deletion of images using byte arrays.
......@@ -49,7 +50,8 @@ public interface ImageStoreService {
* @param format the name of the format to write the image for
* @return absolute path to access the live image
*/
String writeImage(Supplier<byte[]> imageBytes, String organization, String mimeType, Optional<ImageStoreFormat> format);
String writeImage(Supplier<byte[]> imageBytes, String organization, String mimeType,
Optional<ImageStoreFormat> format) throws FileTooLargeException;
/**
* Remove images associated with the given organization. This should clear all images that exist for the
......@@ -73,17 +75,33 @@ public interface ImageStoreService {
String webRoot();
@WithDefault("1000000")
long maxSizeInBytes();
@WithDefault("")
String defaultImageUrl();
MaxSizeInBytes maxSizeInBytes();
Compression compression();
@WithDefault("false")
boolean persistToDb();
/**
* Contains the properties regarding max size of saved assets
*
* @author Martin Lowe
*
*/
interface MaxSizeInBytes {
@WithDefault("1048576")
long web();
@WithDefault("64000")
long webPostCompression();
@WithDefault("10485760")
long print();
}
/**
* Represents compression configuration settings.
*
......@@ -93,10 +111,10 @@ public interface ImageStoreService {
interface Compression {
@WithDefault("true")
boolean enabled();
@WithDefault("200")
int maxDimension();
@WithDefault("0.80f")
@Max(1)
float factor();
......
......@@ -37,7 +37,6 @@ import javax.imageio.ImageWriteParam;
import javax.imageio.ImageWriter;
import javax.imageio.stream.ImageOutputStream;
import javax.inject.Inject;
import javax.ws.rs.BadRequestException;
import javax.ws.rs.ServerErrorException;
import javax.ws.rs.core.MultivaluedMap;
import javax.ws.rs.core.Response;
......@@ -51,6 +50,7 @@ import org.eclipsefoundation.persistence.model.RDBMSQuery;
import org.eclipsefoundation.persistence.service.FilterService;
import org.eclipsefoundation.react.helper.ImageFileHelper;
import org.eclipsefoundation.react.namespace.ImageStoreFormat;
import org.eclipsefoundation.react.namespace.ImageStoreFormat.ImageStoreFormats;
import org.eclipsefoundation.react.service.ImageStoreService;
import org.imgscalr.Scalr;
import org.jboss.resteasy.specimpl.MultivaluedMapImpl;
......@@ -58,6 +58,7 @@ import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import io.quarkus.runtime.Startup;
import io.undertow.server.handlers.form.MultiPartParserDefinition.FileTooLargeException;
/**
* Default implementation of the image service. Writes images to a file system location then provides a web-mounted path
......@@ -141,7 +142,7 @@ public class DefaultImageStoreService implements ImageStoreService {
@Override
public String writeImage(Supplier<byte[]> imageBytes, String fileName, String mimeType,
Optional<ImageStoreFormat> format) {
Optional<ImageStoreFormat> format) throws FileTooLargeException {
// get file metadata
Path p = imageStoreRoot.resolve(ImageFileHelper.getFileNameWithExtension(fileName, format, mimeType));
BasicFileAttributeView attrView = Files.getFileAttributeView(p, BasicFileAttributeView.class);
......@@ -153,21 +154,43 @@ public class DefaultImageStoreService implements ImageStoreService {
// cannot provide image, return null
return null;
}
// compress image and then compare max size
byte[] compressedImage = compress(bytes, mimeType);
if (compressedImage.length > config.maxSizeInBytes() && (!Files.exists(p)
|| !approximatelyMatch((long) compressedImage.length, attrView.readAttributes().size(), 1000))) {
throw new BadRequestException(
"Passed image is larger than allowed size of '" + config.maxSizeInBytes() + "' bytes");
}
// if enabled, update the EclipseDB on logo update when image name is numeric
// max size check is related to max blob size of 64kb
// TODO remove once the Drupal API references this API as this will no longer be needed then
handleDBPersist(fileName, compressedImage, mimeType, format);
if (format.isPresent() && format.get().equals(ImageStoreFormats.PRINT)) {
if (bytes.length > config.maxSizeInBytes().print()) {
throw new FileTooLargeException("Passed image is larger than allowed size of '"
+ config.maxSizeInBytes().print() + "' bytes");
}
// Any print files should not attempt compression (as they are meant to be big)
return getWebUrl(Files.write(p, bytes));
} else {
if (bytes.length > config.maxSizeInBytes().web()) {
throw new FileTooLargeException("Passed image is larger than allowed size of '"
+ config.maxSizeInBytes().web() + "' bytes");
}
// compress image and then compare max size
byte[] compressedImage = compress(bytes, mimeType);
if (compressedImage.length > config.maxSizeInBytes().webPostCompression()
&& (!Files.exists(p) || !approximatelyMatch((long) compressedImage.length,
attrView.readAttributes().size(), 1000))) {
throw new FileTooLargeException("Passed image is larger than allowed size of '"
+ config.maxSizeInBytes().webPostCompression() + "' bytes after compression");
}
// if enabled, update the EclipseDB on logo update when image name is numeric
// max size check is related to max blob size of 64kb
// TODO remove once the Drupal API references this API as this will no longer be needed then
handleDBPersist(fileName, compressedImage, mimeType, format);
// write will create and overwrite file by default if it exists
return getWebUrl(Files.write(p, compress(bytes, mimeType)));
// write the orginal bytes to preserve source file in case of emergency
Files.write(imageStoreRoot.resolve(ImageFileHelper.getFileNameWithExtension(fileName,
Optional.of(ImageStoreFormats.WEB_SRC), mimeType)), bytes);
// write will create and overwrite file by default if it exists
return getWebUrl(Files.write(p, compressedImage));
}
} catch (IOException e) {
if (e instanceof FileTooLargeException) {
throw (FileTooLargeException) e;
}
throw new ServerErrorException("Could not write image for organization " + fileName,
Response.Status.INTERNAL_SERVER_ERROR.getStatusCode(), e);
}
......@@ -229,8 +252,8 @@ public class DefaultImageStoreService implements ImageStoreService {
// get a ref to the given organization information object
MultivaluedMap<String, String> params = new MultivaluedMapImpl<>();
params.add(DefaultUrlParameterNames.ID.getName(), fileName);
List<OrganizationInformation> infoRefs = dao
.get(new RDBMSQuery<>(new RequestWrapper(), filters.get(OrganizationInformation.class), params));
List<OrganizationInformation> infoRefs = dao.get(
new RDBMSQuery<>(new RequestWrapper(), filters.get(OrganizationInformation.class), params));
// if ref doesn't exist, create one
OrganizationInformation oi;
if (infoRefs.isEmpty()) {
......@@ -247,7 +270,7 @@ public class DefaultImageStoreService implements ImageStoreService {
oi.setSmallMime(mimeType);
dao.add(new RDBMSQuery<>(new RequestWrapper(), filters.get(OrganizationInformation.class)),
Arrays.asList(oi));
}
}
}
......
......@@ -292,6 +292,10 @@ export const CONFIG_FOR_BAR_LINE_CHART = {
aspectRatio: 1.8,
};
export const LOGO_DIMENSIONS_LIMITATION = 2000; // pixels
export const LOGO_SIZE_LIMITATION_WEB = 1048576; // bytes = 1MB
export const LOGO_SIZE_LIMITATION_PRINT = 10485760; // bytes = 10MB
// Constants for styles
export const drawerWidth = 280;
export const themeBlack = '#0B0B0B';
......
......@@ -760,7 +760,7 @@ export const fetchWrapper = (url, method, callbackFunc, dataBody, errCallbackFun
.catch((err) => {
console.log(err);
if (errCallbackFunc) {
errCallbackFunc();
errCallbackFunc(err);
return;
}
});
......
......@@ -12,6 +12,9 @@ import {
FETCH_METHOD,
getCurrentMode,
lightGray,
LOGO_DIMENSIONS_LIMITATION,
LOGO_SIZE_LIMITATION_PRINT,
LOGO_SIZE_LIMITATION_WEB,
MODE_REACT_ONLY,
} from '../../../Constants/Constants';
import { fetchWrapper, isProd } from '../../../Utils/formFunctionHelpers';
......@@ -94,6 +97,8 @@ export default function OrgProfilesBasicInfo() {
const [uploadTarget, setUploadTarget] = useState<'Web' | 'Print'>('Web');
const [isFetchingOrg, setIsFetchingOrg] = useState(true);
const [dateForUpdatingPrintLogo, setDateForUpdatingPrintLogo] = useState('');
const [isUploadingWeb, setIsUploadingWeb] = useState(false);
const [isUploadingPrint, setIsUploadingPrint] = useState(false);
const openUploadDialog = (target: 'Web' | 'Print') => {
setUploadTarget(target);
......@@ -104,12 +109,16 @@ export default function OrgProfilesBasicInfo() {
!isProd && console.log('Logo Saved!', files[0]);
let fileContent: any;
const isForPrint = uploadTarget === 'Print';
isForPrint ? setIsUploadingPrint(true) : setIsUploadingWeb(true);
const successCallback = () => {
if (isForPrint) {
const today = new Date().toString();
const currentTime = `${today.slice(16, 24)}, ${today.slice(4, 10)}`;
setDateForUpdatingPrintLogo(currentTime);
setIsUploadingPrint(false);
} else {
setIsUploadingWeb(false);
}
formikOrg.setFieldValue(`logos.logoFor${uploadTarget}`, fileContent);
// Update Portal Context - orgInfo to make sure everything is synced among sections
......@@ -117,41 +126,66 @@ export default function OrgProfilesBasicInfo() {
succeededToExecute('');
};
const logoFile = new FileReader();
logoFile.addEventListener('load', (data) => {
fileContent = data.target?.result;
const getLogoFileData = () => {
const data = new FormData();
const logoFile = files[0];
data.append('image', logoFile);
data.append('image_mime', logoFile.type);
data.append('image_format', isForPrint ? 'PRINT' : 'WEB');
return data;
};
const logoFileInstance = new FileReader();
logoFileInstance.addEventListener('load', (fileData) => {
fileContent = fileData.target?.result;
const logoImg = new Image();
logoImg.src = fileContent;
const failedToUploadLogo = (errCode: number) => {
let errMsg = `The image need to be less than ${LOGO_SIZE_LIMITATION_WEB / 1024 / 1024}MB`;
if (isForPrint) {
errMsg = `The image need to be less than ${LOGO_SIZE_LIMITATION_PRINT / 1024 / 1024}MB`;
}
failedToExecute(errCode === 413 ? errMsg : '');
setIsFetchingOrg(false);
setIsUploadingWeb(false);
setIsUploadingPrint(false);
};
if (isForPrint) {
setOpen(false);
orgId !== 0 &&
fetchWrapper(
api_prefix() + `/organizations/${orgId}/logos`,
FETCH_METHOD.POST,
successCallback,
getLogoFileData(),
failedToUploadLogo
);
return;
}
logoImg.onload = () => {
if (logoImg.height > 200) {
failedToExecute('Image height must be 200px or smaller');
if (logoImg.height > LOGO_DIMENSIONS_LIMITATION) {
failedToExecute(`Image height must be ${LOGO_DIMENSIONS_LIMITATION}px or smaller`);
return;
}
if (logoImg.width > 200) {
failedToExecute('Image width must be 200px or smaller');
if (logoImg.width > LOGO_DIMENSIONS_LIMITATION) {
failedToExecute(`Image width must be ${LOGO_DIMENSIONS_LIMITATION}px or smaller`);
return;
}
setOpen(false);
if (!isReactOnlyMode) {
const failedToUploadLogo = () => {
failedToExecute('');
setIsFetchingOrg(false);
};
const data = new FormData();
const logoFile = files[0];
data.append('image', logoFile);
data.append('image_mime', logoFile.type);
data.append('image_format', isForPrint ? 'PRINT' : 'WEB');
orgId !== 0 &&
fetchWrapper(
api_prefix() + `/organizations/${orgId}/logos`,
FETCH_METHOD.POST,
successCallback,
data,
getLogoFileData(),
failedToUploadLogo
);
} else {
......@@ -159,7 +193,7 @@ export default function OrgProfilesBasicInfo() {
}
};
});
logoFile.readAsDataURL(files[0]);
logoFileInstance.readAsDataURL(files[0]);
};
const handleSaveOrgProfile = () => {
......@@ -330,15 +364,17 @@ export default function OrgProfilesBasicInfo() {
</div>
)}
<Button className={classes.uploadBtn} onClick={() => openUploadDialog('Web')}>
Upload New
<Button className={classes.uploadBtn} onClick={() => openUploadDialog('Web')} disabled={isUploadingWeb}>
{isUploadingWeb ? <CircularProgress size={20} /> : 'Upload New'}
</Button>
<Typography className={classes.helperText} variant="body2">
The supported formats for uploading your logo include: PNG, or JPG file under 1 MB in size.
The supported formats for uploading your logo include: PNG, or JPG file under{' '}
{LOGO_SIZE_LIMITATION_WEB / 1024 / 1024} MB in size.
{/* The calculation is to convert bytes into megabytes */}
</Typography>
<Typography className={classes.helperText} variant="body2">
The logo dimension cannot exceed 200 by 200 pixels.
The logo dimension cannot exceed {LOGO_DIMENSIONS_LIMITATION} by {LOGO_DIMENSIONS_LIMITATION} pixels.
</Typography>
</Grid>
......@@ -359,12 +395,14 @@ export default function OrgProfilesBasicInfo() {
)}
</div>
<Button className={classes.uploadBtn} onClick={() => openUploadDialog('Print')}>
Upload New
<Button className={classes.uploadBtn} onClick={() => openUploadDialog('Print')} disabled={isUploadingPrint}>
{isUploadingPrint ? <CircularProgress size={20} /> : 'Upload New'}
</Button>
<Typography className={classes.helperText} variant="body2">
If available please include the .eps file of your company logo.
If available please include the .eps file of your company logo and it should be under{' '}
{LOGO_SIZE_LIMITATION_PRINT / 1024 / 1024} MB in size.
{/* The calculation is to convert bytes into megabytes */}
</Typography>
</Grid>
</Grid>
......@@ -373,7 +411,7 @@ export default function OrgProfilesBasicInfo() {
open={open}
onSave={(file) => postLogo(file)}
acceptedFiles={uploadTarget === 'Web' ? ['image/jpeg', 'image/png'] : ['.eps']}
maxFileSize={1048576}
maxFileSize={uploadTarget === 'Web' ? LOGO_SIZE_LIMITATION_WEB : LOGO_SIZE_LIMITATION_PRINT}
filesLimit={1}
onClose={() => setOpen(false)}
/>
......
package org.eclipsefoundation.react.test.helper;
import java.time.Instant;
import java.time.OffsetDateTime;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
import java.util.Optional;
import java.util.Random;
......@@ -108,8 +107,7 @@ public class DtoHelper {
wg.setWorkingGroupID(RandomStringUtils.randomAlphabetic(4, 10));
wg.setParticipationLevel(RandomStringUtils.randomAlphabetic(4, 10));
// get a random instance of time
Instant inst = Instant.now().minus(r.nextInt(1000000), ChronoUnit.SECONDS);
wg.setEffectiveDate(new Date(inst.getEpochSecond()));
wg.setEffectiveDate(OffsetDateTime.now().minus(r.nextInt(1000000), ChronoUnit.SECONDS));
wg.setContact(generateContact(form, Optional.empty()));
wg.setForm(form);
return wg;
......
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment