From 769a3bd2346dc29006e0fcec292b189913e2535d Mon Sep 17 00:00:00 2001 From: Maher Khalil Date: Wed, 30 Sep 2026 18:55:28 -0400 Subject: [PATCH] feat: add themed image responses, browser caching, and shared upload handling --- docs/catalog-image-uploads.md | 119 ++++++++++++-- .../services/local/EngineRouteResource.java | 45 ++---- .../web/services/local/ProjectResource.java | 146 +++++++++--------- .../{Uploader.java => AbstractUploader.java} | 49 +++--- src/prerna/upload/CatalogImageUploader.java | 9 +- src/prerna/upload/FileUploader.java | 2 +- src/prerna/upload/ImageUploader.java | 8 +- .../services/util/CatalogImageResponse.java | 111 +++++++++++++ 8 files changed, 344 insertions(+), 145 deletions(-) rename src/prerna/upload/{Uploader.java => AbstractUploader.java} (84%) create mode 100644 src/prerna/web/services/util/CatalogImageResponse.java diff --git a/docs/catalog-image-uploads.md b/docs/catalog-image-uploads.md index c1d00d58..7ad1b012 100644 --- a/docs/catalog-image-uploads.md +++ b/docs/catalog-image-uploads.md @@ -82,8 +82,15 @@ insight upload routes retain their existing request contracts. ## Shared stock images -When a project or engine has no saved image, the download route serves a shared -stock image directly from the configured stock collection. It does not copy that +The client uses the instance-managed badge for registered system projects and +uploaded images or themed stock artwork for other project catalogs (including +agents, skills, apps, notebooks, and automations). Engine catalog +cards and detail headers use their database-type or model-provider icons from +the existing frontend asset library. Engine image endpoints remain available +to other consumers. + +When an ordinary project or engine has no saved image, the download route serves +a shared stock image directly from the requested stock collection. It does not copy that fallback into the resource's version folder or upload it to cluster image storage. The CouchDB project/database fallback likewise returns stock bytes without saving a per-resource attachment. Uploaded images continue to take precedence. @@ -95,13 +102,103 @@ up to 30 seconds to appear on a node that is currently using a stock fallback. Selection uses the existing deterministic key: the resource name for local version paths, the resource ID for cluster image paths, and the partition/name -for CouchDB. The selection stays stable while that key, the configured theme, -and the stock collection stay unchanged. No per-resource reference file or -database record is required. `DEFAULT_IMAGE_THEME` selects the light or dark -stock collection, with `images/stock-engines` as the legacy fallback directory. - -Existing images, including stock copies created by previous versions, are still -served as saved images. This change does not delete or migrate them. Removing old +for CouchDB. Paired light and dark collections use matching, sorted filenames, +so changing the theme keeps the same artwork. No per-resource reference file or +database record is required. + +Both download routes accept `?theme=light` or `?theme=dark`. The catalog UI sends +its resolved theme, including the operating system preference when set to +System, and updates the URL whenever that theme changes. Upload cache revisions +remain in the URL as a separate `v` parameter. Ordinary projects' uploaded images +are returned unchanged for either theme. + +Requests with no theme or an unsupported value use `DEFAULT_IMAGE_THEME`. +If the requested collection is missing or empty, the server tries the configured +theme and then the legacy `images/stock-engines` directory. Local and cluster +response ETags include the stock file path, so light and dark variants have +different cache validators; CouchDB ETags reflect the returned image bytes. + +For ordinary projects, existing images, including stock copies created by +previous versions, are still served as saved images. This change does not delete +or migrate them. Removing old copies requires a separate migration that identifies stock files without removing -custom uploads. Build and deploy both SEMOSS core and Monolith for this behavior; -the SDK and frontend download URLs are unchanged. +custom uploads. Build and deploy SEMOSS core, Monolith, and the client together +to enable theme switching. The download route paths and existing callers remain +compatible; the theme query parameter is optional. + +### Instance-managed projects + +The project download endpoint identifies system projects using +`SystemDefaultEngines.isSystemProject(projectId)`, which includes the registered +platform apps, skills, MCPs, and agents. This uses exact IDs from the same lists +that seed projects at startup. Editable `SYSTEM` tags, project names, `platform__` +prefixes, missing creators, and global visibility do not classify user projects +as instance-managed. Additional managed project types can join these registry +lists without changing the image endpoint. + +After the normal session and project permission checks, registered projects +receive `images/system-projects/instance-managed-light.svg` or +`instance-managed-dark.svg` from the SEMOSS base directory. These badges take +precedence over saved images for system projects only, consistently across local, +cluster, and CouchDB deployments. No saved images are deleted or modified, and +ordinary projects and engine provider/type icons keep their existing behavior. + +An omitted or unsupported theme uses `DEFAULT_IMAGE_THEME`. If the selected badge +is missing, the resolver tries the configured theme and the other available badge. +If neither badge exists, the endpoint continues through its normal image lookup. +The badge files stay outside the random stock collections so they are never +assigned to user-created projects. They use the same private browser cache policy +and theme-specific ETags as other project images. + +Publish SEMOSS core and Monolith together with the two SVG assets to enable this +selection. Existing catalog UI image requests need no frontend changes. + +Image responses set `Content-Type` from the returned file or bytes. PNG stock +artwork is served as `image/png`; SVG uploads use `image/svg+xml`. Advertising +SVG in `@Produces` alone is insufficient: a browser's `Accept` header can select +SVG even when the response contains PNG bytes. +Image ETags include a representation version so previously cached responses +with incorrect content types are replaced when the browser revalidates. + +When publishing an incremental local build, include newly added classes as well +as changed route classes. In particular, both routes require +`WEB-INF/classes/prerna/web/services/util/CatalogImageResponse.class`. A missing +helper causes `NoClassDefFoundError` and failed image requests, so the UI keeps +its initials fallback. Publish the complete build and reload the application; +clearing the browser cache cannot repair a missing server class. + +## Browser caching + +Successful engine and project image downloads use: + +```http +Cache-Control: private, max-age=300, must-revalidate +ETag: "" +Vary: Cookie, Authorization +``` + +The browser can reuse a fresh image for five minutes without contacting the +server. Once stale, it sends `If-None-Match`; an unchanged image returns +`304 Not Modified` with the same ETag, Cache-Control, and Vary headers and no +image body. This applies to local files, cluster files, and CouchDB responses. +Authentication and resource permissions are checked before any server response, +including a 304. Private caching avoids shared proxy storage, and Vary keeps +browser cache entries separate when session cookies or authorization change. + +The UI's `theme` query parameter separates light and dark entries. A successful +upload changes `v`, bypassing the previous cached URL immediately in that browser +session. Other viewers can retain the previous image for the remaining five-minute +freshness window, plus any cluster synchronization delay. These URLs are mutable, +so they are deliberately not marked `immutable` or cached for a year. + +To verify a deployed environment, open browser DevTools > Network and leave +**Disable cache** unchecked. Load a catalog image once, then navigate away and +back: the same URL should show a memory/disk cache hit. After five minutes, its +conditional request should return 304 if the image has not changed. Changing +theme should request the other URL once; switching back can reuse its cached +variant. Uploading an image should request a new `v` URL. Reload and hard-refresh +can force revalidation, so use normal navigation for the freshness check. + +Inspect the final response headers through your deployed proxy. A proxy that +adds `no-store` or `no-cache`, drops the ETag, or changes query-string handling +can override or defeat this behavior. diff --git a/src/prerna/semoss/web/services/local/EngineRouteResource.java b/src/prerna/semoss/web/services/local/EngineRouteResource.java index d26a4c65..ebc7f32f 100644 --- a/src/prerna/semoss/web/services/local/EngineRouteResource.java +++ b/src/prerna/semoss/web/services/local/EngineRouteResource.java @@ -62,7 +62,6 @@ import jakarta.ws.rs.core.MediaType; import jakarta.ws.rs.core.Request; import jakarta.ws.rs.core.Response; -import jakarta.ws.rs.core.Response.ResponseBuilder; import prerna.auth.User; import prerna.auth.utils.SecurityAdminUtils; import prerna.auth.utils.SecurityEngineUtils; @@ -83,6 +82,7 @@ import prerna.util.EngineUtility; import prerna.util.NotificationConstants; import prerna.util.Utility; +import prerna.web.services.util.CatalogImageResponse; import prerna.web.services.util.WebUtility; @Path("/e-{engineId}") @@ -372,10 +372,10 @@ public Response updateSmssFile(@Context HttpServletRequest request, @PathParam(" ClusterUtil.pushEngineSmss(engineId, engine.getCatalogType()); if (Utility.isNotificationDatabaseEnabled()) { // Adding notification - String engineType = String.valueOf(SecurityEngineUtils.getEngineType(engineId)).toLowerCase(); - NotificationDbUtils.createNotification(user, null, null, engineId, NotificationConstants.Type.SMSS_UPDATE, - engineType, NotificationConstants.Priority.MEDIUM, null, null, - NotificationConstants.DisplaySurface.BELL); + String engineType = String.valueOf(SecurityEngineUtils.getEngineType(engineId)).toLowerCase(); + NotificationDbUtils.createNotification(user, null, null, engineId, NotificationConstants.Type.SMSS_UPDATE, + engineType, NotificationConstants.Priority.MEDIUM, null, null, + NotificationConstants.DisplaySurface.BELL); // Adding email notification EmailUtility.sendSmssUpdateEmailNotification(user, engineId, EmailUtility.RESOURCE_TYPE.ENGINE); @@ -408,7 +408,9 @@ public Response uploadImage(@Context ServletContext context, @Context HttpServle * Download the image associated with this engine. The lookup falls through * three sources in order: CouchDB (if enabled), cloud storage (if running in * cluster mode), and finally the engine's local version folder. If no image - * exists locally the shared stock image is served without copying it. + * exists locally the shared stock image is served without copying it. The + * optional {@code theme=light|dark} query parameter selects the stock artwork + * theme. *

* Honors HTTP {@code If-None-Match} via an entity tag built from the file's * path, last-modified timestamp, and size, so an unchanged image returns 304. @@ -422,7 +424,8 @@ public Response uploadImage(@Context ServletContext context, @Context HttpServle */ @GET @Path("/image/download") - @Produces({ MediaType.APPLICATION_OCTET_STREAM, MediaType.APPLICATION_SVG_XML }) + @Produces({ "image/png", "image/jpeg", "image/gif", "image/webp", "image/svg+xml", + MediaType.APPLICATION_OCTET_STREAM }) public Response imageDownload(@Context final Request coreRequest, @Context HttpServletRequest request, @PathParam("engineId") String engineId) { // not required for containment. getEngineTypeAndSubtype and @@ -482,13 +485,15 @@ public Response imageDownload(@Context final Request coreRequest, @Context HttpS return WebUtility.getResponse(returnMap, 400); } + String imageTheme = request.getParameter("theme"); File exportFile = null; // is the image in couch db if (CouchUtil.COUCH_ENABLED) { try { Map selectors = new HashMap<>(); selectors.put(couchSelector, engineId); - return CouchUtil.download(couchSelector, selectors); + return CatalogImageResponse.withBrowserCache(coreRequest, + CouchUtil.download(couchSelector, selectors, imageTheme)); } catch (CouchException e) { classLogger.error( "Failed to download engine image from CouchDB for engine '{}' using selector '{}'; falling through to other sources", @@ -498,7 +503,7 @@ public Response imageDownload(@Context final Request coreRequest, @Context HttpS // is the image in cloud storage else if (ClusterUtil.IS_CLUSTER) { try { - exportFile = ClusterUtil.getEngineAndProjectImage(engineId, engineType); + exportFile = ClusterUtil.getEngineAndProjectImage(engineId, engineType, imageTheme); } catch (Exception e) { classLogger.error("Failed to fetch engine image from cluster storage for engine '{}' (type {})", engineId, engineType, e); @@ -512,32 +517,16 @@ else if (ClusterUtil.IS_CLUSTER) { if (exportFile == null) { // Resolve the shared stock file without creating an engine asset. String fileLocation = engineVersionPath + "/" + "image.png"; - exportFile = DefaultImageGeneratorUtil.getStockImageForPath(fileLocation); + exportFile = DefaultImageGeneratorUtil.getStockImageForPath(fileLocation, imageTheme); } } if (exportFile != null && exportFile.exists()) { String exportName = engineId + "_Image." + FilenameUtils.getExtension(exportFile.getAbsolutePath()); - // want to cache this on browser if user has access -// CacheControl cc = new CacheControl(); -// cc.setMaxAge(86400); -// cc.setPrivate(true); -// cc.setMustRevalidate(true); EntityTag etag = new EntityTag(Integer.toHexString(exportFile.getAbsolutePath().hashCode()) + "-" + exportFile.lastModified() + "-" + exportFile.length()); - ResponseBuilder builder = coreRequest.evaluatePreconditions(etag); - - // cached resource did not change - if (builder != null) { - return builder.build(); - } - - return Response.status(200).entity(exportFile) - .header("Content-Disposition", "attachment; filename=" + exportName) -// .cacheControl(cc) - .tag(etag) -// .lastModified(new Date(exportFile.lastModified())) - .build(); + return CatalogImageResponse.withBrowserCache(coreRequest, Response.ok(exportFile) + .header("Content-Disposition", "attachment; filename=" + exportName).tag(etag).build()); } else { Map errorMap = new HashMap<>(); errorMap.put(Constants.ERROR_MESSAGE, "Error sending image file"); diff --git a/src/prerna/semoss/web/services/local/ProjectResource.java b/src/prerna/semoss/web/services/local/ProjectResource.java index 1dd53582..0042898a 100644 --- a/src/prerna/semoss/web/services/local/ProjectResource.java +++ b/src/prerna/semoss/web/services/local/ProjectResource.java @@ -105,6 +105,7 @@ import prerna.util.NotificationConstants; import prerna.util.Utility; import prerna.web.requests.OverrideParametersServletRequest; +import prerna.web.services.util.CatalogImageResponse; import prerna.web.services.util.WebUtility; @Path("/project-{projectId}") @@ -268,10 +269,10 @@ public Response updateSmssFile(@Context HttpServletRequest request, @PathParam(" // push to cloud ClusterUtil.pushProjectSmss(projectId); if (Utility.isNotificationDatabaseEnabled()) { - // Adding notification - NotificationDbUtils.createNotification(user, null, null, projectId, NotificationConstants.Type.SMSS_UPDATE, - NotificationConstants.APP_CATALOG, NotificationConstants.Priority.MEDIUM, null, null, - NotificationConstants.DisplaySurface.BELL); + // Adding notification + NotificationDbUtils.createNotification(user, null, null, projectId, NotificationConstants.Type.SMSS_UPDATE, + NotificationConstants.APP_CATALOG, NotificationConstants.Priority.MEDIUM, null, null, + NotificationConstants.DisplaySurface.BELL); // Adding email notification EmailUtility.sendSmssUpdateEmailNotification(user, projectId, EmailUtility.RESOURCE_TYPE.PROJECT); @@ -405,9 +406,9 @@ public Response getProjectLandingPage(@Context final Request coreRequest, @Conte String html = FileUtils.readFileToString(file, "UTF-8"); // want to cache this on browser if user has access -// CacheControl cc = new CacheControl(); -// cc.setMaxAge(1); -// cc.setPrivate(true); + // CacheControl cc = new CacheControl(); + // cc.setMaxAge(1); + // cc.setPrivate(true); EntityTag etag = new EntityTag(Integer.toString(html.hashCode())); ResponseBuilder builder = coreRequest.evaluatePreconditions(etag); @@ -417,9 +418,9 @@ public Response getProjectLandingPage(@Context final Request coreRequest, @Conte } return Response.status(200).entity(html) -// .cacheControl(cc) + // .cacheControl(cc) .tag(etag) -// .lastModified(new Date(file.lastModified())) + // .lastModified(new Date(file.lastModified())) .build(); } catch (IOException e) { Map errorMap = new HashMap<>(); @@ -485,9 +486,9 @@ public Response downloadProjectAsset(@Context final Request coreRequest, @Contex String contents = FileUtils.readFileToString(file, "UTF-8"); // want to cache this on browser if user has access -// CacheControl cc = new CacheControl(); -// cc.setMaxAge(1); -// cc.setPrivate(true); + // CacheControl cc = new CacheControl(); + // cc.setMaxAge(1); + // cc.setPrivate(true); EntityTag etag = new EntityTag(Integer.toString(contents.hashCode())); ResponseBuilder builder = coreRequest.evaluatePreconditions(etag); @@ -497,9 +498,9 @@ public Response downloadProjectAsset(@Context final Request coreRequest, @Contex } return Response.status(200).entity(contents) -// .cacheControl(cc) + // .cacheControl(cc) .tag(etag) -// .lastModified(new Date(file.lastModified())) + // .lastModified(new Date(file.lastModified())) .build(); } catch (IOException e) { Map errorMap = new HashMap<>(); @@ -554,9 +555,9 @@ public Response getEmbedUrl(@Context final Request coreRequest, @Context HttpSer String contents = new String(Files.readAllBytes(Paths.get(file.getAbsolutePath()))); // want to cache this on browser if user has access -// CacheControl cc = new CacheControl(); -// cc.setMaxAge(1); -// cc.setPrivate(true); + // CacheControl cc = new CacheControl(); + // cc.setMaxAge(1); + // cc.setPrivate(true); EntityTag etag = new EntityTag(Integer.toString(contents.hashCode())); ResponseBuilder builder = coreRequest.evaluatePreconditions(etag); @@ -567,9 +568,9 @@ public Response getEmbedUrl(@Context final Request coreRequest, @Context HttpSer String mimeType = Files.probeContentType(file.toPath()); return Response.status(200).entity(contents).type(mimeType) -// .cacheControl(cc) + // .cacheControl(cc) .tag(etag) -// .lastModified(new Date(file.lastModified())) + // .lastModified(new Date(file.lastModified())) .build(); } catch (IOException e) { Map errorMap = new HashMap<>(); @@ -633,9 +634,14 @@ public Response uploadProjectImage(@Context ServletContext context, @Context Htt return uploadImage(context, request, projectId); } + /** + * Downloads the uploaded image or stock artwork for the optional light/dark + * theme. + */ @GET @Path("/projectImage/download") - @Produces({ MediaType.APPLICATION_OCTET_STREAM, MediaType.APPLICATION_SVG_XML }) + @Produces({ "image/png", "image/jpeg", "image/gif", "image/webp", "image/svg+xml", + MediaType.APPLICATION_OCTET_STREAM }) public Response downloadProjectImage(@Context final Request coreRequest, @Context HttpServletRequest request, @PathParam("projectId") String projectId) { // not required for containment. canAccessOrDiscoverableProject below resolves @@ -664,44 +670,33 @@ public Response downloadProjectImage(@Context final Request coreRequest, @Contex return WebUtility.getResponse(errorMap, 401); } - if (CouchUtil.COUCH_ENABLED) { + String imageTheme = request.getParameter("theme"); + // Instance-managed projects have one consistent badge in every storage mode. + File exportFile = DefaultImageGeneratorUtil.getSystemProjectImage(projectId, imageTheme); + if (exportFile == null && CouchUtil.COUCH_ENABLED) { try { Map selectors = new HashMap<>(); selectors.put(CouchUtil.PROJECT, projectId); - return CouchUtil.download(CouchUtil.PROJECT, selectors); + return CatalogImageResponse.withBrowserCache(coreRequest, + CouchUtil.download(CouchUtil.PROJECT, selectors, imageTheme)); } catch (CouchException e) { classLogger.error("Failed to download project image from CouchDB for project {}", projectId, e); } } - File exportFile = null; - try { - exportFile = getProjectImageFile(projectId); - } catch (Exception e) { - classLogger.error("Failed to resolve project image file for project {}", projectId, e); + if (exportFile == null) { + try { + exportFile = getProjectImageFile(projectId, imageTheme); + } catch (Exception e) { + classLogger.error("Failed to resolve project image file for project {}", projectId, e); + } } if (exportFile != null && exportFile.exists()) { String exportName = projectId + "_Image." + FilenameUtils.getExtension(exportFile.getAbsolutePath()); - // want to cache this on browser if user has access -// CacheControl cc = new CacheControl(); -// cc.setMaxAge(86400); -// cc.setPrivate(true); -// cc.setMustRevalidate(true); EntityTag etag = new EntityTag(Integer.toHexString(exportFile.getAbsolutePath().hashCode()) + "-" + exportFile.lastModified() + "-" + exportFile.length()); - ResponseBuilder builder = coreRequest.evaluatePreconditions(etag); - - // cached resource did not change - if (builder != null) { - return builder.build(); - } - - return Response.status(200).entity(exportFile) - .header("Content-Disposition", "attachment; filename=" + exportName) -// .cacheControl(cc) - .tag(etag) -// .lastModified(new Date(exportFile.lastModified())) - .build(); + return CatalogImageResponse.withBrowserCache(coreRequest, Response.ok(exportFile) + .header("Content-Disposition", "attachment; filename=" + exportName).tag(etag).build()); } else { Map errorMap = new HashMap<>(); errorMap.put(Constants.ERROR_MESSAGE, "error sending image file"); @@ -717,8 +712,13 @@ public Response downloadProjectImage(@Context final Request coreRequest, @Contex * @throws Exception */ protected File getProjectImageFile(String projectId) throws Exception { + return getProjectImageFile(projectId, null); + } + + /** Preserves uploaded images and themes only the shared stock fallback. */ + protected File getProjectImageFile(String projectId, String theme) throws Exception { if (ClusterUtil.IS_CLUSTER) { - return ClusterUtil.getEngineAndProjectImage(projectId, IEngine.CATALOG_TYPE.PROJECT); + return ClusterUtil.getEngineAndProjectImage(projectId, IEngine.CATALOG_TYPE.PROJECT, theme); } projectId = WebUtility.inputSanitizer(projectId); @@ -731,12 +731,13 @@ protected File getProjectImageFile(String projectId) throws Exception { return f; } // Resolve the shared stock file without creating a project asset. - return DefaultImageGeneratorUtil.getStockImageForPath(fileLocation + DIR_SEPARATOR + "image.png"); + return DefaultImageGeneratorUtil.getStockImageForPath(fileLocation + DIR_SEPARATOR + "image.png", theme); } @GET @Path("/insightImage/download") - @Produces({ MediaType.APPLICATION_OCTET_STREAM, MediaType.APPLICATION_SVG_XML }) + @Produces({ "image/png", "image/jpeg", "image/gif", "image/webp", "image/svg+xml", + MediaType.APPLICATION_OCTET_STREAM }) public Response downloadInsightImage(@Context final Request coreRequest, @Context HttpServletRequest request, @PathParam("projectId") String projectId, @QueryParam("rdbmsId") String id, @QueryParam("params") String params) { @@ -778,7 +779,7 @@ public Response downloadInsightImage(@Context final Request coreRequest, @Contex Map selectors = new HashMap<>(); selectors.put(CouchUtil.INSIGHT, id); selectors.put(CouchUtil.PROJECT, projectId); - return CouchUtil.download(CouchUtil.INSIGHT, selectors); + return CatalogImageResponse.withContentType(CouchUtil.download(CouchUtil.INSIGHT, selectors)); } catch (CouchException e) { classLogger.error("Failed to download insight image from CouchDB for project {} and insight {}", projectId, id, e); @@ -790,11 +791,11 @@ public Response downloadInsightImage(@Context final Request coreRequest, @Contex if (exportFile != null && exportFile.exists()) { String exportName = projectId + "_Image." + FilenameUtils.getExtension(exportFile.getAbsolutePath()); // want to cache this on browser if user has access -// CacheControl cc = new CacheControl(); -// cc.setMaxAge(86400); -// cc.setPrivate(true); -// cc.setMustRevalidate(true); - EntityTag etag = new EntityTag(Long.toString(exportFile.lastModified())); + // CacheControl cc = new CacheControl(); + // cc.setMaxAge(86400); + // cc.setPrivate(true); + // cc.setMustRevalidate(true); + EntityTag etag = new EntityTag("image-v2-" + exportFile.lastModified()); ResponseBuilder builder = coreRequest.evaluatePreconditions(etag); // cached resource did not change @@ -802,12 +803,8 @@ public Response downloadInsightImage(@Context final Request coreRequest, @Contex return builder.build(); } - return Response.status(200).entity(exportFile) - .header("Content-Disposition", "attachment; filename=" + exportName) -// .cacheControl(cc) - .tag(etag) -// .lastModified(new Date(exportFile.lastModified())) - .build(); + return CatalogImageResponse.withContentType(Response.ok(exportFile) + .header("Content-Disposition", "attachment; filename=" + exportName).tag(etag).build()); } else { Map errorMap = new HashMap<>(); errorMap.put(Constants.ERROR_MESSAGE, "Error sending image file"); @@ -850,20 +847,21 @@ private File getInsightImageFile(String projectId, String id, String feUrl, Stri // JK! this is super annoying when running a bunch of // insights at the same time which is what happens // currently on the app home page -// if (!ClusterUtil.IS_CLUSTER) { -// if(feUrl != null) { -// try { -// ImageCaptureReactor.runImageCapture(feUrl, appId, id, params, sessionId); -// } -// catch(Exception | NoSuchMethodError er) { -// //Image Capture will not run. No image exists nor will be made. The exception kills the rest. -// // return stock image -// er.printStackTrace(); -// f = AbstractSecurityUtils.getStockImage(appId, id); -// return f; -// } -// } -// } + // if (!ClusterUtil.IS_CLUSTER) { + // if(feUrl != null) { + // try { + // ImageCaptureReactor.runImageCapture(feUrl, appId, id, params, sessionId); + // } + // catch(Exception | NoSuchMethodError er) { + // //Image Capture will not run. No image exists nor will be made. The exception + // kills the rest. + // // return stock image + // er.printStackTrace(); + // f = AbstractSecurityUtils.getStockImage(appId, id); + // return f; + // } + // } + // } // the image capture ran // let us try to see if there is a file now... f = findImageFile(fileLocation); diff --git a/src/prerna/upload/Uploader.java b/src/prerna/upload/AbstractUploader.java similarity index 84% rename from src/prerna/upload/Uploader.java rename to src/prerna/upload/AbstractUploader.java index b216e4b2..4f096429 100644 --- a/src/prerna/upload/Uploader.java +++ b/src/prerna/upload/AbstractUploader.java @@ -39,6 +39,7 @@ import java.io.Writer; import java.nio.charset.Charset; import java.nio.charset.StandardCharsets; +import java.nio.file.Files; import java.nio.file.Path; import java.util.List; @@ -61,12 +62,11 @@ import prerna.web.services.util.WebUtility; /** - * Servlet implementation class Uploader + * Shared base for multipart file upload servlets */ -@SuppressWarnings("serial") -public abstract class Uploader extends HttpServlet { +public abstract class AbstractUploader extends HttpServlet { - private static final Logger classLogger = LogManager.getLogger(Uploader.class); + private static final Logger classLogger = LogManager.getLogger(AbstractUploader.class); protected static final String DIR_SEPARATOR = java.nio.file.FileSystems.getDefault().getSeparator(); @@ -151,6 +151,25 @@ protected void deleteFilesFromServer(String[] files) { } } + /** + * Creates the multipart parser shared by general and catalog image uploads. + * Callers set their own request limits and progress listeners before parsing. + * + * @param context the servlet context containing the temporary upload path + * @return a parser using temporary disk storage and UTF-8 headers + * @throws IOException if the temporary upload directory cannot be created + */ + static JakartaServletDiskFileUpload createUploadHandler(ServletContext context) throws IOException { + String configuredTemp = context.getInitParameter(TEMP_FILE_UPLOAD_KEY); + Path temp = configuredTemp == null ? Path.of(System.getProperty("java.io.tmpdir")) + : Path.of(WebUtility.normalizePath(configuredTemp)); + Files.createDirectories(temp); + DiskFileItemFactory factory = DiskFileItemFactory.builder().setThreshold(maxMemSize).setPath(temp).get(); + JakartaServletDiskFileUpload upload = new JakartaServletDiskFileUpload(factory); + upload.setHeaderCharset(StandardCharsets.UTF_8); + return upload; + } + /** * Processes a request to upload a file. * @@ -162,21 +181,14 @@ protected void deleteFilesFromServer(String[] files) { */ protected List processRequest(@Context ServletContext context, @Context HttpServletRequest request, String insightId) throws FileUploadException { - String tempFilePath = context.getInitParameter(TEMP_FILE_UPLOAD_KEY); - tempFilePath = normalizeAndCreatePath(tempFilePath); - - List fileItems = null; - DiskFileItemFactory factory = DiskFileItemFactory.builder() - // maximum size that will be stored in memory - .setThreshold(maxMemSize) - // Location to save data that is larger than maxMemSize. - .setPath(Path.of(tempFilePath)).get(); - // Create a new file upload handler - JakartaServletDiskFileUpload upload = new JakartaServletDiskFileUpload(factory); + JakartaServletDiskFileUpload upload; + try { + upload = createUploadHandler(context); + } catch (IOException e) { + throw new FileUploadException("Unable to prepare temporary upload directory", e); + } // maximum file size to be uploaded. upload.setMaxSize(maxFileSize); - // set encoding as well for the request - upload.setHeaderCharset(StandardCharsets.UTF_8); // make sure the insight id is valid if present if (insightId != null) { if (InsightStore.getInstance().get(insightId) == null) { @@ -190,8 +202,7 @@ protected List processRequest(@Context ServletContext context, @Co upload.setProgressListener(progressListener); // Parse the request to get file items - fileItems = upload.parseRequest(request); - return fileItems; + return upload.parseRequest(request); } /** diff --git a/src/prerna/upload/CatalogImageUploader.java b/src/prerna/upload/CatalogImageUploader.java index 38fc9edd..25532076 100644 --- a/src/prerna/upload/CatalogImageUploader.java +++ b/src/prerna/upload/CatalogImageUploader.java @@ -29,7 +29,6 @@ import java.io.IOException; import java.io.InputStream; -import java.nio.charset.StandardCharsets; import java.nio.file.AtomicMoveNotSupportedException; import java.nio.file.Files; import java.nio.file.Path; @@ -47,7 +46,6 @@ import javax.imageio.stream.MemoryCacheImageInputStream; import org.apache.commons.fileupload2.core.DiskFileItem; -import org.apache.commons.fileupload2.core.DiskFileItemFactory; import org.apache.commons.fileupload2.core.FileUploadException; import org.apache.commons.fileupload2.core.FileUploadSizeException; import org.apache.commons.fileupload2.jakarta.servlet6.JakartaServletDiskFileUpload; @@ -165,12 +163,7 @@ public static Response upload(ServletContext context, HttpServletRequest request private static List parseRequest(ServletContext context, HttpServletRequest request) throws IOException { - String configuredTemp = context.getInitParameter(Uploader.TEMP_FILE_UPLOAD_KEY); - Path temp = configuredTemp == null ? Path.of(System.getProperty("java.io.tmpdir")) : Path.of(configuredTemp); - Files.createDirectories(temp); - DiskFileItemFactory factory = DiskFileItemFactory.builder().setThreshold(8 * 1024).setPath(temp).get(); - JakartaServletDiskFileUpload upload = new JakartaServletDiskFileUpload(factory); - upload.setHeaderCharset(StandardCharsets.UTF_8); + JakartaServletDiskFileUpload upload = AbstractUploader.createUploadHandler(context); upload.setMaxFileSize(MAX_IMAGE_BYTES); upload.setMaxSize(MAX_IMAGE_BYTES + 16 * 1024); upload.setMaxFileCount(1); diff --git a/src/prerna/upload/FileUploader.java b/src/prerna/upload/FileUploader.java index 36908b1a..bbbf40e1 100644 --- a/src/prerna/upload/FileUploader.java +++ b/src/prerna/upload/FileUploader.java @@ -87,7 +87,7 @@ @Path("/uploadFile") @PermitAll -public class FileUploader extends Uploader { +public class FileUploader extends AbstractUploader { private static final long serialVersionUID = 1L; diff --git a/src/prerna/upload/ImageUploader.java b/src/prerna/upload/ImageUploader.java index 60cbc009..80b853e4 100644 --- a/src/prerna/upload/ImageUploader.java +++ b/src/prerna/upload/ImageUploader.java @@ -71,7 +71,7 @@ @Path("/images") @PermitAll -public class ImageUploader extends Uploader { +public class ImageUploader extends AbstractUploader { private static final Logger classLogger = LogManager.getLogger(ImageUploader.class); @@ -124,7 +124,7 @@ public Response uploadEngineImage(@Context ServletContext context, @Context Http try { for (DiskFileItem fi : fileItems) { String fieldName = fi.getFieldName(); - String value = WebUtility.inputSanitizer(Uploader.convertToString(fi.getReader())); + String value = WebUtility.inputSanitizer(AbstractUploader.convertToString(fi.getReader())); if (fieldName.equals("file")) { imageFile = fi; } @@ -480,7 +480,7 @@ public Response uploadProjectImage(@Context ServletContext context, @Context Htt try { for (DiskFileItem fi : fileItems) { String fieldName = fi.getFieldName(); - String value = WebUtility.inputSanitizer(Uploader.convertToString(fi.getReader())); + String value = WebUtility.inputSanitizer(AbstractUploader.convertToString(fi.getReader())); if (fieldName.equals("file")) { imageFile = fi; } @@ -736,7 +736,7 @@ public Response uploadInsightImage(@Context ServletContext context, @Context Htt try { for (DiskFileItem fi : fileItems) { String fieldName = fi.getFieldName(); - String value = WebUtility.inputSanitizer(Uploader.convertToString(fi.getReader())); + String value = WebUtility.inputSanitizer(AbstractUploader.convertToString(fi.getReader())); if (fieldName.equals("file")) { imageFile = fi; } diff --git a/src/prerna/web/services/util/CatalogImageResponse.java b/src/prerna/web/services/util/CatalogImageResponse.java new file mode 100644 index 00000000..5b262fe4 --- /dev/null +++ b/src/prerna/web/services/util/CatalogImageResponse.java @@ -0,0 +1,111 @@ +/******************************************************************************* + * Copyright 2015 Defense Health Agency (DHA) + * + * If your use of this software does not include any GPLv2 components: + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * ---------------------------------------------------------------------------- + * If your use of this software includes any GPLv2 components: + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + *******************************************************************************/ +package prerna.web.services.util; + +import java.io.ByteArrayInputStream; +import java.io.File; + +import jakarta.ws.rs.core.CacheControl; +import jakarta.ws.rs.core.EntityTag; +import jakarta.ws.rs.core.HttpHeaders; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Request; +import jakarta.ws.rs.core.Response; +import jakarta.ws.rs.core.Response.ResponseBuilder; +import prerna.util.MimeTypeUtility; + +/** + * Image content types and browser caching for authenticated image downloads. + */ +public final class CatalogImageResponse { + + private static final int MAX_AGE_SECONDS = 300; + // Re-fetch responses cached before image Content-Type was set explicitly. + private static final String IMAGE_ETAG_PREFIX = "image-v2-"; + + private CatalogImageResponse() { + } + + /** + * Sets the type from the returned image, so a browser's preference for SVG + * cannot cause PNG or JPEG bytes to be labeled as SVG during negotiation. + * Handles both local/cluster files and CouchDB byte responses. + */ + public static Response withContentType(Response response) { + if (response.getStatus() != Response.Status.OK.getStatusCode() || response.getMediaType() != null) { + return response; + } + String contentType; + Object entity = response.getEntity(); + if (entity instanceof File file) { + contentType = MimeTypeUtility.detectMimeType(file); + } else if (entity instanceof byte[] bytes) { + contentType = MimeTypeUtility.detectMimeType(new ByteArrayInputStream(bytes), null); + } else { + return response; + } + return Response.fromResponse(response) + .type(contentType == null ? MediaType.APPLICATION_OCTET_STREAM : contentType).build(); + } + + /** + * Reuses a fresh image for five minutes, then validates its ETag. Call only + * after authorizing the resource. Theme and upload revision query parameters + * distinguish image URLs; Vary separates browser entries between sessions. + * Applies the same cache policy to 200 and 304, preserving other responses. + */ + public static Response withBrowserCache(Request request, Response response) { + EntityTag etag = response.getEntityTag(); + if (response.getStatus() != Response.Status.OK.getStatusCode() || etag == null) { + return withContentType(response); + } + etag = new EntityTag(IMAGE_ETAG_PREFIX + etag.getValue(), etag.isWeak()); + + ResponseBuilder builder = request.evaluatePreconditions(etag); + if (builder == null) { + builder = Response.fromResponse(withContentType(response)); + } else { + Response conditional = builder.tag(etag).build(); + if (conditional.getStatus() != Response.Status.NOT_MODIFIED.getStatusCode()) { + return conditional; + } + builder = Response.fromResponse(conditional); + String vary = response.getHeaderString(HttpHeaders.VARY); + if (vary != null) { + builder.header(HttpHeaders.VARY, vary); + } + } + + CacheControl cache = new CacheControl(); + cache.setPrivate(true); + cache.setMaxAge(MAX_AGE_SECONDS); + cache.setMustRevalidate(true); + cache.setNoTransform(false); + return builder.tag(etag).cacheControl(cache).header(HttpHeaders.VARY, "Cookie, Authorization").build(); + } +}