From 2a9d5c941f364aaf59c7d9e6717447cf1758743e Mon Sep 17 00:00:00 2001 From: Mario Serrano Date: Fri, 15 May 2026 12:35:04 -0500 Subject: [PATCH 01/22] feat: refactor entity file storage controller and enhance URL generation for entity files --- extensions/entity-files/sources/core/pom.xml | 8 +- .../EntityFileSecurityProvider.java | 21 ++ .../modules/entityfile/StoredEntityFile.java | 77 ++++--- .../modules/entityfile/UploadedFileInfo.java | 9 + .../EntityFileStorageController.java | 152 ++++++++++++++ .../LocalEntityFileStorageController.java | 34 --- .../modules/entityfile/domain/EntityFile.java | 10 +- .../local/LocalEntityFileStorage.java | 21 +- .../local/LocalEntityFileStorageHandler.java | 14 +- .../entityfile/service/EntityFileService.java | 198 ++++++++---------- .../service/impl/EntityFileServiceImpl.java | 5 + extensions/entity-files/sources/s3/pom.xml | 4 +- .../entityfiles/s3/S3EntityFileStorage.java | 82 +++++++- .../modules/entityfiles/s3/S3Utils.java | 30 ++- extensions/entity-files/sources/ui/pom.xml | 6 +- .../ReloadEntityFileStoragesAction.java | 46 ++++ .../ui/actions/ViewFileURLAction.java | 2 +- .../ui/components/EntityFileImage.java | 5 +- .../META-INF/descriptors/EntityFileConfig.yml | 14 +- .../META-INF/descriptors/EntityFileTree.yml | 2 +- 20 files changed, 529 insertions(+), 211 deletions(-) create mode 100644 extensions/entity-files/sources/core/src/main/java/tools/dynamia/modules/entityfile/EntityFileSecurityProvider.java create mode 100644 extensions/entity-files/sources/core/src/main/java/tools/dynamia/modules/entityfile/controller/EntityFileStorageController.java delete mode 100644 extensions/entity-files/sources/core/src/main/java/tools/dynamia/modules/entityfile/controller/LocalEntityFileStorageController.java create mode 100644 extensions/entity-files/sources/ui/src/main/java/tools/dynamia/modules/entityfile/ui/actions/ReloadEntityFileStoragesAction.java diff --git a/extensions/entity-files/sources/core/pom.xml b/extensions/entity-files/sources/core/pom.xml index 1027a48c..0378e15d 100644 --- a/extensions/entity-files/sources/core/pom.xml +++ b/extensions/entity-files/sources/core/pom.xml @@ -22,7 +22,7 @@ tools.dynamia.modules.entityfiles.parent tools.dynamia.modules - 26.4.1 + 26.5.0 DynamiaModules - EntityFiles - Core tools.dynamia.modules.entityfiles @@ -54,20 +54,20 @@ tools.dynamia tools.dynamia.domain.jpa - 26.4.1 + 26.5.0 jar tools.dynamia tools.dynamia.io - 26.4.1 + 26.5.0 jar tools.dynamia tools.dynamia.web - 26.4.1 + 26.5.0 jar diff --git a/extensions/entity-files/sources/core/src/main/java/tools/dynamia/modules/entityfile/EntityFileSecurityProvider.java b/extensions/entity-files/sources/core/src/main/java/tools/dynamia/modules/entityfile/EntityFileSecurityProvider.java new file mode 100644 index 00000000..88a5397f --- /dev/null +++ b/extensions/entity-files/sources/core/src/main/java/tools/dynamia/modules/entityfile/EntityFileSecurityProvider.java @@ -0,0 +1,21 @@ +package tools.dynamia.modules.entityfile; + +import tools.dynamia.modules.entityfile.domain.EntityFile; + +/** + * Simple security provider interface to control access to entity files. + * Implement this interface and register as a bean in the application context to control access to entity files. + * The canAccess method will be called before allowing access to an entity file, and you can implement your custom logic + * to determine if the access should be granted or denied based on the properties of the EntityFile object. + */ +public interface EntityFileSecurityProvider { + + /** + * + * @param entityFile + * @return + */ + boolean canAccess(EntityFile entityFile); + + +} diff --git a/extensions/entity-files/sources/core/src/main/java/tools/dynamia/modules/entityfile/StoredEntityFile.java b/extensions/entity-files/sources/core/src/main/java/tools/dynamia/modules/entityfile/StoredEntityFile.java index 5040a075..79667dac 100644 --- a/extensions/entity-files/sources/core/src/main/java/tools/dynamia/modules/entityfile/StoredEntityFile.java +++ b/extensions/entity-files/sources/core/src/main/java/tools/dynamia/modules/entityfile/StoredEntityFile.java @@ -20,41 +20,66 @@ import java.io.File; import java.io.Serializable; +import org.springframework.core.io.FileSystemResource; +import org.springframework.core.io.Resource; +import tools.dynamia.io.VirtualFile; import tools.dynamia.modules.entityfile.domain.EntityFile; public abstract class StoredEntityFile implements Serializable { - /** - * - */ - private static final long serialVersionUID = 421213041955145817L; - private EntityFile entityFile; - private String url; - private File realFile; + /** + * + */ + private static final long serialVersionUID = 421213041955145817L; + private EntityFile entityFile; + private String url; + private File realFile; - public StoredEntityFile(EntityFile entityFile, String url, File realFile) { - super(); - this.entityFile = entityFile; - this.url = url; - this.realFile = realFile; - } + public StoredEntityFile(EntityFile entityFile, String url, File realFile) { + super(); + this.entityFile = entityFile; + this.url = url; + this.realFile = realFile; + } - public EntityFile getEntityFile() { - return entityFile; - } + public EntityFile getEntityFile() { + return entityFile; + } - public String getUrl() { - return url; - } + public String getUrl() { + return url; + } - public String getThumbnailUrl() { - return getThumbnailUrl(200, 200); - } + public String getThumbnailUrl() { + return getThumbnailUrl(200, 200); + } - public abstract String getThumbnailUrl(int width, int height); + public abstract String getThumbnailUrl(int width, int height); - public File getRealFile() { - return realFile; - } + public File getRealFile() { + return realFile; + } + public File getThumbnailFile(int width, int height) { + return realFile; + } + + public Resource toResource() { + if (realFile != null) { + if (realFile.exists() && realFile.isFile()) { + return new FileSystemResource(realFile); + } + } + return null; + } + + public Resource toThumbnailResource(int width, int height) { + File thumbnailFile = getThumbnailFile(width, height); + if (thumbnailFile != null) { + if (thumbnailFile.exists() && thumbnailFile.isFile()) { + return new FileSystemResource(thumbnailFile); + } + } + return null; + } } diff --git a/extensions/entity-files/sources/core/src/main/java/tools/dynamia/modules/entityfile/UploadedFileInfo.java b/extensions/entity-files/sources/core/src/main/java/tools/dynamia/modules/entityfile/UploadedFileInfo.java index fd5d101c..41517872 100644 --- a/extensions/entity-files/sources/core/src/main/java/tools/dynamia/modules/entityfile/UploadedFileInfo.java +++ b/extensions/entity-files/sources/core/src/main/java/tools/dynamia/modules/entityfile/UploadedFileInfo.java @@ -55,6 +55,11 @@ public UploadedFileInfo(FileInfo info) { this.fullName = info.getName(); this.length = info.getFile().length(); this.source = info.getFile(); + try { + this.contentType = Files.probeContentType(info.getFile().toPath()); + } catch (IOException e) { + + } } public UploadedFileInfo(Path path) { @@ -83,6 +88,10 @@ public UploadedFileInfo(String fullName, String contentType, InputStream inputSt this.source = inputStream; } + public void detectContentType() { + + } + public String getStoredFileName() { return storedFileName; } diff --git a/extensions/entity-files/sources/core/src/main/java/tools/dynamia/modules/entityfile/controller/EntityFileStorageController.java b/extensions/entity-files/sources/core/src/main/java/tools/dynamia/modules/entityfile/controller/EntityFileStorageController.java new file mode 100644 index 00000000..7a30d311 --- /dev/null +++ b/extensions/entity-files/sources/core/src/main/java/tools/dynamia/modules/entityfile/controller/EntityFileStorageController.java @@ -0,0 +1,152 @@ +package tools.dynamia.modules.entityfile.controller; + +import jakarta.servlet.http.HttpServletRequest; +import org.jspecify.annotations.NonNull; +import org.springframework.core.io.Resource; +import org.springframework.http.*; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestParam; +import tools.dynamia.integration.Containers; +import tools.dynamia.integration.sterotypes.Controller; +import tools.dynamia.io.IOUtils; +import tools.dynamia.io.impl.SpringResource; +import tools.dynamia.modules.entityfile.EntityFileAccountProvider; +import tools.dynamia.modules.entityfile.EntityFileSecurityProvider; +import tools.dynamia.modules.entityfile.EntityFileStorage; +import tools.dynamia.modules.entityfile.StoredEntityFile; +import tools.dynamia.modules.entityfile.domain.EntityFile; +import tools.dynamia.modules.entityfile.enums.EntityFileType; +import tools.dynamia.modules.entityfile.local.LocalEntityFileStorage; +import tools.dynamia.modules.entityfile.local.LocalEntityFileStorageHandler; +import tools.dynamia.modules.entityfile.service.EntityFileService; + +import java.io.File; +import java.util.concurrent.TimeUnit; + +import static tools.dynamia.modules.entityfile.local.LocalEntityFileStorageHandler.getParam; +import static tools.dynamia.modules.entityfile.local.LocalEntityFileStorageHandler.isThumbnail; + +@Controller +public class EntityFileStorageController { + + private final EntityFileService entityFileService; + + public EntityFileStorageController(EntityFileService entityFileService) { + this.entityFileService = entityFileService; + + } + + @GetMapping(value = "/storage/{uuid}/{file}") + public ResponseEntity get(@PathVariable("uuid") String uuid, @PathVariable String file, HttpServletRequest request) { + var entityFile = entityFileService.getEntityFile(uuid); + if (entityFile == null) { + return ResponseEntity.notFound().build(); + } + + EntityFileAccountProvider accountProvider = Containers.get().findObject(EntityFileAccountProvider.class); + if (accountProvider != null) { + if (entityFile.getAccountId() != null) { + if (!entityFile.getAccountId().equals(accountProvider.getAccountId())) { + return ResponseEntity.notFound().build(); + } + } + } + + if (!entityFile.isShared()) { + EntityFileSecurityProvider securityProvider = Containers.get().findObject(EntityFileSecurityProvider.class); + if (securityProvider != null && !securityProvider.canAccess(entityFile)) { + return ResponseEntity.status(HttpStatus.FORBIDDEN) + .build(); + } + } + + Resource resource = null; + var storedEntityFile = entityFile.getStoredEntityFile(); + var etag = "v" + entityFile.currentVersion(); + + if (entityFile.getType() == EntityFileType.IMAGE && isThumbnail(request)) { + String w = getParam(request, "w", "200"); + String h = getParam(request, "h", "200"); + etag += "-thumb-" + w + "x" + h; + resource = storedEntityFile.toThumbnailResource(safeSize(w, 200), safeSize(h, 200)); + } else { + resource = storedEntityFile.toResource(); + } + + + if (resource != null && resource.exists() && resource.isReadable()) { + var contentType = getMediaType(entityFile); + + CacheControl cacheControl; + if (entityFile.isShared()) { + cacheControl = CacheControl + .maxAge(365, TimeUnit.DAYS) + .cachePublic() + .immutable(); + } else { + cacheControl = CacheControl + .maxAge(365, TimeUnit.DAYS) + .cachePrivate() + .immutable(); + } + + String ifNoneMatch = request.getHeader(HttpHeaders.IF_NONE_MATCH); + + if (etag.equals(ifNoneMatch)) { + return ResponseEntity + .status(HttpStatus.NOT_MODIFIED) + .eTag(etag) + .cacheControl(cacheControl) + .build(); + } + + return ResponseEntity.ok() + .contentType(contentType) + .cacheControl(cacheControl) + .header(HttpHeaders.ETAG, etag) + .body(resource); + } else { + return ResponseEntity.notFound().build(); + } + } + + private static @NonNull MediaType getMediaType(EntityFile entityFile) { + var contentType = MediaType.APPLICATION_OCTET_STREAM; + try { + + if (entityFile.getContentType() != null) { + contentType = MediaType.parseMediaType(entityFile.getContentType()); + } else { + contentType = switch (entityFile.getExtension().toLowerCase()) { + case "jpg", "jpeg" -> MediaType.IMAGE_JPEG; + case "png" -> MediaType.IMAGE_PNG; + case "gif" -> MediaType.IMAGE_GIF; + case "pdf" -> MediaType.APPLICATION_PDF; + case "doc" -> MediaType.TEXT_HTML; + case "docx" -> MediaType.TEXT_XML; + case "xlsx" -> MediaType.TEXT_XML; + case "xls" -> MediaType.TEXT_XML; + case "pptx" -> MediaType.TEXT_XML; + case "ppt" -> MediaType.TEXT_XML; + case "txt" -> MediaType.TEXT_PLAIN; + case "html" -> MediaType.TEXT_HTML; + case "json" -> MediaType.APPLICATION_JSON; + default -> MediaType.APPLICATION_OCTET_STREAM; + }; + } + + } catch (Exception e) { + + } + return contentType; + } + + private int safeSize(String value, int def) { + try { + return Math.min(Math.max(Integer.parseInt(value), 1), 2000); + } catch (Exception e) { + return def; + } + } +} diff --git a/extensions/entity-files/sources/core/src/main/java/tools/dynamia/modules/entityfile/controller/LocalEntityFileStorageController.java b/extensions/entity-files/sources/core/src/main/java/tools/dynamia/modules/entityfile/controller/LocalEntityFileStorageController.java deleted file mode 100644 index 7b9fb793..00000000 --- a/extensions/entity-files/sources/core/src/main/java/tools/dynamia/modules/entityfile/controller/LocalEntityFileStorageController.java +++ /dev/null @@ -1,34 +0,0 @@ -package tools.dynamia.modules.entityfile.controller; - -import jakarta.servlet.http.HttpServletRequest; -import org.springframework.core.io.Resource; -import org.springframework.http.HttpHeaders; -import org.springframework.http.HttpStatus; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.RequestParam; -import tools.dynamia.integration.sterotypes.Controller; -import tools.dynamia.modules.entityfile.local.LocalEntityFileStorageHandler; - -@Controller -public class LocalEntityFileStorageController { - - private final LocalEntityFileStorageHandler handler; - - public LocalEntityFileStorageController(LocalEntityFileStorageHandler handler) { - this.handler = handler; - } - - @GetMapping(value = "/storage/{file}") - public ResponseEntity get(@PathVariable String file, @RequestParam("uuid") String uuid, HttpServletRequest request) { - var resource = handler.getResource(file, uuid, request); - if (resource != null && resource.exists() && resource.isReadable()) { - return ResponseEntity.ok() - .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + resource.getFilename() + "\"") - .body(resource); - } else { - return new ResponseEntity<>(HttpStatus.NOT_FOUND); - } - } -} diff --git a/extensions/entity-files/sources/core/src/main/java/tools/dynamia/modules/entityfile/domain/EntityFile.java b/extensions/entity-files/sources/core/src/main/java/tools/dynamia/modules/entityfile/domain/EntityFile.java index f8f5dbdc..747b9b89 100644 --- a/extensions/entity-files/sources/core/src/main/java/tools/dynamia/modules/entityfile/domain/EntityFile.java +++ b/extensions/entity-files/sources/core/src/main/java/tools/dynamia/modules/entityfile/domain/EntityFile.java @@ -28,6 +28,7 @@ import tools.dynamia.modules.entityfile.StoredEntityFile; import tools.dynamia.modules.entityfile.domain.enums.EntityFileState; import tools.dynamia.modules.entityfile.enums.EntityFileType; +import tools.dynamia.modules.entityfile.local.LocalEntityFileStorage; import tools.dynamia.modules.entityfile.service.EntityFileService; import jakarta.persistence.*; @@ -273,7 +274,13 @@ public String toURL() { if (remoteURL != null && !remoteURL.isBlank()) { return remoteURL; } - return getStoredEntityFile().getUrl(); + LocalEntityFileStorage storage = Containers.get().findObject(LocalEntityFileStorage.class); + return storage.generateURL(this); + } + + public String toThumbnailURL( int w, int h) { + String url = toURL(); + return url+"?w="+w+"&h="+h; } public String getExternalRef() { @@ -309,4 +316,5 @@ public boolean isUploading() { public void setUploading(boolean uploading) { this.uploading = uploading; } + } diff --git a/extensions/entity-files/sources/core/src/main/java/tools/dynamia/modules/entityfile/local/LocalEntityFileStorage.java b/extensions/entity-files/sources/core/src/main/java/tools/dynamia/modules/entityfile/local/LocalEntityFileStorage.java index fa255e57..b72c2b6b 100644 --- a/extensions/entity-files/sources/core/src/main/java/tools/dynamia/modules/entityfile/local/LocalEntityFileStorage.java +++ b/extensions/entity-files/sources/core/src/main/java/tools/dynamia/modules/entityfile/local/LocalEntityFileStorage.java @@ -31,10 +31,12 @@ import tools.dynamia.modules.entityfile.UploadedFileInfo; import tools.dynamia.modules.entityfile.domain.EntityFile; import tools.dynamia.modules.entityfile.domain.enums.EntityFileState; +import tools.dynamia.modules.entityfile.service.EntityFileService; import tools.dynamia.web.util.HttpUtils; import java.io.File; import java.io.IOException; +import java.io.Serial; @Service public class LocalEntityFileStorage implements EntityFileStorage { @@ -54,10 +56,13 @@ public class LocalEntityFileStorage implements EntityFileStorage { private final Environment environment; - public LocalEntityFileStorage(Parameters appParams, CrudService crudService, Environment environment) { + private final EntityFileService entityFileService; + + public LocalEntityFileStorage(Parameters appParams, CrudService crudService, Environment environment, EntityFileService entityFileService) { this.appParams = appParams; this.crudService = crudService; this.environment = environment; + this.entityFileService = entityFileService; } @Override @@ -93,7 +98,7 @@ public StoredEntityFile download(EntityFile entityFile) { return sef; } - private String generateURL(EntityFile entityFile) { + public String generateURL(EntityFile entityFile) { String serverPath = HttpUtils.getServerPath(); boolean useHttps = isUseHttps(); @@ -111,9 +116,8 @@ private String generateURL(EntityFile entityFile) { String fileName = entityFile.getName(); fileName = fileName.replace(" ", "%20"); - String url = serverPath + context + LOCAL_FILE_HANDLER + fileName + "?uuid=" + entityFile.getUuid(); - return url; + return serverPath + context + LOCAL_FILE_HANDLER + entityFile.getUuid() + "/" + fileName; } private String getContextPath() { @@ -192,16 +196,16 @@ public void delete(EntityFile entityFile) { } } - private class LocalStoredEntityFile extends StoredEntityFile { + public static class LocalStoredEntityFile extends StoredEntityFile { /** * */ + @Serial private static final long serialVersionUID = -6295813096900514353L; public LocalStoredEntityFile(EntityFile entityFile, String url, File realFile) { super(entityFile, url, realFile); - // TODO Auto-generated constructor stub } @Override @@ -209,6 +213,11 @@ public String getThumbnailUrl(int width, int height) { return getUrl() + "&w=" + width + "&h=" + height; } + @Override + public File getThumbnailFile(int width, int height) { + return LocalEntityFileStorageHandler.createThumbnail(getRealFile(), getEntityFile(), width, height); + } + } public void copy(EntityFile entityFile, EntityFileStorage otherStorage) { diff --git a/extensions/entity-files/sources/core/src/main/java/tools/dynamia/modules/entityfile/local/LocalEntityFileStorageHandler.java b/extensions/entity-files/sources/core/src/main/java/tools/dynamia/modules/entityfile/local/LocalEntityFileStorageHandler.java index 21dde1b2..19ab25d7 100644 --- a/extensions/entity-files/sources/core/src/main/java/tools/dynamia/modules/entityfile/local/LocalEntityFileStorageHandler.java +++ b/extensions/entity-files/sources/core/src/main/java/tools/dynamia/modules/entityfile/local/LocalEntityFileStorageHandler.java @@ -21,6 +21,7 @@ import jakarta.servlet.http.HttpServletRequest; +import org.jspecify.annotations.NonNull; import org.springframework.core.io.FileSystemResource; import org.springframework.core.io.Resource; @@ -63,7 +64,6 @@ public Resource getResource(String fileName, String uuid, HttpServletRequest req EntityFile entityFile = service.getEntityFile(uuid); - if (entityFile != null && (currentAccountId == null || currentAccountId.equals(0L) || entityFile.isShared() || entityFile.getAccountId().equals(currentAccountId))) { StoredEntityFile storedEntityFile = storage.download(entityFile); @@ -89,7 +89,7 @@ public Resource getResource(String fileName, String uuid, HttpServletRequest req } } - private boolean isThumbnail(HttpServletRequest request) { + public static boolean isThumbnail(HttpServletRequest request) { return getParam(request, "w", null) != null && getParam(request, "h", null) != null; } @@ -97,19 +97,23 @@ private File createOrLoadThumbnail(File realImg, EntityFile entityFile, HttpServ String w = getParam(request, "w", "200"); String h = getParam(request, "h", "200"); + return createThumbnail(realImg, entityFile, Integer.parseInt(w), Integer.parseInt(h)); + + } + + public static @NonNull File createThumbnail(File realImg, EntityFile entityFile, int w, int h) { String subfolder = w + "x" + h; File realThumbImg = new File(realImg.getParentFile(), subfolder + "/" + realImg.getName()); if (!realThumbImg.exists()) { if (realImg.exists()) { - ImageUtil.resizeImage(realImg, realThumbImg, entityFile.getExtension(), Integer.parseInt(w), Integer.parseInt(h)); + ImageUtil.resizeImage(realImg, realThumbImg, entityFile.getExtension(), w, h); } } return realThumbImg; - } - public String getParam(HttpServletRequest request, String name, String defaultValue) { + public static String getParam(HttpServletRequest request, String name, String defaultValue) { String value = request.getParameter(name); if (value == null || value.trim().isEmpty()) { value = defaultValue; diff --git a/extensions/entity-files/sources/core/src/main/java/tools/dynamia/modules/entityfile/service/EntityFileService.java b/extensions/entity-files/sources/core/src/main/java/tools/dynamia/modules/entityfile/service/EntityFileService.java index bd43b4a2..49941cb2 100644 --- a/extensions/entity-files/sources/core/src/main/java/tools/dynamia/modules/entityfile/service/EntityFileService.java +++ b/extensions/entity-files/sources/core/src/main/java/tools/dynamia/modules/entityfile/service/EntityFileService.java @@ -23,6 +23,7 @@ import java.io.Serializable; import java.util.List; +import tools.dynamia.modules.entityfile.EntityFileStorage; import tools.dynamia.modules.entityfile.StoredEntityFile; import tools.dynamia.modules.entityfile.UploadedFileInfo; import tools.dynamia.modules.entityfile.domain.EntityFile; @@ -34,140 +35,121 @@ */ public interface EntityFileService { - /** - * Creates the directory. - * - * @param ownerEntity - * the owner entity - * @param name - * the name - * @param description - * the description - * @return the entity file - */ + /** + * Creates the directory. + * + * @param ownerEntity the owner entity + * @param name the name + * @param description the description + * @return the entity file + */ EntityFile createDirectory(Object ownerEntity, String name, String description); - /** - * Creates the directory. - * - * @param parent - * the parent - * @param name - * the name - * @param description - * the description - * @return the entity file - */ + /** + * Creates the directory. + * + * @param parent the parent + * @param name the name + * @param description the description + * @return the entity file + */ EntityFile createDirectory(EntityFile parent, String name, String description); - /** - * Creates the entity file. - * - * @param fileInfo - * the file info - * @param target - * the target - * @param desc - * the desc - * @return the entity file - */ + /** + * Creates the entity file. + * + * @param fileInfo the file info + * @param target the target + * @param desc the desc + * @return the entity file + */ EntityFile createEntityFile(UploadedFileInfo fileInfo, Object target, String desc); - /** - * Creates the entity file. - * - * @param fileInfo - * the file info - * @param targetEntity - * the target entity - * @return the entity file - */ + /** + * Creates the entity file. + * + * @param fileInfo the file info + * @param targetEntity the target entity + * @return the entity file + */ EntityFile createEntityFile(UploadedFileInfo fileInfo, Object targetEntity); - /** - * Gets the entity files. - * - * @param clazz - * the clazz - * @param id - * the id - * @param parentDirectory - * the parent directory - * @return the entity files - */ + /** + * Gets the entity files. + * + * @param clazz the clazz + * @param id the id + * @param parentDirectory the parent directory + * @return the entity files + */ List getEntityFiles(Class clazz, Serializable id, EntityFile parentDirectory); - /** - * Gets the entity files. - * - * @param entity - * the entity - * @param parentDirectory - * the parent directory - * @return the entity files - */ + /** + * Gets the entity files. + * + * @param entity the entity + * @param parentDirectory the parent directory + * @return the entity files + */ List getEntityFiles(Object entity, EntityFile parentDirectory); - /** - * Gets the entity files. - * - * @param entity - * the entity - * @return the entity files - */ + /** + * Gets the entity files. + * + * @param entity the entity + * @return the entity files + */ List getEntityFiles(Object entity); - /** - * Delete. - * - * @param entityFile - * the entity file - */ + /** + * Delete. + * + * @param entityFile the entity file + */ void delete(EntityFile entityFile); - /** - * Sync entity file aware. - */ + /** + * Sync entity file aware. + */ void syncEntityFileAware(); - /** - * Get an stored entity file instance for download - * @param entityFile - * @return - */ + /** + * Get an stored entity file instance for download + * + * @param entityFile + * @return + */ StoredEntityFile download(EntityFile entityFile); - /** - * Download the EntityFile internal file to a local output file, this is - * usefull when entityfiles are stored in difernte localtion - * - * @param entityFile - * @param outputFile - */ + /** + * Download the EntityFile internal file to a local output file, this is + * usefull when entityfiles are stored in difernte localtion + * + * @param entityFile + * @param outputFile + */ void download(EntityFile entityFile, File outputFile); - /** - * Creates the temporal entity file. - * - * @param fileInfo - * the file info - * @return the entity file - */ + /** + * Creates the temporal entity file. + * + * @param fileInfo the file info + * @return the entity file + */ EntityFile createTemporalEntityFile(UploadedFileInfo fileInfo); - /** - * Configure entity file. Setup targetEntity and targetEntityId for - * EntityFile - * - * @param target - * the target - * @param entityFile - * the entity file - */ + /** + * Configure entity file. Setup targetEntity and targetEntityId for + * EntityFile + * + * @param target the target + * @param entityFile the entity file + */ void configureEntityFile(Object target, EntityFile entityFile); - void syncEntityFileAware(Object target); + void syncEntityFileAware(Object target); - EntityFile getEntityFile(String uuid); + EntityFile getEntityFile(String uuid); + EntityFileStorage getStorage(String name); } diff --git a/extensions/entity-files/sources/core/src/main/java/tools/dynamia/modules/entityfile/service/impl/EntityFileServiceImpl.java b/extensions/entity-files/sources/core/src/main/java/tools/dynamia/modules/entityfile/service/impl/EntityFileServiceImpl.java index 83946195..60c96d0b 100644 --- a/extensions/entity-files/sources/core/src/main/java/tools/dynamia/modules/entityfile/service/impl/EntityFileServiceImpl.java +++ b/extensions/entity-files/sources/core/src/main/java/tools/dynamia/modules/entityfile/service/impl/EntityFileServiceImpl.java @@ -334,6 +334,11 @@ private EntityFileStorage getCurrentStorage() { return storage; } + @Override + public EntityFileStorage getStorage(String name) { + return findStorage(name); + } + private EntityFileStorage findStorage(String storageId) { Optional storage = Containers.get().findObjects(EntityFileStorage.class) .stream() diff --git a/extensions/entity-files/sources/s3/pom.xml b/extensions/entity-files/sources/s3/pom.xml index 01444438..8444e78d 100644 --- a/extensions/entity-files/sources/s3/pom.xml +++ b/extensions/entity-files/sources/s3/pom.xml @@ -23,7 +23,7 @@ tools.dynamia.modules tools.dynamia.modules.entityfiles.parent - 26.4.1 + 26.5.0 DynamiaModules - EntityFiles - S3 @@ -49,7 +49,7 @@ tools.dynamia.modules tools.dynamia.modules.entityfiles - 26.4.1 + 26.5.0 software.amazon.awssdk diff --git a/extensions/entity-files/sources/s3/src/main/java/tools/dynamia/modules/entityfiles/s3/S3EntityFileStorage.java b/extensions/entity-files/sources/s3/src/main/java/tools/dynamia/modules/entityfiles/s3/S3EntityFileStorage.java index eed5a560..01caca64 100644 --- a/extensions/entity-files/sources/s3/src/main/java/tools/dynamia/modules/entityfiles/s3/S3EntityFileStorage.java +++ b/extensions/entity-files/sources/s3/src/main/java/tools/dynamia/modules/entityfiles/s3/S3EntityFileStorage.java @@ -22,9 +22,15 @@ import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.core.env.Environment; +import org.springframework.core.io.InputStreamResource; +import org.springframework.core.io.Resource; import org.springframework.stereotype.Service; +import software.amazon.awssdk.core.ResponseInputStream; import software.amazon.awssdk.core.async.AsyncRequestBody; +import software.amazon.awssdk.core.async.AsyncResponseTransformer; import software.amazon.awssdk.services.s3.S3AsyncClient; +import software.amazon.awssdk.services.s3.model.GetObjectRequest; +import software.amazon.awssdk.services.s3.model.GetObjectResponse; import software.amazon.awssdk.services.s3.model.ObjectCannedACL; import software.amazon.awssdk.services.s3.model.PutObjectRequest; import software.amazon.awssdk.services.s3.presigner.model.PresignedGetObjectRequest; @@ -64,6 +70,7 @@ public class S3EntityFileStorage implements EntityFileStorage { public static final String AWS_S3_ENDPOINT = "AWS_S3_ENDPOINT"; public static final String AWS_S3_REGION = "AWS_S3_REGION"; public static final String AWS_S3_BUCKET = "AWS_S3_BUCKET"; + public static final String AWS_S3_OVERRIDE_ENDPOINT = "AWS_S3_OVERRIDE_ENDPOINT"; private static final Logger log = LoggerFactory.getLogger(S3EntityFileStorage.class); public static final int PRESIGNED_URL_TIMEOUT = 30; private final LoggingService logger = new SLF4JLoggingService(S3EntityFileStorage.class, "S3: "); @@ -148,10 +155,10 @@ public void upload(EntityFile entityFile, UploadedFileInfo fileInfo) { AsyncRequestBody body = null; if (fileToUpload != null && fileToUpload.exists()) { - logger.info("Uploading file " + fileToUpload.getPath() + " to " + key); + logger.info("Uploading file " + fileToUpload.getPath() + " to bucket [" + bucket + "] key=" + key); body = AsyncRequestBody.fromFile(fileToUpload); } else if (fileInfo.hasInputStream()) { - logger.info("Uploading input stream from " + fileInfo.getFullName() + " to " + key); + logger.info("Uploading input stream from " + fileInfo.getFullName() + " to bucket [" + bucket + "] key=" + key); body = AsyncRequestBody.fromInputStream(fileInfo.getInputStream(), length, executorService); } entityFile.setUploading(true); @@ -182,10 +189,9 @@ public StoredEntityFile download(EntityFile entityFile) { String urlKey = entityFile.getUuid(); String url = URL_CACHE.get(urlKey); String fileName = getFileName(entityFile); - + String folder = getAccountFolderName(entityFile.getAccountId()); if (url == null) { - String folder = getAccountFolderName(entityFile.getAccountId()); if (entityFile.isShared()) { url = generateStaticURL(getBucketName(), folder + fileName); URL_CACHE.add(urlKey, url); @@ -194,7 +200,7 @@ public StoredEntityFile download(EntityFile entityFile) { } } - return new S3StoredEntityFile(entityFile, url, new File(fileName)); + return new S3StoredEntityFile(entityFile, url, new File(fileName), folder + fileName); } protected String generateSignedURL(String bucketName, String fileName) { @@ -245,8 +251,13 @@ private String getFileName(EntityFile entityFile) { protected S3AsyncClient getClient() { if (s3Client == null) { - s3Client = S3Utils.buildS3AsyncClient(getAccessKey(), getSecretKey(), getRegion()) - .build(); + var endpoint = getOverrideEndpoint(); + if (endpoint != null && !endpoint.isBlank()) { + s3Client = S3Utils.buildGenericClient(getAccessKey(), getSecretKey(), endpoint); + } else { + s3Client = S3Utils.buildS3AsyncClient(getAccessKey(), getSecretKey(), getRegion()) + .build(); + } } return s3Client; @@ -300,8 +311,8 @@ protected String createAndUploadThumbnail(EntityFile entityFile, String bucketNa File localDestination = File.createTempFile(System.currentTimeMillis() + "file", entityFile.getName()); File localThumbDestination = File.createTempFile(System.currentTimeMillis() + "thumb", entityFile.getName()); - var url = download(entityFile).getUrl(); - Files.copy(new URL(url).openStream(), localDestination.toPath(), StandardCopyOption.REPLACE_EXISTING); + var resource = entityFile.getStoredEntityFile().toResource(); + Files.copy(resource.getInputStream(), localDestination.toPath(), StandardCopyOption.REPLACE_EXISTING); ImageUtil.resizeImage(localDestination, localThumbDestination, entityFile.getExtension(), w, h); // metadata @@ -366,6 +377,10 @@ public String getAccessKey() { return getParameter(AWS_ACCESS_KEY_ID); } + public String getOverrideEndpoint() { + return getParameter(AWS_S3_OVERRIDE_ENDPOINT); + } + public String getRegion() { var region = getParameter(AWS_S3_REGION); if (region == null || region.isBlank()) { @@ -398,14 +413,61 @@ public void reloadParams() { class S3StoredEntityFile extends StoredEntityFile { - public S3StoredEntityFile(EntityFile entityFile, String url, File realFile) { + private final String s3Key; + + public S3StoredEntityFile(EntityFile entityFile, String url, File realFile, String s3Key) { super(entityFile, url, realFile); + this.s3Key = s3Key; } @Override public String getThumbnailUrl(int width, int height) { return generateThumbnailURL(getEntityFile(), width, width); } + + @Override + public Resource toResource() { + ResponseInputStream stream = + s3Client.getObject( + GetObjectRequest.builder() + .bucket(getBucketName()) + .key(s3Key) + .build(), + AsyncResponseTransformer.toBlockingInputStream() + ).join(); + + return new InputStreamResource(stream); + } + + @Override + public Resource toThumbnailResource(int width, int height) { + var entityFile = getEntityFile(); + if (entityFile.getType() != EntityFileType.IMAGE && EntityFileType.getFileType(entityFile.getExtension()) != EntityFileType.IMAGE) { + return null; + } + + String folder = getAccountFolderName(entityFile.getAccountId()); + String fileName = getFileName(entityFile); + String thumbKey = folder + width + "x" + height + "/" + fileName; + + if (!objectExists(getBucketName(), thumbKey)) { + generateThumbnailURL(entityFile, width, height); + if (!objectExists(getBucketName(), thumbKey)) { + return null; + } + } + + ResponseInputStream stream = + s3Client.getObject( + GetObjectRequest.builder() + .bucket(getBucketName()) + .key(thumbKey) + .build(), + AsyncResponseTransformer.toBlockingInputStream() + ).join(); + + return new InputStreamResource(stream); + } } diff --git a/extensions/entity-files/sources/s3/src/main/java/tools/dynamia/modules/entityfiles/s3/S3Utils.java b/extensions/entity-files/sources/s3/src/main/java/tools/dynamia/modules/entityfiles/s3/S3Utils.java index c71adb5a..e49d93a2 100644 --- a/extensions/entity-files/sources/s3/src/main/java/tools/dynamia/modules/entityfiles/s3/S3Utils.java +++ b/extensions/entity-files/sources/s3/src/main/java/tools/dynamia/modules/entityfiles/s3/S3Utils.java @@ -14,6 +14,7 @@ import tools.dynamia.commons.StringUtils; import java.io.File; +import java.net.URI; import java.nio.file.Path; import java.time.Duration; import java.util.List; @@ -62,6 +63,23 @@ public static S3AsyncClientBuilder buildS3AsyncClient(String accessKey, String s } + /** + * Build a generic client with override endpoint. Useful for servers like MinIO or Seaweed + * + * @param accessKey the access key + * @param secretKey the secret key + * @param endpoint the endpoint + * @return the S3AsyncClient + */ + public static S3AsyncClient buildGenericClient(String accessKey, String secretKey, String endpoint) { + return S3AsyncClient.builder() + .credentialsProvider(() -> getCredentials(accessKey, secretKey)) + .region(Region.US_EAST_1) + .endpointOverride(URI.create(endpoint)) + .forcePathStyle(true) + .build(); + } + /** * Upload file to S3 bucket * @@ -161,11 +179,12 @@ public static List getRegions() { */ public static boolean objectExists(S3AsyncClient client, String bucketName, String key) { try { - getObjectAttributes(client, bucketName, key).wait(); + headObject(client, bucketName, key).join(); + return true; } catch (Exception e) { return false; } - return true; + } /** @@ -189,7 +208,12 @@ public static CompletableFuture getObjectAttributes * @return the CompletableFuture */ public static CompletableFuture headObject(S3AsyncClient client, String bucketName, String key) { - return client.headObject(builder -> builder.bucket(bucketName).key(key)); + HeadObjectRequest headObjectRequest = HeadObjectRequest.builder() + .bucket(bucketName) + .key(key) + .build(); + + return client.headObject(headObjectRequest); } /** diff --git a/extensions/entity-files/sources/ui/pom.xml b/extensions/entity-files/sources/ui/pom.xml index 9b8399f0..f0a976a0 100644 --- a/extensions/entity-files/sources/ui/pom.xml +++ b/extensions/entity-files/sources/ui/pom.xml @@ -22,7 +22,7 @@ tools.dynamia.modules.entityfiles.parent tools.dynamia.modules - 26.4.1 + 26.5.0 DynamiaModules - EntityFiles UI tools.dynamia.modules.entityfiles.ui @@ -48,12 +48,12 @@ tools.dynamia.modules tools.dynamia.modules.entityfiles - 26.4.1 + 26.5.0 tools.dynamia tools.dynamia.zk - 26.4.1 + 26.5.0 jar diff --git a/extensions/entity-files/sources/ui/src/main/java/tools/dynamia/modules/entityfile/ui/actions/ReloadEntityFileStoragesAction.java b/extensions/entity-files/sources/ui/src/main/java/tools/dynamia/modules/entityfile/ui/actions/ReloadEntityFileStoragesAction.java new file mode 100644 index 00000000..ce1b4bbb --- /dev/null +++ b/extensions/entity-files/sources/ui/src/main/java/tools/dynamia/modules/entityfile/ui/actions/ReloadEntityFileStoragesAction.java @@ -0,0 +1,46 @@ +package tools.dynamia.modules.entityfile.ui.actions; + + +import tools.dynamia.actions.ActionEvent; +import tools.dynamia.actions.InstallAction; +import tools.dynamia.commons.logger.LoggingService; +import tools.dynamia.commons.logger.SLF4JLoggingService; +import tools.dynamia.crud.cfg.AbstractConfigPageAction; +import tools.dynamia.domain.ValidationError; +import tools.dynamia.domain.query.QueryConditions; +import tools.dynamia.domain.query.QueryParameters; +import tools.dynamia.domain.services.CrudService; +import tools.dynamia.domain.util.QueryBuilder; +import tools.dynamia.integration.Containers; +import tools.dynamia.integration.ProgressMonitor; +import tools.dynamia.modules.entityfile.EntityFileStorage; +import tools.dynamia.modules.entityfile.domain.EntityFile; +import tools.dynamia.modules.entityfile.domain.enums.EntityFileState; +import tools.dynamia.modules.entityfile.local.LocalEntityFileStorage; +import tools.dynamia.ui.UIMessages; +import tools.dynamia.zk.ui.LongOperationMonitorWindow; +import tools.dynamia.zk.util.LongOperation; +import tools.dynamia.zk.util.ZKUtil; + +import java.util.ArrayList; +import java.util.List; + +@InstallAction +class ReloadEntityFileStoragesAction extends AbstractConfigPageAction { + + + private final LoggingService logger = new SLF4JLoggingService(ReloadEntityFileStoragesAction.class); + + public ReloadEntityFileStoragesAction() { + setName("Reload Storages"); + setApplicableConfig("EntityFileCFG"); + setType("secondary"); + } + + @Override + public void actionPerformed(ActionEvent evt) { + Containers.get().findObjects(EntityFileStorage.class).forEach(EntityFileStorage::reloadParams); + UIMessages.showMessage("Reloaded"); + } + +} diff --git a/extensions/entity-files/sources/ui/src/main/java/tools/dynamia/modules/entityfile/ui/actions/ViewFileURLAction.java b/extensions/entity-files/sources/ui/src/main/java/tools/dynamia/modules/entityfile/ui/actions/ViewFileURLAction.java index 0d90b7b3..2e5bec0a 100644 --- a/extensions/entity-files/sources/ui/src/main/java/tools/dynamia/modules/entityfile/ui/actions/ViewFileURLAction.java +++ b/extensions/entity-files/sources/ui/src/main/java/tools/dynamia/modules/entityfile/ui/actions/ViewFileURLAction.java @@ -36,7 +36,7 @@ public ViewFileURLAction() { @Override public void actionPerformed(EntityFileActionEvent evt) { if (evt.getEntityFile() != null) { - String url = evt.getEntityFile().getStoredEntityFile().getUrl(); + String url = evt.getEntityFile().toURL(); UIMessages.showMessageDialog("
" + url + "
"); } } diff --git a/extensions/entity-files/sources/ui/src/main/java/tools/dynamia/modules/entityfile/ui/components/EntityFileImage.java b/extensions/entity-files/sources/ui/src/main/java/tools/dynamia/modules/entityfile/ui/components/EntityFileImage.java index 10a8496f..c5b23194 100644 --- a/extensions/entity-files/sources/ui/src/main/java/tools/dynamia/modules/entityfile/ui/components/EntityFileImage.java +++ b/extensions/entity-files/sources/ui/src/main/java/tools/dynamia/modules/entityfile/ui/components/EntityFileImage.java @@ -76,11 +76,8 @@ private void loadImage() { if (entityFile != null) { Long key = entityFile.getId(); try { - StoredEntityFile sef = entityFile.getStoredEntityFile(); - if (isThumbnail()) { - String thumbUrl = URL_THUMB_CACHE.getOrLoad(key, k -> entityFile.getStoredEntityFile().getThumbnailUrl(thumbnailWidth, thumbnailHeight)); - setSrc(thumbUrl); + setSrc(URL_THUMB_CACHE.getOrLoad(key, k -> entityFile.toThumbnailURL(thumbnailWidth, thumbnailHeight))); } else { setSrc(URL_CACHE.getOrLoad(key, k -> entityFile.toURL())); } diff --git a/extensions/entity-files/sources/ui/src/main/resources/META-INF/descriptors/EntityFileConfig.yml b/extensions/entity-files/sources/ui/src/main/resources/META-INF/descriptors/EntityFileConfig.yml index 68518e24..493f18ae 100644 --- a/extensions/entity-files/sources/ui/src/main/resources/META-INF/descriptors/EntityFileConfig.yml +++ b/extensions/entity-files/sources/ui/src/main/resources/META-INF/descriptors/EntityFileConfig.yml @@ -56,10 +56,18 @@ fields: parameterName: AWS_SECRET_KEY cacheable: true + s3Endpoint: + label: Endpoint Override + description: Optional endpoint override for S3 compatible storage + params: + parameterName: AWS_S3_OVERRIDE_ENDPOINT + cacheable: true + span: 2 + groups: - s3: - label: Amazon Web Service (S3) - fields: [ s3BucketName,s3Region,s3User,s3Secret ] + s3Config: + label: S3 Compatible Storage + fields: [ s3BucketName,s3Region,s3User,s3Secret,s3Endpoint ] layout: columns: 4 \ No newline at end of file diff --git a/extensions/entity-files/sources/ui/src/main/resources/META-INF/descriptors/EntityFileTree.yml b/extensions/entity-files/sources/ui/src/main/resources/META-INF/descriptors/EntityFileTree.yml index f47eccd9..8cbfd0b9 100644 --- a/extensions/entity-files/sources/ui/src/main/resources/META-INF/descriptors/EntityFileTree.yml +++ b/extensions/entity-files/sources/ui/src/main/resources/META-INF/descriptors/EntityFileTree.yml @@ -11,7 +11,7 @@ fields: label: Nombre params: header: - width: 460px + width: 600px creationDate: label: Fecha From 2d4d13b5671e0a5db7b87691d5e989354343d38f Mon Sep 17 00:00:00 2001 From: Mario Serrano Date: Fri, 15 May 2026 12:35:56 -0500 Subject: [PATCH 02/22] feat: update dependencies to version 26.5.0 --- docs/backend/EXTENSIONS.md | 10 +++++++ examples/demo-zk-books/pom.xml | 4 +-- extensions/dashboard/sources/pom.xml | 6 ++--- extensions/email-sms/sources/core/pom.xml | 6 ++--- extensions/email-sms/sources/pom.xml | 4 +-- extensions/email-sms/sources/ui/pom.xml | 6 ++--- extensions/entity-files/README.md | 11 ++++++++ extensions/entity-files/sources/pom.xml | 2 +- extensions/file-importer/sources/core/pom.xml | 4 +-- extensions/file-importer/sources/pom.xml | 2 +- extensions/file-importer/sources/ui/pom.xml | 6 ++--- extensions/finances/sources/api/pom.xml | 2 +- extensions/finances/sources/pom.xml | 2 +- extensions/pom.xml | 2 +- extensions/reports/sources/api/pom.xml | 2 +- extensions/reports/sources/core/pom.xml | 12 ++++----- extensions/reports/sources/pom.xml | 2 +- extensions/reports/sources/ui/pom.xml | 8 +++--- extensions/saas/sources/api/pom.xml | 4 +-- extensions/saas/sources/core/pom.xml | 10 +++---- extensions/saas/sources/jpa/pom.xml | 6 ++--- extensions/saas/sources/pom.xml | 2 +- extensions/saas/sources/remote/pom.xml | 4 +-- extensions/saas/sources/ui/pom.xml | 8 +++--- extensions/security/sources/core/pom.xml | 14 +++++----- extensions/security/sources/pom.xml | 2 +- extensions/security/sources/ui/pom.xml | 8 +++--- platform/app/pom.xml | 26 +++++++++--------- platform/core/actions/pom.xml | 6 ++--- platform/core/commons/pom.xml | 2 +- platform/core/crud/pom.xml | 10 +++---- platform/core/domain-jpa/pom.xml | 4 +-- .../dynamia/domain/jpa/SimpleEntity.java | 8 ++++++ platform/core/domain/pom.xml | 2 +- platform/core/integration/pom.xml | 4 +-- platform/core/io/pom.xml | 2 +- platform/core/navigation/pom.xml | 8 +++--- platform/core/reports/pom.xml | 2 +- platform/core/templates/pom.xml | 6 ++--- platform/core/viewers/pom.xml | 12 ++++----- platform/core/web/pom.xml | 12 ++++----- platform/starters/zk-starter/pom.xml | 10 +++---- platform/ui/ui-shared/pom.xml | 8 +++--- platform/ui/zk/pom.xml | 18 ++++++------- .../dynamia/zk/crud/cfg/SaveConfigAction.java | 1 + .../zk/viewers/form/FormViewRenderer.java | 27 ++++++++++--------- pom.xml | 7 ++--- themes/pom.xml | 2 +- themes/theme-dynamical/sources/pom.xml | 4 +-- 49 files changed, 182 insertions(+), 148 deletions(-) diff --git a/docs/backend/EXTENSIONS.md b/docs/backend/EXTENSIONS.md index c4424a11..c9843e3b 100644 --- a/docs/backend/EXTENSIONS.md +++ b/docs/backend/EXTENSIONS.md @@ -301,6 +301,16 @@ dynamia.entityfiles.s3.secret-key=${AWS_SECRET_KEY} ``` ### When to Use Entity Files Extension +#### MinIO Storage +```properties +# MinIO storage +DEFAULT_STORAGE_ID=MinioStorage +MINIO_ENDPOINT=http://localhost:9000 +MINIO_BUCKET=my-bucket +MINIO_REGION=us-east-1 +MINIO_ACCESS_KEY=${MINIO_ACCESS_KEY} +MINIO_SECRET_KEY=${MINIO_SECRET_KEY} +``` - Attach documents to records - Store user profiles or avatars diff --git a/examples/demo-zk-books/pom.xml b/examples/demo-zk-books/pom.xml index 463365ad..c22e5829 100644 --- a/examples/demo-zk-books/pom.xml +++ b/examples/demo-zk-books/pom.xml @@ -32,7 +32,7 @@ org.springframework.boot spring-boot-starter-parent - 4.0.3 + 4.0.6 @@ -47,7 +47,7 @@ ${maven.build.timestamp} yyyyMMdd - 26.4.0 + 26.5.0 diff --git a/extensions/dashboard/sources/pom.xml b/extensions/dashboard/sources/pom.xml index 323aac1c..954947b5 100644 --- a/extensions/dashboard/sources/pom.xml +++ b/extensions/dashboard/sources/pom.xml @@ -23,7 +23,7 @@ tools.dynamia.modules tools.dynamia.modules.parent - 26.4.1 + 26.5.0 ../../pom.xml @@ -38,12 +38,12 @@ tools.dynamia tools.dynamia.zk - 26.4.1 + 26.5.0 tools.dynamia.modules tools.dynamia.modules.saas.api - 26.4.1 + 26.5.0 diff --git a/extensions/email-sms/sources/core/pom.xml b/extensions/email-sms/sources/core/pom.xml index 2002f6a3..8156aee1 100644 --- a/extensions/email-sms/sources/core/pom.xml +++ b/extensions/email-sms/sources/core/pom.xml @@ -23,7 +23,7 @@ tools.dynamia.modules.email.parent tools.dynamia.modules - 26.4.1 + 26.5.0 tools.dynamia.modules.email @@ -50,12 +50,12 @@ tools.dynamia tools.dynamia.domain.jpa - 26.4.1 + 26.5.0 tools.dynamia tools.dynamia.templates - 26.4.1 + 26.5.0 org.springframework diff --git a/extensions/email-sms/sources/pom.xml b/extensions/email-sms/sources/pom.xml index 3c7ba5a6..ba4efc11 100644 --- a/extensions/email-sms/sources/pom.xml +++ b/extensions/email-sms/sources/pom.xml @@ -23,7 +23,7 @@ tools.dynamia.modules tools.dynamia.modules.parent - 26.4.1 + 26.5.0 ../../pom.xml @@ -85,7 +85,7 @@ tools.dynamia.modules tools.dynamia.modules.saas.jpa - 26.4.1 + 26.5.0 diff --git a/extensions/email-sms/sources/ui/pom.xml b/extensions/email-sms/sources/ui/pom.xml index 3971de5d..92669853 100644 --- a/extensions/email-sms/sources/ui/pom.xml +++ b/extensions/email-sms/sources/ui/pom.xml @@ -22,7 +22,7 @@ tools.dynamia.modules.email.parent tools.dynamia.modules - 26.4.1 + 26.5.0 DynamiaModules - Email UI @@ -34,12 +34,12 @@ tools.dynamia tools.dynamia.zk - 26.4.1 + 26.5.0 tools.dynamia.modules tools.dynamia.modules.email - 26.4.1 + 26.5.0 tools.dynamia.zk.addons diff --git a/extensions/entity-files/README.md b/extensions/entity-files/README.md index 05356a98..f4b43caf 100644 --- a/extensions/entity-files/README.md +++ b/extensions/entity-files/README.md @@ -43,12 +43,22 @@ metadata are store in the database in table `mod_entity_files` using JPA entity ``` +#### MinIO Support +```xml + + tools.dynamia.modules + tools.dynamia.modules.entityfiles.minio + 7.4.0 + +``` + ### Gradle ```groovy compile 'tools.dynamia.modules:tools.dynamia.modules.entityfiles:7.4.0' compile 'tools.dynamia.modules:tools.dynamia.modules.entityfiles.ui:7.4.0' compile 'tools.dynamia.modules:tools.dynamia.modules.entityfiles.s3:7.4.0' +compile 'tools.dynamia.modules:tools.dynamia.modules.entityfiles.minio:7.4.0' ``` ## Usage @@ -115,6 +125,7 @@ files. - `LocalEntityFileStorage` is the default implementation and store files in local file system - `S3EntityFileStorage` in module S3 can upload files to AWS S3 buckets +- `MinioEntityFileStorage` in module MinIO can upload files to MinIO buckets using the MinIO Java SDK ## License diff --git a/extensions/entity-files/sources/pom.xml b/extensions/entity-files/sources/pom.xml index 0d5e48ae..87655d46 100644 --- a/extensions/entity-files/sources/pom.xml +++ b/extensions/entity-files/sources/pom.xml @@ -23,7 +23,7 @@ tools.dynamia.modules tools.dynamia.modules.parent - 26.4.1 + 26.5.0 ../../pom.xml diff --git a/extensions/file-importer/sources/core/pom.xml b/extensions/file-importer/sources/core/pom.xml index 6948f2ff..80ef3540 100644 --- a/extensions/file-importer/sources/core/pom.xml +++ b/extensions/file-importer/sources/core/pom.xml @@ -23,7 +23,7 @@ tools.dynamia.modules.importer.parent tools.dynamia.modules - 26.4.1 + 26.5.0 Dynamia Modules - Importer Core tools.dynamia.modules.importer @@ -56,7 +56,7 @@ tools.dynamia tools.dynamia.reports - 26.4.1 + 26.5.0 diff --git a/extensions/file-importer/sources/pom.xml b/extensions/file-importer/sources/pom.xml index 1c45634b..a7d56064 100644 --- a/extensions/file-importer/sources/pom.xml +++ b/extensions/file-importer/sources/pom.xml @@ -26,7 +26,7 @@ tools.dynamia.modules tools.dynamia.modules.parent - 26.4.1 + 26.5.0 ../../pom.xml diff --git a/extensions/file-importer/sources/ui/pom.xml b/extensions/file-importer/sources/ui/pom.xml index f428ce04..11c92729 100644 --- a/extensions/file-importer/sources/ui/pom.xml +++ b/extensions/file-importer/sources/ui/pom.xml @@ -23,7 +23,7 @@ tools.dynamia.modules.importer.parent tools.dynamia.modules - 26.4.1 + 26.5.0 Dynamia Modules - Importer UI tools.dynamia.modules.importer.ui @@ -55,13 +55,13 @@ tools.dynamia tools.dynamia.zk - 26.4.1 + 26.5.0 tools.dynamia.modules tools.dynamia.modules.importer - 26.4.1 + 26.5.0 diff --git a/extensions/finances/sources/api/pom.xml b/extensions/finances/sources/api/pom.xml index 809fa308..ed2fe779 100644 --- a/extensions/finances/sources/api/pom.xml +++ b/extensions/finances/sources/api/pom.xml @@ -26,7 +26,7 @@ tools.dynamia.modules tools.dynamia.modules.finances.parent - 26.4.1 + 26.5.0 Dynamia Modules - Finances API diff --git a/extensions/finances/sources/pom.xml b/extensions/finances/sources/pom.xml index 12307fd7..4b33867a 100644 --- a/extensions/finances/sources/pom.xml +++ b/extensions/finances/sources/pom.xml @@ -26,7 +26,7 @@ tools.dynamia.modules tools.dynamia.modules.parent - 26.4.1 + 26.5.0 ../../pom.xml diff --git a/extensions/pom.xml b/extensions/pom.xml index a5b92e00..034828b4 100644 --- a/extensions/pom.xml +++ b/extensions/pom.xml @@ -6,7 +6,7 @@ tools.dynamia tools.dynamia.parent - 26.4.1 + 26.5.0 ../pom.xml diff --git a/extensions/reports/sources/api/pom.xml b/extensions/reports/sources/api/pom.xml index 4d2c4162..f278dee8 100644 --- a/extensions/reports/sources/api/pom.xml +++ b/extensions/reports/sources/api/pom.xml @@ -23,7 +23,7 @@ tools.dynamia.modules tools.dynamia.modules.reports.parent - 26.4.1 + 26.5.0 DynamiaModules - Reports API diff --git a/extensions/reports/sources/core/pom.xml b/extensions/reports/sources/core/pom.xml index 388e29b8..0a15fc02 100644 --- a/extensions/reports/sources/core/pom.xml +++ b/extensions/reports/sources/core/pom.xml @@ -23,7 +23,7 @@ tools.dynamia.modules tools.dynamia.modules.reports.parent - 26.4.1 + 26.5.0 DynamiaModules - Reports Core @@ -50,17 +50,17 @@ tools.dynamia.modules tools.dynamia.modules.reports.api - 26.4.1 + 26.5.0 tools.dynamia tools.dynamia.domain.jpa - 26.4.1 + 26.5.0 tools.dynamia.modules tools.dynamia.modules.saas.jpa - 26.4.1 + 26.5.0 org.springframework @@ -69,12 +69,12 @@ tools.dynamia tools.dynamia.reports - 26.4.1 + 26.5.0 tools.dynamia tools.dynamia.templates - 26.4.1 + 26.5.0 compile diff --git a/extensions/reports/sources/pom.xml b/extensions/reports/sources/pom.xml index 3d7a7dff..9ff56312 100644 --- a/extensions/reports/sources/pom.xml +++ b/extensions/reports/sources/pom.xml @@ -23,7 +23,7 @@ tools.dynamia.modules tools.dynamia.modules.parent - 26.4.1 + 26.5.0 ../../pom.xml diff --git a/extensions/reports/sources/ui/pom.xml b/extensions/reports/sources/ui/pom.xml index b3c0f1c1..c8d93e6f 100644 --- a/extensions/reports/sources/ui/pom.xml +++ b/extensions/reports/sources/ui/pom.xml @@ -23,7 +23,7 @@ tools.dynamia.modules tools.dynamia.modules.reports.parent - 26.4.1 + 26.5.0 DynamiaModules - Reports UI @@ -49,17 +49,17 @@ tools.dynamia.modules tools.dynamia.modules.reports.core - 26.4.1 + 26.5.0 tools.dynamia tools.dynamia.zk - 26.4.1 + 26.5.0 tools.dynamia.modules tools.dynamia.modules.dashboard - 26.4.1 + 26.5.0 io.swagger.core.v3 diff --git a/extensions/saas/sources/api/pom.xml b/extensions/saas/sources/api/pom.xml index 8cb939f8..770a9341 100644 --- a/extensions/saas/sources/api/pom.xml +++ b/extensions/saas/sources/api/pom.xml @@ -26,7 +26,7 @@ tools.dynamia.modules tools.dynamia.modules.saas.parent - 26.4.1 + 26.5.0 @@ -55,7 +55,7 @@ tools.dynamia tools.dynamia.actions - 26.4.1 + 26.5.0 org.springframework.boot diff --git a/extensions/saas/sources/core/pom.xml b/extensions/saas/sources/core/pom.xml index dbca4eeb..c011e59a 100644 --- a/extensions/saas/sources/core/pom.xml +++ b/extensions/saas/sources/core/pom.xml @@ -22,7 +22,7 @@ tools.dynamia.modules tools.dynamia.modules.saas.parent - 26.4.1 + 26.5.0 DynamiaModules - SaaS Core @@ -49,18 +49,18 @@ tools.dynamia.modules tools.dynamia.modules.saas.api - 26.4.1 + 26.5.0 tools.dynamia.modules tools.dynamia.modules.saas.jpa - 26.4.1 + 26.5.0 tools.dynamia tools.dynamia.integration - 26.4.1 + 26.5.0 @@ -86,7 +86,7 @@ tools.dynamia.modules tools.dynamia.modules.entityfiles - 26.4.1 + 26.5.0 org.hibernate.orm diff --git a/extensions/saas/sources/jpa/pom.xml b/extensions/saas/sources/jpa/pom.xml index d46b7378..d381c404 100644 --- a/extensions/saas/sources/jpa/pom.xml +++ b/extensions/saas/sources/jpa/pom.xml @@ -24,7 +24,7 @@ tools.dynamia.modules.saas.parent tools.dynamia.modules - 26.4.1 + 26.5.0 DynamiaModules - SaaS JPA @@ -35,12 +35,12 @@ tools.dynamia.modules tools.dynamia.modules.saas.api - 26.4.1 + 26.5.0 tools.dynamia tools.dynamia.domain.jpa - 26.4.1 + 26.5.0 diff --git a/extensions/saas/sources/pom.xml b/extensions/saas/sources/pom.xml index 5b9d53a7..4b66b577 100644 --- a/extensions/saas/sources/pom.xml +++ b/extensions/saas/sources/pom.xml @@ -23,7 +23,7 @@ tools.dynamia.modules tools.dynamia.modules.parent - 26.4.1 + 26.5.0 ../../pom.xml diff --git a/extensions/saas/sources/remote/pom.xml b/extensions/saas/sources/remote/pom.xml index c8aa1d0e..0c0557b4 100644 --- a/extensions/saas/sources/remote/pom.xml +++ b/extensions/saas/sources/remote/pom.xml @@ -25,7 +25,7 @@ tools.dynamia.modules.saas.parent tools.dynamia.modules - 26.4.1 + 26.5.0 @@ -38,7 +38,7 @@ tools.dynamia.modules tools.dynamia.modules.saas.jpa - 26.4.1 + 26.5.0 diff --git a/extensions/saas/sources/ui/pom.xml b/extensions/saas/sources/ui/pom.xml index ae813d8d..d51a64e9 100644 --- a/extensions/saas/sources/ui/pom.xml +++ b/extensions/saas/sources/ui/pom.xml @@ -22,7 +22,7 @@ tools.dynamia.modules tools.dynamia.modules.saas.parent - 26.4.1 + 26.5.0 DynamiaModules - SaaS UI tools.dynamia.modules.saas.ui @@ -54,12 +54,12 @@ tools.dynamia tools.dynamia.zk - 26.4.1 + 26.5.0 tools.dynamia.modules tools.dynamia.modules.saas - 26.4.1 + 26.5.0 @@ -70,7 +70,7 @@ tools.dynamia.modules tools.dynamia.modules.entityfiles.ui - 26.4.1 + 26.5.0 diff --git a/extensions/security/sources/core/pom.xml b/extensions/security/sources/core/pom.xml index 8230ac74..c340fd85 100644 --- a/extensions/security/sources/core/pom.xml +++ b/extensions/security/sources/core/pom.xml @@ -17,7 +17,7 @@ tools.dynamia.modules tools.dynamia.modules.security.parent - 26.4.1 + 26.5.0 4.0.0 @@ -32,34 +32,34 @@ tools.dynamia.modules tools.dynamia.modules.saas.api - 26.4.1 + 26.5.0 tools.dynamia.modules tools.dynamia.modules.entityfiles - 26.4.1 + 26.5.0 tools.dynamia tools.dynamia.domain.jpa - 26.4.1 + 26.5.0 tools.dynamia tools.dynamia.domain - 26.4.1 + 26.5.0 tools.dynamia tools.dynamia.integration - 26.4.1 + 26.5.0 tools.dynamia tools.dynamia.web - 26.4.1 + 26.5.0 diff --git a/extensions/security/sources/pom.xml b/extensions/security/sources/pom.xml index c2b0bcbb..426f8d2f 100644 --- a/extensions/security/sources/pom.xml +++ b/extensions/security/sources/pom.xml @@ -19,7 +19,7 @@ tools.dynamia.modules tools.dynamia.modules.parent - 26.4.1 + 26.5.0 ../../pom.xml diff --git a/extensions/security/sources/ui/pom.xml b/extensions/security/sources/ui/pom.xml index a023c6ac..da1546e5 100644 --- a/extensions/security/sources/ui/pom.xml +++ b/extensions/security/sources/ui/pom.xml @@ -17,7 +17,7 @@ tools.dynamia.modules tools.dynamia.modules.security.parent - 26.4.1 + 26.5.0 DynamiaModules - Security UI @@ -44,18 +44,18 @@ tools.dynamia.modules tools.dynamia.modules.security - 26.4.1 + 26.5.0 tools.dynamia tools.dynamia.zk - 26.4.1 + 26.5.0 tools.dynamia tools.dynamia.app - 26.4.1 + 26.5.0 diff --git a/platform/app/pom.xml b/platform/app/pom.xml index d5ab3d79..183f9f16 100644 --- a/platform/app/pom.xml +++ b/platform/app/pom.xml @@ -23,7 +23,7 @@ tools.dynamia tools.dynamia.parent - 26.4.1 + 26.5.0 ../../pom.xml @@ -74,58 +74,58 @@ tools.dynamia tools.dynamia.actions - 26.4.1 + 26.5.0 tools.dynamia tools.dynamia.commons - 26.4.1 + 26.5.0 tools.dynamia tools.dynamia.crud - 26.4.1 + 26.5.0 tools.dynamia tools.dynamia.domain - 26.4.1 + 26.5.0 tools.dynamia tools.dynamia.integration - 26.4.1 + 26.5.0 tools.dynamia tools.dynamia.io - 26.4.1 + 26.5.0 tools.dynamia tools.dynamia.navigation - 26.4.1 + 26.5.0 tools.dynamia tools.dynamia.reports - 26.4.1 + 26.5.0 tools.dynamia tools.dynamia.templates - 26.4.1 + 26.5.0 tools.dynamia tools.dynamia.viewers - 26.4.1 + 26.5.0 tools.dynamia tools.dynamia.web - 26.4.1 + 26.5.0 @@ -205,7 +205,7 @@ tools.dynamia tools.dynamia.domain.jpa - 26.4.1 + 26.5.0 test diff --git a/platform/core/actions/pom.xml b/platform/core/actions/pom.xml index 17b5079a..3f094e78 100644 --- a/platform/core/actions/pom.xml +++ b/platform/core/actions/pom.xml @@ -23,7 +23,7 @@ tools.dynamia tools.dynamia.parent - 26.4.1 + 26.5.0 ../../../pom.xml @@ -65,12 +65,12 @@ tools.dynamia tools.dynamia.integration - 26.4.1 + 26.5.0 tools.dynamia tools.dynamia.commons - 26.4.1 + 26.5.0 diff --git a/platform/core/commons/pom.xml b/platform/core/commons/pom.xml index 51bbda17..9ce714f1 100644 --- a/platform/core/commons/pom.xml +++ b/platform/core/commons/pom.xml @@ -25,7 +25,7 @@ tools.dynamia tools.dynamia.parent - 26.4.1 + 26.5.0 ../../../pom.xml DynamiaTools - Commons diff --git a/platform/core/crud/pom.xml b/platform/core/crud/pom.xml index b077e403..0cf2b142 100644 --- a/platform/core/crud/pom.xml +++ b/platform/core/crud/pom.xml @@ -23,7 +23,7 @@ tools.dynamia tools.dynamia.parent - 26.4.1 + 26.5.0 ../../../pom.xml @@ -62,23 +62,23 @@ tools.dynamia tools.dynamia.actions - 26.4.1 + 26.5.0 tools.dynamia tools.dynamia.viewers - 26.4.1 + 26.5.0 tools.dynamia tools.dynamia.navigation - 26.4.1 + 26.5.0 tools.dynamia tools.dynamia.domain.jpa - 26.4.1 + 26.5.0 test diff --git a/platform/core/domain-jpa/pom.xml b/platform/core/domain-jpa/pom.xml index 15fba195..0aa32f31 100644 --- a/platform/core/domain-jpa/pom.xml +++ b/platform/core/domain-jpa/pom.xml @@ -23,7 +23,7 @@ tools.dynamia tools.dynamia.parent - 26.4.1 + 26.5.0 ../../../pom.xml @@ -65,7 +65,7 @@ tools.dynamia tools.dynamia.domain - 26.4.1 + 26.5.0 diff --git a/platform/core/domain-jpa/src/main/java/tools/dynamia/domain/jpa/SimpleEntity.java b/platform/core/domain-jpa/src/main/java/tools/dynamia/domain/jpa/SimpleEntity.java index badb4f94..d8520a9d 100644 --- a/platform/core/domain-jpa/src/main/java/tools/dynamia/domain/jpa/SimpleEntity.java +++ b/platform/core/domain-jpa/src/main/java/tools/dynamia/domain/jpa/SimpleEntity.java @@ -81,4 +81,12 @@ public Long getRemoteId() { public void setRemoteId(Long remoteId) { this.remoteId = remoteId; } + + /** + * Generic getter to query curent entity version + * @return version + */ + public int currentVersion() { + return version; + } } diff --git a/platform/core/domain/pom.xml b/platform/core/domain/pom.xml index aecb3486..5fac0789 100644 --- a/platform/core/domain/pom.xml +++ b/platform/core/domain/pom.xml @@ -26,7 +26,7 @@ tools.dynamia tools.dynamia.parent - 26.4.1 + 26.5.0 ../../../pom.xml DynamiaTools - Domain diff --git a/platform/core/integration/pom.xml b/platform/core/integration/pom.xml index 7e38a862..4de4388f 100644 --- a/platform/core/integration/pom.xml +++ b/platform/core/integration/pom.xml @@ -27,7 +27,7 @@ tools.dynamia tools.dynamia.parent - 26.4.1 + 26.5.0 ../../../pom.xml @@ -67,7 +67,7 @@ tools.dynamia tools.dynamia.commons - 26.4.1 + 26.5.0 provided diff --git a/platform/core/io/pom.xml b/platform/core/io/pom.xml index 0d665e42..b91fc6a7 100644 --- a/platform/core/io/pom.xml +++ b/platform/core/io/pom.xml @@ -28,7 +28,7 @@ tools.dynamia tools.dynamia.parent - 26.4.1 + 26.5.0 ../../../pom.xml diff --git a/platform/core/navigation/pom.xml b/platform/core/navigation/pom.xml index 376bc029..d5257f64 100644 --- a/platform/core/navigation/pom.xml +++ b/platform/core/navigation/pom.xml @@ -23,7 +23,7 @@ tools.dynamia tools.dynamia.parent - 26.4.1 + 26.5.0 ../../../pom.xml @@ -63,17 +63,17 @@ tools.dynamia tools.dynamia.commons - 26.4.1 + 26.5.0 tools.dynamia tools.dynamia.integration - 26.4.1 + 26.5.0 tools.dynamia tools.dynamia.actions - 26.4.1 + 26.5.0 diff --git a/platform/core/reports/pom.xml b/platform/core/reports/pom.xml index 54d95537..c82663ec 100644 --- a/platform/core/reports/pom.xml +++ b/platform/core/reports/pom.xml @@ -26,7 +26,7 @@ tools.dynamia tools.dynamia.parent - 26.4.1 + 26.5.0 ../../../pom.xml diff --git a/platform/core/templates/pom.xml b/platform/core/templates/pom.xml index 57d96bc1..768a2c54 100644 --- a/platform/core/templates/pom.xml +++ b/platform/core/templates/pom.xml @@ -23,7 +23,7 @@ tools.dynamia.parent tools.dynamia - 26.4.1 + 26.5.0 ../../../pom.xml @@ -64,12 +64,12 @@ tools.dynamia tools.dynamia.integration - 26.4.1 + 26.5.0 tools.dynamia tools.dynamia.commons - 26.4.1 + 26.5.0 diff --git a/platform/core/viewers/pom.xml b/platform/core/viewers/pom.xml index 2708cea1..556d68cb 100644 --- a/platform/core/viewers/pom.xml +++ b/platform/core/viewers/pom.xml @@ -25,7 +25,7 @@ tools.dynamia tools.dynamia.parent - 26.4.1 + 26.5.0 ../../../pom.xml @@ -67,27 +67,27 @@ tools.dynamia tools.dynamia.commons - 26.4.1 + 26.5.0 tools.dynamia tools.dynamia.integration - 26.4.1 + 26.5.0 tools.dynamia tools.dynamia.io - 26.4.1 + 26.5.0 tools.dynamia tools.dynamia.domain - 26.4.1 + 26.5.0 tools.dynamia tools.dynamia.actions - 26.4.1 + 26.5.0 org.yaml diff --git a/platform/core/web/pom.xml b/platform/core/web/pom.xml index 09183fae..0fc777ca 100644 --- a/platform/core/web/pom.xml +++ b/platform/core/web/pom.xml @@ -29,7 +29,7 @@ tools.dynamia tools.dynamia.parent - 26.4.1 + 26.5.0 ../../../pom.xml @@ -88,27 +88,27 @@ tools.dynamia tools.dynamia.commons - 26.4.1 + 26.5.0 tools.dynamia tools.dynamia.integration - 26.4.1 + 26.5.0 tools.dynamia tools.dynamia.navigation - 26.4.1 + 26.5.0 tools.dynamia tools.dynamia.viewers - 26.4.1 + 26.5.0 tools.dynamia tools.dynamia.crud - 26.4.1 + 26.5.0 org.springframework diff --git a/platform/starters/zk-starter/pom.xml b/platform/starters/zk-starter/pom.xml index 30d2feed..672308f4 100644 --- a/platform/starters/zk-starter/pom.xml +++ b/platform/starters/zk-starter/pom.xml @@ -4,7 +4,7 @@ tools.dynamia tools.dynamia.parent - 26.4.1 + 26.5.0 ../../../pom.xml @@ -28,22 +28,22 @@ tools.dynamia tools.dynamia.app - 26.4.1 + 26.5.0 tools.dynamia tools.dynamia.commons - 26.4.1 + 26.5.0 tools.dynamia tools.dynamia.zk - 26.4.1 + 26.5.0 tools.dynamia tools.dynamia.domain.jpa - 26.4.1 + 26.5.0 org.hibernate.validator diff --git a/platform/ui/ui-shared/pom.xml b/platform/ui/ui-shared/pom.xml index c8a03fec..e3ddcd27 100644 --- a/platform/ui/ui-shared/pom.xml +++ b/platform/ui/ui-shared/pom.xml @@ -23,7 +23,7 @@ tools.dynamia tools.dynamia.parent - 26.4.1 + 26.5.0 ../../../pom.xml @@ -64,17 +64,17 @@ tools.dynamia tools.dynamia.integration - 26.4.1 + 26.5.0 tools.dynamia tools.dynamia.commons - 26.4.1 + 26.5.0 tools.dynamia tools.dynamia.io - 26.4.1 + 26.5.0 diff --git a/platform/ui/zk/pom.xml b/platform/ui/zk/pom.xml index 87b6df56..b5a2799f 100644 --- a/platform/ui/zk/pom.xml +++ b/platform/ui/zk/pom.xml @@ -21,7 +21,7 @@ tools.dynamia.parent tools.dynamia - 26.4.1 + 26.5.0 ../../../pom.xml 4.0.0 @@ -99,31 +99,31 @@ tools.dynamia tools.dynamia.web - 26.4.1 + 26.5.0 tools.dynamia tools.dynamia.navigation - 26.4.1 + 26.5.0 tools.dynamia tools.dynamia.ui - 26.4.1 + 26.5.0 tools.dynamia tools.dynamia.domain - 26.4.1 + 26.5.0 tools.dynamia tools.dynamia.viewers - 26.4.1 + 26.5.0 org.yaml @@ -134,19 +134,19 @@ tools.dynamia tools.dynamia.crud - 26.4.1 + 26.5.0 tools.dynamia tools.dynamia.reports - 26.4.1 + 26.5.0 compile tools.dynamia tools.dynamia.templates - 26.4.1 + 26.5.0 compile diff --git a/platform/ui/zk/src/main/java/tools/dynamia/zk/crud/cfg/SaveConfigAction.java b/platform/ui/zk/src/main/java/tools/dynamia/zk/crud/cfg/SaveConfigAction.java index a725a649..03253a6c 100644 --- a/platform/ui/zk/src/main/java/tools/dynamia/zk/crud/cfg/SaveConfigAction.java +++ b/platform/ui/zk/src/main/java/tools/dynamia/zk/crud/cfg/SaveConfigAction.java @@ -25,6 +25,7 @@ import tools.dynamia.domain.query.Parameter; import tools.dynamia.domain.query.Parameters; import tools.dynamia.domain.services.CrudService; +import tools.dynamia.integration.Containers; import tools.dynamia.navigation.NavigationManager; import tools.dynamia.ui.MessageType; import tools.dynamia.ui.UIMessages; diff --git a/platform/ui/zk/src/main/java/tools/dynamia/zk/viewers/form/FormViewRenderer.java b/platform/ui/zk/src/main/java/tools/dynamia/zk/viewers/form/FormViewRenderer.java index 3bd21f34..849f25e1 100644 --- a/platform/ui/zk/src/main/java/tools/dynamia/zk/viewers/form/FormViewRenderer.java +++ b/platform/ui/zk/src/main/java/tools/dynamia/zk/viewers/form/FormViewRenderer.java @@ -71,7 +71,7 @@ public class FormViewRenderer implements ViewRenderer { * Renders a descriptor into a form view instance. * * @param descriptor descriptor to render - * @param value initial value + * @param value initial value * @return rendered view */ @Override @@ -100,9 +100,9 @@ public View render(ViewDescriptor descriptor, T value) { /** * Renders descriptor fields and actions into an existing form view instance. * - * @param view target form view + * @param view target form view * @param descriptor descriptor to render - * @param value initial value + * @param value initial value * @return rendered view */ public View render(FormView view, ViewDescriptor descriptor, T value) { @@ -125,11 +125,12 @@ public View render(FormView view, ViewDescriptor descriptor, T value) { // protected METHODS + /** * Calculates effective number of columns used by the form layout. * * @param view form view - * @param d descriptor + * @param d descriptor * @return number of columns in range 1..12 */ protected int renderHeaders(FormView view, ViewDescriptor d) { @@ -158,10 +159,10 @@ protected int renderHeaders(FormView view, ViewDescriptor d) { /** * Renders ungrouped and grouped fields into form rows. * - * @param view target form view + * @param view target form view * @param viewDesc descriptor * @param realCols effective columns - * @param value current value + * @param value current value * @return root rendered component */ protected Component renderRows(FormView view, ViewDescriptor viewDesc, int realCols, T value) { @@ -176,14 +177,16 @@ protected Component renderRows(FormView view, ViewDescriptor viewDesc, int re for (Field field : ViewRendererUtil.filterRenderableFields(view, viewDesc)) { - if (!hasSpace(row, realCols, field)) { - row = newRow(); - row.setParent(panelBody); - if (panel.getParent() == null) { - panel.setParent(view.getContentArea()); + if (field.getGroup() == null) { + if (!hasSpace(row, realCols, field)) { + row = newRow(); + row.setParent(panelBody); + if (panel.getParent() == null) { + panel.setParent(view.getContentArea()); + } } + renderField(row, field, view.getBinder(), view, value, realCols); } - renderField(row, field, view.getBinder(), view, value, realCols); } diff --git a/pom.xml b/pom.xml index 476c74a6..2b1bcf62 100644 --- a/pom.xml +++ b/pom.xml @@ -24,7 +24,7 @@ 4.0.0 tools.dynamia tools.dynamia.parent - 26.4.1 + 26.5.0 pom Dynamia Soluciones IT SAS @@ -57,14 +57,15 @@ ${project.baseUri} - 4.0.5 + 4.0.6 2.2.46 1 10.2.1-jakarta 1.1.0 - 2.42.30 + 2.44.5 + 8.5.17 6.21.4 diff --git a/themes/pom.xml b/themes/pom.xml index ffead089..90bdb201 100644 --- a/themes/pom.xml +++ b/themes/pom.xml @@ -6,7 +6,7 @@ tools.dynamia tools.dynamia.parent - 26.4.1 + 26.5.0 ../pom.xml diff --git a/themes/theme-dynamical/sources/pom.xml b/themes/theme-dynamical/sources/pom.xml index 46dd9562..62c40257 100644 --- a/themes/theme-dynamical/sources/pom.xml +++ b/themes/theme-dynamical/sources/pom.xml @@ -24,7 +24,7 @@ tools.dynamia.themes tools.dynamia.themes.parent - 26.4.1 + 26.5.0 ../../pom.xml @@ -102,7 +102,7 @@ tools.dynamia tools.dynamia.zk - 26.4.1 + 26.5.0 provided From a2a4cef39f8002ecbdf48af3ed19c46e48b99c0f Mon Sep 17 00:00:00 2001 From: Mario Serrano Date: Fri, 15 May 2026 15:56:22 -0500 Subject: [PATCH 03/22] feat: add utility methods to load request parameters and headers into maps --- .../tools/dynamia/web/util/HttpUtils.java | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/platform/core/web/src/main/java/tools/dynamia/web/util/HttpUtils.java b/platform/core/web/src/main/java/tools/dynamia/web/util/HttpUtils.java index ac93086a..ea03caf7 100644 --- a/platform/core/web/src/main/java/tools/dynamia/web/util/HttpUtils.java +++ b/platform/core/web/src/main/java/tools/dynamia/web/util/HttpUtils.java @@ -26,6 +26,7 @@ import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; +import java.util.HashMap; import java.util.Map; import java.util.Map.Entry; import java.util.StringJoiner; @@ -421,5 +422,33 @@ public static String getSubdomain(HttpServletRequest request) { } return null; } + + /** + * Load request parameters in to map + * + * @param request request + * @return parameters map + */ + public static Map loadParams(HttpServletRequest request) { + Map params = new HashMap<>(); + request.getParameterNames().asIterator().forEachRemaining(name -> { + params.put(name, request.getParameter(name)); + }); + return params; + } + + /** + * Load request headers in to a map + * + * @param request request + * @return headers + */ + public static Map loadHeaders(HttpServletRequest request) { + Map headers = new HashMap<>(); + request.getHeaderNames().asIterator().forEachRemaining(name -> { + headers.put(name, request.getHeader(name)); + }); + return headers; + } } From c573a8b0016d44667d3f0bf8d854cccfbd3b924a Mon Sep 17 00:00:00 2001 From: Mario Serrano Date: Fri, 15 May 2026 15:56:29 -0500 Subject: [PATCH 04/22] feat: update DownloadFileAction to improve file download handling and localization --- .../ui/actions/DownloadFileAction.java | 17 +++++------------ 1 file changed, 5 insertions(+), 12 deletions(-) diff --git a/extensions/entity-files/sources/ui/src/main/java/tools/dynamia/modules/entityfile/ui/actions/DownloadFileAction.java b/extensions/entity-files/sources/ui/src/main/java/tools/dynamia/modules/entityfile/ui/actions/DownloadFileAction.java index a186daf1..f5453f59 100644 --- a/extensions/entity-files/sources/ui/src/main/java/tools/dynamia/modules/entityfile/ui/actions/DownloadFileAction.java +++ b/extensions/entity-files/sources/ui/src/main/java/tools/dynamia/modules/entityfile/ui/actions/DownloadFileAction.java @@ -31,7 +31,7 @@ public class DownloadFileAction extends AbstractEntityFileAction implements ReadableOnly { public DownloadFileAction() { - setName("Descargar Archivo"); + setName("Download"); setImage("icons:download"); setGroup(ActionGroup.get("FILES")); setMenuSupported(true); @@ -45,25 +45,18 @@ public void actionPerformed(EntityFileActionEvent evt) { EntityFile file = evt.getEntityFile(); if (file != null) { - - StoredEntityFile sef = file.getStoredEntityFile(); - if (sef != null && sef.getUrl() != null) { - download(sef); - } else { - UIMessages.showMessage("No se pudo encontrar archivo " + file.getName() - + " en el servidor, por favor contacte con el administrador del sistema", MessageType.ERROR); - } + download(file.toURL()); } else { - UIMessages.showMessage("Seleccion archivo para descargar", MessageType.WARNING); + UIMessages.showMessage("Select file to download", MessageType.WARNING); } } - private void download(StoredEntityFile sef) { + private void download(String url) { Execution exec = Executions.getCurrent(); - exec.sendRedirect(sef.getUrl(), "_blank"); + exec.sendRedirect(url, "_blank"); } } From 834f56f85dc710bbf8586f80e791887b18d4cee1 Mon Sep 17 00:00:00 2001 From: Mario Serrano Date: Fri, 15 May 2026 15:56:37 -0500 Subject: [PATCH 05/22] feat: add URL and thumbnail URL accessors to EntityFile for improved file handling --- .../modules/entityfile/domain/EntityFile.java | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/extensions/entity-files/sources/core/src/main/java/tools/dynamia/modules/entityfile/domain/EntityFile.java b/extensions/entity-files/sources/core/src/main/java/tools/dynamia/modules/entityfile/domain/EntityFile.java index 747b9b89..e61b5744 100644 --- a/extensions/entity-files/sources/core/src/main/java/tools/dynamia/modules/entityfile/domain/EntityFile.java +++ b/extensions/entity-files/sources/core/src/main/java/tools/dynamia/modules/entityfile/domain/EntityFile.java @@ -283,6 +283,21 @@ public String toThumbnailURL( int w, int h) { return url+"?w="+w+"&h="+h; } + @Transient + public String getUrl(){ + return toURL(); + } + + @Transient + public String getThumbnailUrl(){ + return toThumbnailURL(200, 200); + } + + @Transient + public String getThumbnailUrl(int w, int h) { + return toThumbnailURL(w, h); + } + public String getExternalRef() { return externalRef; } From 245f2594ef09b841b136c7629955231760f6c5e6 Mon Sep 17 00:00:00 2001 From: Mario Serrano Date: Fri, 15 May 2026 15:56:42 -0500 Subject: [PATCH 06/22] feat: enhance EntityFileSecurityProvider with access check and token generation methods --- .../EntityFileSecurityProvider.java | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/extensions/entity-files/sources/core/src/main/java/tools/dynamia/modules/entityfile/EntityFileSecurityProvider.java b/extensions/entity-files/sources/core/src/main/java/tools/dynamia/modules/entityfile/EntityFileSecurityProvider.java index 88a5397f..660ab298 100644 --- a/extensions/entity-files/sources/core/src/main/java/tools/dynamia/modules/entityfile/EntityFileSecurityProvider.java +++ b/extensions/entity-files/sources/core/src/main/java/tools/dynamia/modules/entityfile/EntityFileSecurityProvider.java @@ -2,6 +2,8 @@ import tools.dynamia.modules.entityfile.domain.EntityFile; +import java.util.Map; + /** * Simple security provider interface to control access to entity files. * Implement this interface and register as a bean in the application context to control access to entity files. @@ -11,11 +13,22 @@ public interface EntityFileSecurityProvider { /** + * Check if entity file has access + * + * @param entityFile entity file + * @param params request parameters + * @param headers request headers + * @return allowed + */ + boolean canAccess(EntityFile entityFile, Map params, Map headers); + + /** + * Generate a token for the given entity file. This token can be used to grant temporary access to the file without requiring authentication. * - * @param entityFile - * @return + * @param entityFile entity file + * @return token */ - boolean canAccess(EntityFile entityFile); + String generateToken(EntityFile entityFile); } From 396a4e1b3a20fc4b09d6ff6052dabef7477a885d Mon Sep 17 00:00:00 2001 From: Mario Serrano Date: Fri, 15 May 2026 15:56:48 -0500 Subject: [PATCH 07/22] feat: add export endpoint to EntityFileStorageController for entity file details retrieval --- .../EntityFileStorageController.java | 59 ++++++++++++++++--- 1 file changed, 51 insertions(+), 8 deletions(-) diff --git a/extensions/entity-files/sources/core/src/main/java/tools/dynamia/modules/entityfile/controller/EntityFileStorageController.java b/extensions/entity-files/sources/core/src/main/java/tools/dynamia/modules/entityfile/controller/EntityFileStorageController.java index 7a30d311..8de6c368 100644 --- a/extensions/entity-files/sources/core/src/main/java/tools/dynamia/modules/entityfile/controller/EntityFileStorageController.java +++ b/extensions/entity-files/sources/core/src/main/java/tools/dynamia/modules/entityfile/controller/EntityFileStorageController.java @@ -1,12 +1,14 @@ package tools.dynamia.modules.entityfile.controller; import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; import org.jspecify.annotations.NonNull; import org.springframework.core.io.Resource; import org.springframework.http.*; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestParam; +import tools.dynamia.commons.MapBuilder; import tools.dynamia.integration.Containers; import tools.dynamia.integration.sterotypes.Controller; import tools.dynamia.io.IOUtils; @@ -20,8 +22,10 @@ import tools.dynamia.modules.entityfile.local.LocalEntityFileStorage; import tools.dynamia.modules.entityfile.local.LocalEntityFileStorageHandler; import tools.dynamia.modules.entityfile.service.EntityFileService; +import tools.dynamia.web.util.HttpUtils; import java.io.File; +import java.util.Map; import java.util.concurrent.TimeUnit; import static tools.dynamia.modules.entityfile.local.LocalEntityFileStorageHandler.getParam; @@ -34,7 +38,38 @@ public class EntityFileStorageController { public EntityFileStorageController(EntityFileService entityFileService) { this.entityFileService = entityFileService; + } + + @GetMapping(value = "/api/storage/{uuid}/export", produces = MediaType.APPLICATION_JSON_VALUE) + public ResponseEntity> export(@PathVariable String uuid, HttpServletResponse response, HttpServletRequest request) { + var entityFile = entityFileService.getEntityFile(uuid); + if (entityFile == null) { + return ResponseEntity.notFound().build(); + } + + if (!isSameAccount(entityFile)) { + return ResponseEntity.notFound().build(); + } + + String url = entityFile.toURL(); + if (!entityFile.isShared()) { + EntityFileSecurityProvider securityProvider = Containers.get().findObject(EntityFileSecurityProvider.class); + if (securityProvider != null) { + String token = securityProvider.generateToken(entityFile); + url = entityFile.toURL() + "?token=" + token; + } + } + return ResponseEntity.ok(MapBuilder.put( + "name", entityFile.getName(), + "id", entityFile.getId(), + "uuid", entityFile.getUuid(), + "description", entityFile.getDescription(), + "size", entityFile.getSize(), + "version", entityFile.currentVersion(), + "url", url + ) + ); } @GetMapping(value = "/storage/{uuid}/{file}") @@ -44,18 +79,16 @@ public ResponseEntity get(@PathVariable("uuid") String uuid, @PathVari return ResponseEntity.notFound().build(); } - EntityFileAccountProvider accountProvider = Containers.get().findObject(EntityFileAccountProvider.class); - if (accountProvider != null) { - if (entityFile.getAccountId() != null) { - if (!entityFile.getAccountId().equals(accountProvider.getAccountId())) { - return ResponseEntity.notFound().build(); - } - } + if (!isSameAccount(entityFile)) { + return ResponseEntity.notFound().build(); } if (!entityFile.isShared()) { EntityFileSecurityProvider securityProvider = Containers.get().findObject(EntityFileSecurityProvider.class); - if (securityProvider != null && !securityProvider.canAccess(entityFile)) { + + Map params = HttpUtils.loadParams(request); + Map headers = HttpUtils.loadHeaders(request); + if (securityProvider != null && !securityProvider.canAccess(entityFile, params, headers)) { return ResponseEntity.status(HttpStatus.FORBIDDEN) .build(); } @@ -111,6 +144,16 @@ public ResponseEntity get(@PathVariable("uuid") String uuid, @PathVari } } + private boolean isSameAccount(EntityFile entityFile) { + EntityFileAccountProvider accountProvider = Containers.get().findObject(EntityFileAccountProvider.class); + if (accountProvider != null) { + if (entityFile.getAccountId() != null) { + return entityFile.getAccountId().equals(accountProvider.getAccountId()); + } + } + return true; + } + private static @NonNull MediaType getMediaType(EntityFile entityFile) { var contentType = MediaType.APPLICATION_OCTET_STREAM; try { From b19a70ab84e4a6fb86262f8e77daefcb1604a122 Mon Sep 17 00:00:00 2001 From: Mario Serrano Date: Sat, 16 May 2026 00:01:59 -0500 Subject: [PATCH 08/22] feat: add export and upload endpoints to FilesApi for enhanced file management --- .../packages/files-sdk/src/api.ts | 209 +++++++++++++++++- .../packages/files-sdk/src/index.ts | 11 +- .../packages/files-sdk/test/files.test.ts | 98 +++++++- .../packages/files-sdk/test/helpers.ts | 11 +- 4 files changed, 315 insertions(+), 14 deletions(-) diff --git a/extensions/entity-files/packages/files-sdk/src/api.ts b/extensions/entity-files/packages/files-sdk/src/api.ts index ad3fa62d..d09b203f 100644 --- a/extensions/entity-files/packages/files-sdk/src/api.ts +++ b/extensions/entity-files/packages/files-sdk/src/api.ts @@ -1,4 +1,60 @@ -import type { HttpClient } from '@dynamia-tools/sdk'; +import { DynamiaApiError, type DynamiaClientConfig, type HttpClient } from '@dynamia-tools/sdk'; + +export interface EntityFileExportResponse { + id: number; + uuid: string; + name: string; + description?: string | null; + size?: number | null; + version?: number | null; + url: string; +} + +export interface FilesUploadOptions { + className?: string; + entityId?: string | number; + description?: string; + shared?: boolean; + subfolder?: string; + storedFileName?: string; + parentUuid?: string; +} + +export interface MultipartUploadOptions extends FilesUploadOptions { + fileName?: string; +} + +export interface Base64UploadRequest extends FilesUploadOptions { + fileName?: string; + name?: string; + contentType?: string; + base64?: string; + data?: string; +} + +export interface EntityFileUploadResponse { + id: number; + uuid: string; + name: string; + description?: string | null; + contentType?: string | null; + size?: number | null; + shared: boolean; + subfolder?: string | null; + storedFileName?: string | null; + targetEntity?: string | null; + targetEntityId?: number | null; + targetEntitySId?: string | null; + parentId?: number | null; + version?: number | null; + url: string; + valid: boolean; +} + +interface InternalHttpClientState { + config?: DynamiaClientConfig; + _fetch?: typeof fetch; +} /** * Download files managed by the Entity Files extension. @@ -12,11 +68,19 @@ export class FilesApi { } /** - * GET /storage/{file}?uuid={uuid} + * GET /api/storage/{uuid}/export + * Returns the server-side metadata and direct URL for a stored entity file. + */ + export(uuid: string): Promise { + return this.http.get(`/api/storage/${encodeURIComponent(uuid)}/export`); + } + + /** + * GET /storage/{uuid}/{file} * Download a file as a Blob. */ download(file: string, uuid: string): Promise { - return this.http.get(`/storage/${encodeURIComponent(file)}`, { uuid }); + return this.http.get(`/storage/${encodeURIComponent(uuid)}/${encodeURIComponent(file)}`); } /** @@ -24,7 +88,142 @@ export class FilesApi { * No HTTP request is made. */ getUrl(file: string, uuid: string): string { - return this.http.url(`/storage/${encodeURIComponent(file)}`, { uuid }); + return this.http.url(`/storage/${encodeURIComponent(uuid)}/${encodeURIComponent(file)}`); } -} + /** + * POST /api/storage/upload + * Uploads a file using multipart/form-data. + */ + uploadMultipart(file: Blob | File, options: MultipartUploadOptions = {}): Promise { + const fileName = this.resolveFileName(file, options.fileName); + const formData = new FormData(); + formData.append('file', file, fileName); + this.appendOptionalFormValue(formData, 'className', options.className); + this.appendOptionalFormValue(formData, 'entityId', this.toOptionalString(options.entityId)); + this.appendOptionalFormValue(formData, 'description', options.description); + this.appendOptionalFormValue(formData, 'shared', options.shared); + this.appendOptionalFormValue(formData, 'subfolder', options.subfolder); + this.appendOptionalFormValue(formData, 'storedFileName', options.storedFileName); + this.appendOptionalFormValue(formData, 'parentUuid', options.parentUuid); + + return this.request('/api/storage/upload', { + method: 'POST', + body: formData, + headers: this.buildHeaders(), + }); + } + + /** + * POST /api/storage/upload-base64 + * Uploads a file using a JSON payload containing base64 or data URI content. + */ + uploadBase64(request: Base64UploadRequest): Promise { + return this.request('/api/storage/upload-base64', { + method: 'POST', + body: JSON.stringify(request), + headers: this.buildHeaders('application/json'), + }); + } + + private resolveFileName(file: Blob | File, fileName?: string): string { + if (fileName && fileName.trim()) { + return fileName.trim(); + } + + if ('name' in file && typeof file.name === 'string' && file.name.trim()) { + return file.name.trim(); + } + + throw new Error('FilesApi.uploadMultipart requires options.fileName when the provided Blob has no name'); + } + + private appendOptionalFormValue(formData: FormData, name: string, value: string | boolean | undefined): void { + if (value === undefined) { + return; + } + + formData.append(name, String(value)); + } + + private toOptionalString(value: string | number | undefined): string | undefined { + return value === undefined ? undefined : String(value); + } + + private buildHeaders(contentType?: string): Headers { + const headers = new Headers({ Accept: 'application/json' }); + const config = this.getConfig(); + + if (contentType) { + headers.set('Content-Type', contentType); + } + + if (config?.token) { + headers.set('Authorization', `Bearer ${config.token}`); + } else if (config?.username && config?.password) { + headers.set('Authorization', `Basic ${this.encodeBasicAuth(config.username, config.password)}`); + } + + return headers; + } + + private encodeBasicAuth(username: string, password: string): string { + const plain = `${username}:${password}`; + if (typeof btoa === 'function') { + return btoa(plain); + } + return Buffer.from(plain, 'utf-8').toString('base64'); + } + + private getConfig(): DynamiaClientConfig | undefined { + return (this.http as unknown as InternalHttpClientState).config; + } + + private getFetch(): typeof fetch { + const fetchImpl = (this.http as unknown as InternalHttpClientState)._fetch ?? globalThis.fetch; + if (!fetchImpl) { + throw new Error('FilesApi requires a fetch implementation. Pass one through DynamiaClient config.'); + } + return fetchImpl.bind(globalThis); + } + + private async request(path: string, init: RequestInit): Promise { + const url = this.http.url(path); + const config = this.getConfig(); + const requestInit: RequestInit = { + ...init, + }; + + if (config?.corsMode) { + requestInit.mode = config.corsMode; + } + + if (config?.withCredentials) { + requestInit.credentials = 'include'; + } + + const response = await this.getFetch()(url, requestInit); + + if (!response.ok) { + let errorBody: unknown; + try { + errorBody = await response.json(); + } catch { + errorBody = await response.text().catch(() => undefined); + } + + const message = + (errorBody as Record | undefined)?.message ?? + (errorBody as Record | undefined)?.error ?? + response.statusText; + + throw new DynamiaApiError(message, response.status, url, errorBody); + } + + if (response.status === 204) { + return undefined as T; + } + + return response.json() as Promise; + } +} diff --git a/extensions/entity-files/packages/files-sdk/src/index.ts b/extensions/entity-files/packages/files-sdk/src/index.ts index 59d27c7d..a7eb7a62 100644 --- a/extensions/entity-files/packages/files-sdk/src/index.ts +++ b/extensions/entity-files/packages/files-sdk/src/index.ts @@ -1,2 +1,11 @@ -export { FilesApi } from './api.js'; +export { + FilesApi, +} from './api.js'; +export type { + EntityFileExportResponse, + EntityFileUploadResponse, + FilesUploadOptions, + MultipartUploadOptions, + Base64UploadRequest, +} from './api.js'; diff --git a/extensions/entity-files/packages/files-sdk/test/files.test.ts b/extensions/entity-files/packages/files-sdk/test/files.test.ts index 203c861b..fcb07590 100644 --- a/extensions/entity-files/packages/files-sdk/test/files.test.ts +++ b/extensions/entity-files/packages/files-sdk/test/files.test.ts @@ -1,14 +1,100 @@ import { describe, it, expect } from 'vitest'; import { FilesApi } from '../src/index.js'; -import { mockFetch, makeHttpClient } from './helpers.js'; +import { makeHttpClient, mockBlobResponse, mockJsonResponse } from './helpers.js'; describe('FilesApi', () => { - it('getUrl() returns a URL with uuid query param', () => { - const http = makeHttpClient(mockFetch(200, {})); + it('getUrl() returns a URL using the new /storage/{uuid}/{file} pattern', () => { + const http = makeHttpClient(mockJsonResponse({})); const api = new FilesApi(http); const url = api.getUrl('photo.png', 'uuid-123'); - expect(url).toContain('/storage/photo.png'); - expect(url).toContain('uuid=uuid-123'); + expect(url).toBe('https://app.example.com/storage/uuid-123/photo.png'); + }); + + it('download() requests the new storage path', async () => { + const fetchMock = mockBlobResponse(); + const http = makeHttpClient(fetchMock); + const api = new FilesApi(http); + + await api.download('photo.png', 'uuid-123'); + + expect(fetchMock).toHaveBeenCalledWith( + 'https://app.example.com/storage/uuid-123/photo.png', + expect.objectContaining({ method: 'GET' }), + ); }); -}); + it('export() loads file metadata from the export endpoint', async () => { + const fetchMock = mockJsonResponse({ uuid: 'uuid-123', name: 'photo.png', url: 'https://cdn.example.com/photo.png', id: 1 }); + const http = makeHttpClient(fetchMock); + const api = new FilesApi(http); + + const result = await api.export('uuid-123'); + + expect(result.uuid).toBe('uuid-123'); + expect(fetchMock).toHaveBeenCalledWith( + 'https://app.example.com/api/storage/uuid-123/export', + expect.objectContaining({ method: 'GET' }), + ); + }); + + it('uploadMultipart() sends multipart form data to the new upload endpoint', async () => { + const fetchMock = mockJsonResponse({ uuid: 'uuid-123', valid: true, name: 'photo.png', shared: false, url: '/storage/uuid-123/photo.png', id: 1 }); + const http = makeHttpClient(fetchMock); + const api = new FilesApi(http); + const file = new File(['content'], 'photo.png', { type: 'image/png' }); + + const result = await api.uploadMultipart(file, { + className: 'com.example.Customer', + entityId: 15, + description: 'Profile picture', + shared: true, + parentUuid: 'parent-1', + }); + + expect(result.valid).toBe(true); + expect(fetchMock).toHaveBeenCalledTimes(1); + + const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(url).toBe('https://app.example.com/api/storage/upload'); + expect(init.method).toBe('POST'); + expect(init.headers).toBeInstanceOf(Headers); + expect((init.headers as Headers).get('Authorization')).toBe('Bearer test-token'); + expect((init.headers as Headers).get('Content-Type')).toBeNull(); + expect(init.body).toBeInstanceOf(FormData); + + const formData = init.body as FormData; + expect(formData.get('file')).toBeInstanceOf(File); + expect(formData.get('className')).toBe('com.example.Customer'); + expect(formData.get('entityId')).toBe('15'); + expect(formData.get('description')).toBe('Profile picture'); + expect(formData.get('shared')).toBe('true'); + expect(formData.get('parentUuid')).toBe('parent-1'); + }); + + it('uploadBase64() posts JSON payload to the base64 endpoint', async () => { + const fetchMock = mockJsonResponse({ uuid: 'uuid-123', valid: true, name: 'photo.png', shared: false, url: '/storage/uuid-123/photo.png', id: 1 }); + const http = makeHttpClient(fetchMock); + const api = new FilesApi(http); + + await api.uploadBase64({ + fileName: 'photo.png', + contentType: 'image/png', + base64: 'ZmFrZQ==', + className: 'com.example.Customer', + entityId: '15', + }); + + const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(url).toBe('https://app.example.com/api/storage/upload-base64'); + expect(init.method).toBe('POST'); + expect((init.headers as Headers).get('Content-Type')).toBe('application/json'); + expect((init.headers as Headers).get('Authorization')).toBe('Bearer test-token'); + expect(init.body).toBe(JSON.stringify({ + fileName: 'photo.png', + contentType: 'image/png', + base64: 'ZmFrZQ==', + className: 'com.example.Customer', + entityId: '15', + })); + }); +}); diff --git a/extensions/entity-files/packages/files-sdk/test/helpers.ts b/extensions/entity-files/packages/files-sdk/test/helpers.ts index d23cabc2..6280cf06 100644 --- a/extensions/entity-files/packages/files-sdk/test/helpers.ts +++ b/extensions/entity-files/packages/files-sdk/test/helpers.ts @@ -9,12 +9,19 @@ export function mockFetch(status: number, body: unknown, contentType = 'applicat headers: { get: (key: string) => (key === 'content-type' ? contentType : null) }, json: () => Promise.resolve(body), text: () => Promise.resolve(String(body)), - blob: () => Promise.resolve(new Blob()), + blob: () => Promise.resolve(body instanceof Blob ? body : new Blob()), } as unknown as Response); } +export function mockJsonResponse(body: T, status = 200) { + return mockFetch(status, body, 'application/json'); +} + +export function mockBlobResponse(body = new Blob(['content']), status = 200) { + return mockFetch(status, body, 'application/octet-stream'); +} + export function makeHttpClient(fetchMock: ReturnType): HttpClient { const client = new DynamiaClient({ baseUrl: 'https://app.example.com', token: 'test-token', fetch: fetchMock }); return client.http as HttpClient; } - From 41d606b8d32d741fe8fd4417480939f4909e018e Mon Sep 17 00:00:00 2001 From: Mario Serrano Date: Sat, 16 May 2026 00:02:05 -0500 Subject: [PATCH 09/22] feat: update README to include upload methods and enhance file handling details --- .../entity-files/packages/files-sdk/README.md | 72 ++++++++++++++++--- 1 file changed, 64 insertions(+), 8 deletions(-) diff --git a/extensions/entity-files/packages/files-sdk/README.md b/extensions/entity-files/packages/files-sdk/README.md index 175833a4..98942279 100644 --- a/extensions/entity-files/packages/files-sdk/README.md +++ b/extensions/entity-files/packages/files-sdk/README.md @@ -2,9 +2,9 @@ > TypeScript / JavaScript client SDK for the Dynamia Entity Files extension REST API. -`@dynamia-tools/files-sdk` provides a small, focused client to download files managed by the Entity Files extension of a Dynamia Platform backend. The package exposes a single API class, `FilesApi`, which delegates HTTP, authentication and error handling to the core `@dynamia-tools/sdk` `HttpClient`. +`@dynamia-tools/files-sdk` provides a small, focused client to download, inspect and upload files managed by the Entity Files extension of a Dynamia Platform backend. The package exposes a single API class, `FilesApi`, which delegates base URL handling to the core `@dynamia-tools/sdk` `HttpClient` and reuses the same authentication context for upload operations. -This README explains how to install the package, how to use `FilesApi` (recommended via `DynamiaClient`) and how to handle binary downloads in browser and Node.js environments. +This README explains how to install the package, how to use `FilesApi` (recommended via `DynamiaClient`) and how to handle downloads plus the new multipart / base64 upload flows. --- @@ -13,6 +13,7 @@ This README explains how to install the package, how to use `FilesApi` (recommen - [Installation](#installation) - [Quick Start (recommended)](#quick-start-recommended) - [API methods](#api-methods) +- [Upload examples](#upload-examples) - [Browser example (download and show)](#browser-example-download-and-show) - [Node.js example (save to disk)](#nodejs-example-save-to-disk) - [Authentication & Errors](#authentication--errors) @@ -54,27 +55,82 @@ const files = new FilesApi(client.http); // Download a file as a Blob (browser) const blob = await files.download('myfile.pdf', 'f9a3e8c2-...'); +// Get file metadata and direct URL from the server +const metadata = await files.export('f9a3e8c2-...'); + // Get a direct URL (no network call performed) -const url = files.getUrl('images/logo.png', 'f9a3e8c2-...'); +const url = files.getUrl('myfile.pdf', 'f9a3e8c2-...'); console.log(url); ``` Notes: - `FilesApi` methods are thin wrappers over the core `HttpClient` (`get`, `url`) implemented by `DynamiaClient`. -- The `download()` method calls `GET /storage/{file}?uuid={uuid}` and returns a `Blob` in browser environments; when running in Node.js the underlying fetch polyfill may provide an `ArrayBuffer`/`Buffer` which you should convert to a file. +- The `download()` method calls `GET /storage/{uuid}/{file}` and returns a `Blob` in browser environments; when running in Node.js the underlying fetch polyfill may provide an `ArrayBuffer`/`Buffer` which you should convert to a file. +- The upload methods call the new REST endpoints exposed by `EntityFileStorageController`: `POST /api/storage/upload` and `POST /api/storage/upload-base64`. --- ## API methods -The implementation in `src/api.ts` exposes two methods on `FilesApi`: +The implementation in `src/api.ts` exposes the following methods on `FilesApi`: +- `export(uuid: string): Promise` + - GET `/api/storage/{uuid}/export` — Returns server-side metadata such as `name`, `size`, `version` and `url`. - `download(file: string, uuid: string): Promise` - - GET `/storage/{file}?uuid={uuid}` — Downloads the file. The SDK returns parsed JSON for `application/json` responses and a `Blob` for other content types (binary). + - GET `/storage/{uuid}/{file}` — Downloads the file. The SDK returns parsed JSON for `application/json` responses and a `Blob` for other content types (binary). - `getUrl(file: string, uuid: string): string` - - Returns a fully-qualified URL that points to `/storage/{file}` with the `uuid` query parameter. No HTTP request is made. + - Returns a fully-qualified URL that points to `/storage/{uuid}/{file}`. No HTTP request is made. +- `uploadMultipart(file: Blob | File, options?: MultipartUploadOptions): Promise` + - POST `/api/storage/upload` — Uploads a file using multipart form data. +- `uploadBase64(request: Base64UploadRequest): Promise` + - POST `/api/storage/upload-base64` — Uploads a file using JSON containing `base64` or `data` content. + +Use `download()` when you need the file content programmatically (e.g., for preview or file save). Use `getUrl()` when you want to put a direct link in an `img` `src`, anchor `href`, or let the browser perform the download. Use `export()` when you need server-generated metadata or a pre-authorized URL. Use the upload methods when the frontend must create `EntityFile` records directly. + +--- + +## Upload examples + +### Multipart upload + +```ts +const uploaded = await files.uploadMultipart(fileInput.files![0], { + className: 'com.example.crm.Customer', + entityId: 15, + description: 'Signed contract', + shared: false, + parentUuid: 'folder-uuid', +}); + +console.log(uploaded.uuid, uploaded.url); +``` + +### Base64 JSON upload + +```ts +const uploaded = await files.uploadBase64({ + fileName: 'avatar.png', + contentType: 'image/png', + base64: imageBase64, + className: 'com.example.crm.Customer', + entityId: '15', + shared: true, +}); + +console.log(uploaded.uuid, uploaded.valid); +``` + +Both upload methods support these optional fields: + +- `className` +- `entityId` +- `description` +- `shared` +- `subfolder` +- `storedFileName` +- `parentUuid` -Use `download()` when you need the file content programmatically (e.g., for preview or file save). Use `getUrl()` when you want to put a direct link in an `img` `src`, anchor `href`, or let the browser perform the download. +When `className` and `entityId` are omitted, the server creates a temporal file. When both are provided, the uploaded file is attached to the referenced entity. --- From 54d1cb88a15286ca16c1b790badd57aeac7340c9 Mon Sep 17 00:00:00 2001 From: Mario Serrano Date: Sat, 16 May 2026 00:02:35 -0500 Subject: [PATCH 10/22] feat: add upload endpoints for multipart and Base64 file uploads in EntityFileStorageController --- .../EntityFileStorageController.java | 631 +++++++++++++++++- 1 file changed, 613 insertions(+), 18 deletions(-) diff --git a/extensions/entity-files/sources/core/src/main/java/tools/dynamia/modules/entityfile/controller/EntityFileStorageController.java b/extensions/entity-files/sources/core/src/main/java/tools/dynamia/modules/entityfile/controller/EntityFileStorageController.java index 8de6c368..cd32e128 100644 --- a/extensions/entity-files/sources/core/src/main/java/tools/dynamia/modules/entityfile/controller/EntityFileStorageController.java +++ b/extensions/entity-files/sources/core/src/main/java/tools/dynamia/modules/entityfile/controller/EntityFileStorageController.java @@ -7,39 +7,66 @@ import org.springframework.http.*; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.multipart.MultipartFile; import tools.dynamia.commons.MapBuilder; +import tools.dynamia.domain.services.CrudService; +import tools.dynamia.domain.util.DomainUtils; import tools.dynamia.integration.Containers; import tools.dynamia.integration.sterotypes.Controller; -import tools.dynamia.io.IOUtils; -import tools.dynamia.io.impl.SpringResource; +import tools.dynamia.modules.entityfile.UploadedFileInfo; import tools.dynamia.modules.entityfile.EntityFileAccountProvider; import tools.dynamia.modules.entityfile.EntityFileSecurityProvider; -import tools.dynamia.modules.entityfile.EntityFileStorage; -import tools.dynamia.modules.entityfile.StoredEntityFile; import tools.dynamia.modules.entityfile.domain.EntityFile; import tools.dynamia.modules.entityfile.enums.EntityFileType; -import tools.dynamia.modules.entityfile.local.LocalEntityFileStorage; -import tools.dynamia.modules.entityfile.local.LocalEntityFileStorageHandler; import tools.dynamia.modules.entityfile.service.EntityFileService; import tools.dynamia.web.util.HttpUtils; -import java.io.File; +import java.io.ByteArrayInputStream; +import java.io.Serializable; +import java.util.Base64; +import java.util.LinkedHashMap; import java.util.Map; +import java.util.Objects; import java.util.concurrent.TimeUnit; import static tools.dynamia.modules.entityfile.local.LocalEntityFileStorageHandler.getParam; import static tools.dynamia.modules.entityfile.local.LocalEntityFileStorageHandler.isThumbnail; +/** + * Web controller responsible for exporting, downloading and uploading {@link EntityFile} resources. + *

+ * In addition to read/download operations, this controller exposes upload endpoints for both + * {@code multipart/form-data} and JSON payloads containing Base64 file content. Upload requests may + * optionally be associated with an existing domain entity using its fully qualified class name and ID. + */ @Controller public class EntityFileStorageController { private final EntityFileService entityFileService; + private final CrudService crudService; - public EntityFileStorageController(EntityFileService entityFileService) { + /** + * Creates a new controller instance. + * + * @param entityFileService service used to create, resolve and download entity files + * @param crudService service used to resolve target entities dynamically by class name and ID + */ + public EntityFileStorageController(EntityFileService entityFileService, CrudService crudService) { this.entityFileService = entityFileService; + this.crudService = crudService; } + /** + * Exports the main metadata of a stored file, including a public or tokenized URL when applicable. + * + * @param uuid unique file identifier + * @param response current HTTP response + * @param request current HTTP request + * @return a JSON payload with basic file metadata or {@code 404} when the file cannot be resolved + */ @GetMapping(value = "/api/storage/{uuid}/export", produces = MediaType.APPLICATION_JSON_VALUE) public ResponseEntity> export(@PathVariable String uuid, HttpServletResponse response, HttpServletRequest request) { var entityFile = entityFileService.getEntityFile(uuid); @@ -51,15 +78,6 @@ public ResponseEntity> export(@PathVariable String uuid, Htt return ResponseEntity.notFound().build(); } - String url = entityFile.toURL(); - if (!entityFile.isShared()) { - EntityFileSecurityProvider securityProvider = Containers.get().findObject(EntityFileSecurityProvider.class); - if (securityProvider != null) { - String token = securityProvider.generateToken(entityFile); - url = entityFile.toURL() + "?token=" + token; - } - } - return ResponseEntity.ok(MapBuilder.put( "name", entityFile.getName(), "id", entityFile.getId(), @@ -67,11 +85,123 @@ public ResponseEntity> export(@PathVariable String uuid, Htt "description", entityFile.getDescription(), "size", entityFile.getSize(), "version", entityFile.currentVersion(), - "url", url + "url", buildPublicURL(entityFile) ) ); } + /** + * Uploads a file using {@code multipart/form-data}. + *

+ * The uploaded file can optionally be linked to an existing entity by providing both + * {@code className} and {@code entityId}. When {@code parentUuid} is provided, the new file is created + * under the referenced directory/file tree. + * + * @param file multipart file content + * @param className fully qualified target entity class name + * @param entityId target entity identifier + * @param description optional file description + * @param shared whether the file should be publicly shared + * @param subfolder optional logical subfolder + * @param storedFileName optional physical stored file name override + * @param parentUuid optional parent entity file UUID + * @return the created entity file metadata or an error response describing the validation failure + */ + @PostMapping(value = "/api/storage/upload", consumes = MediaType.MULTIPART_FORM_DATA_VALUE, produces = MediaType.APPLICATION_JSON_VALUE) + public ResponseEntity> uploadMultipart(@RequestParam("file") MultipartFile file, + @RequestParam(required = false) String className, + @RequestParam(required = false) String entityId, + @RequestParam(required = false) String description, + @RequestParam(required = false, defaultValue = "false") boolean shared, + @RequestParam(required = false) String subfolder, + @RequestParam(required = false) String storedFileName, + @RequestParam(required = false) String parentUuid) { + + if (file == null || file.isEmpty()) { + return error(HttpStatus.BAD_REQUEST, "File is required"); + } + + var fileName = firstText(file.getOriginalFilename(), file.getName()); + if (!hasText(fileName)) { + return error(HttpStatus.BAD_REQUEST, "Uploaded file name is required"); + } + + try { + UploadedFileInfo info = new UploadedFileInfo(fileName, file.getContentType(), file.getInputStream()); + info.setLength(file.getSize()); + info.setShared(shared); + info.setSubfolder(subfolder); + info.setStoredFileName(normalizeStoredFileName(storedFileName, fileName)); + return saveUpload(info, description, className, entityId, parentUuid); + } catch (Exception e) { + return error(HttpStatus.INTERNAL_SERVER_ERROR, "Error reading uploaded file: " + e.getMessage()); + } + } + + /** + * Uploads a file using a JSON payload that contains Base64-encoded content. + *

+ * The request supports raw Base64 content as well as full data URI values. As with multipart upload, + * the file can optionally be attached to an existing entity or nested under an existing parent UUID. + * + * @param request JSON request containing file metadata and Base64 content + * @return the created entity file metadata or an error response when the payload is invalid + */ + @PostMapping(value = "/api/storage/upload-base64", consumes = MediaType.APPLICATION_JSON_VALUE, produces = MediaType.APPLICATION_JSON_VALUE) + public ResponseEntity> uploadBase64(@RequestBody Base64UploadRequest request) { + if (request == null) { + return error(HttpStatus.BAD_REQUEST, "Request body is required"); + } + + var base64Content = firstText(request.getBase64(), request.getData()); + if (!hasText(base64Content)) { + return error(HttpStatus.BAD_REQUEST, "Base64 payload is required"); + } + + var fileName = request.getResolvedFileName(); + if (!hasText(fileName)) { + return error(HttpStatus.BAD_REQUEST, "fileName is required"); + } + + try { + String contentType = request.getContentType(); + String normalizedBase64 = base64Content.trim(); + if (normalizedBase64.startsWith("data:")) { + int commaIndex = normalizedBase64.indexOf(','); + if (commaIndex < 0) { + return error(HttpStatus.BAD_REQUEST, "Invalid data URI payload"); + } + + if (!hasText(contentType)) { + String meta = normalizedBase64.substring(5, commaIndex); + int separator = meta.indexOf(';'); + contentType = separator > -1 ? meta.substring(0, separator) : meta; + } + normalizedBase64 = normalizedBase64.substring(commaIndex + 1); + } + + byte[] data = Base64.getMimeDecoder().decode(normalizedBase64); + UploadedFileInfo info = new UploadedFileInfo(fileName, contentType, new ByteArrayInputStream(data)); + info.setLength(data.length); + info.setShared(Boolean.TRUE.equals(request.getShared())); + info.setSubfolder(request.getSubfolder()); + info.setStoredFileName(normalizeStoredFileName(request.getStoredFileName(), fileName)); + return saveUpload(info, request.getDescription(), request.getClassName(), request.getEntityId(), request.getParentUuid()); + } catch (IllegalArgumentException e) { + return error(HttpStatus.BAD_REQUEST, "Invalid base64 payload"); + } catch (Exception e) { + return error(HttpStatus.INTERNAL_SERVER_ERROR, "Error processing base64 upload: " + e.getMessage()); + } + } + + /** + * Streams a stored entity file or its thumbnail representation to the client. + * + * @param uuid unique file identifier + * @param file path placeholder containing the requested file name + * @param request current HTTP request + * @return the resolved resource stream or {@code 404}/{@code 403} when access is not allowed + */ @GetMapping(value = "/storage/{uuid}/{file}") public ResponseEntity get(@PathVariable("uuid") String uuid, @PathVariable String file, HttpServletRequest request) { var entityFile = entityFileService.getEntityFile(uuid); @@ -144,6 +274,297 @@ public ResponseEntity get(@PathVariable("uuid") String uuid, @PathVari } } + /** + * Persists the uploaded file after resolving optional parent and target entity references. + * + * @param fileInfo uploaded file information wrapper + * @param description optional file description + * @param className fully qualified target entity class name + * @param entityId target entity identifier + * @param parentUuid optional parent entity file UUID + * @return a {@code 201 Created} response containing the created file metadata + */ + private ResponseEntity> saveUpload(UploadedFileInfo fileInfo, String description, String className, String entityId, String parentUuid) { + try { + EntityFile parent = resolveParent(parentUuid); + Object targetEntity = resolveTargetEntity(className, entityId, parent); + + if (parent != null) { + fileInfo.setParent(parent); + } + + EntityFile entityFile; + if (targetEntity != null) { + entityFile = entityFileService.createEntityFile(fileInfo, targetEntity, description); + } else { + entityFile = entityFileService.createTemporalEntityFile(fileInfo); + } + + return ResponseEntity.status(HttpStatus.CREATED).body(buildUploadResponse(entityFile)); + } catch (UploadRequestException e) { + return error(e.getStatus(), e.getMessage()); + } catch (Exception e) { + return error(HttpStatus.INTERNAL_SERVER_ERROR, e.getMessage()); + } + } + + /** + * Resolves a parent {@link EntityFile} by UUID and validates account ownership. + * + * @param parentUuid optional parent UUID received from the client + * @return the resolved parent file or {@code null} when no UUID was provided + */ + private EntityFile resolveParent(String parentUuid) { + if (!hasText(parentUuid)) { + return null; + } + + EntityFile parent = entityFileService.getEntityFile(parentUuid.trim()); + if (parent == null || !isSameAccount(parent)) { + throw new UploadRequestException(HttpStatus.NOT_FOUND, "Parent entity file not found: " + parentUuid); + } + return parent; + } + + /** + * Resolves the target domain entity for the upload request. + *

+ * The entity may come directly from {@code className/entityId} parameters or be inferred from the + * provided parent file when it already belongs to a persistent entity. + * + * @param className fully qualified target entity class name + * @param entityId target entity identifier + * @param parent optional parent entity file + * @return the resolved entity instance or {@code null} when the upload should remain temporal + */ + private Object resolveTargetEntity(String className, String entityId, EntityFile parent) { + boolean hasClassName = hasText(className); + boolean hasEntityId = hasText(entityId); + + if (hasClassName != hasEntityId) { + throw new UploadRequestException(HttpStatus.BAD_REQUEST, "className and entityId must be provided together"); + } + + Object target = null; + if (hasClassName) { + target = loadEntity(className.trim(), entityId.trim()); + } else if (parent != null) { + if ("temporal".equalsIgnoreCase(parent.getTargetEntity())) { + throw new UploadRequestException(HttpStatus.BAD_REQUEST, + "parentUuid belongs to a temporal entity. Please provide className and entityId"); + } + String parentEntityId = parent.getTargetEntityId() != null ? String.valueOf(parent.getTargetEntityId()) : parent.getTargetEntitySId(); + target = loadEntity(parent.getTargetEntity(), parentEntityId); + } + + if (parent != null && target != null && !matchesParentTarget(parent, target)) { + throw new UploadRequestException(HttpStatus.BAD_REQUEST, + "Provided className/entityId does not match the parentUuid target entity"); + } + + return target; + } + + /** + * Loads a persistent entity by its fully qualified class name and string identifier. + * + * @param className fully qualified entity class name + * @param entityId entity identifier as received from the request + * @return the resolved entity instance + */ + private Object loadEntity(String className, String entityId) { + if (!hasText(className) || !hasText(entityId)) { + throw new UploadRequestException(HttpStatus.BAD_REQUEST, "className and entityId are required"); + } + + try { + Class entityClass = Class.forName(className); + Object entity = findEntity(entityClass, entityId); + if (entity == null) { + throw new UploadRequestException(HttpStatus.NOT_FOUND, + "Entity not found: " + className + " with id " + entityId); + } + return entity; + } catch (ClassNotFoundException e) { + throw new UploadRequestException(HttpStatus.BAD_REQUEST, "Class not found: " + className); + } + } + + /** + * Attempts to find an entity using either a numeric ID or a string-based identifier. + * + * @param entityClass target entity class + * @param entityId entity identifier in string form + * @return the resolved entity instance, or {@code null} when no match exists + */ + private Object findEntity(Class entityClass, String entityId) { + if (isNumeric(entityId)) { + Object entity = crudService.find((Class) entityClass, Long.valueOf(entityId)); + if (entity != null) { + return entity; + } + } + + return crudService.find((Class) entityClass, entityId); + } + + /** + * Verifies that the resolved target entity matches the same target metadata stored in the parent file. + * + * @param parent parent entity file + * @param target resolved target domain entity + * @return {@code true} when both point to the same entity, {@code false} otherwise + */ + private boolean matchesParentTarget(EntityFile parent, Object target) { + if (!Objects.equals(parent.getTargetEntity(), target.getClass().getName())) { + return false; + } + + Serializable targetId = DomainUtils.findEntityId(target); + if (targetId == null) { + return false; + } + + if (targetId instanceof Long longId) { + return Objects.equals(parent.getTargetEntityId(), longId); + } + + return Objects.equals(parent.getTargetEntitySId(), targetId.toString()); + } + + /** + * Builds the JSON response body returned after a successful upload. + * + * @param entityFile newly created entity file + * @return a map containing the most relevant metadata for API clients + */ + private Map buildUploadResponse(EntityFile entityFile) { + Map response = new LinkedHashMap<>(); + response.put("id", entityFile.getId()); + response.put("uuid", entityFile.getUuid()); + response.put("name", entityFile.getName()); + response.put("description", entityFile.getDescription()); + response.put("contentType", entityFile.getContentType()); + response.put("size", entityFile.getSize()); + response.put("shared", entityFile.isShared()); + response.put("subfolder", entityFile.getSubfolder()); + response.put("storedFileName", entityFile.getStoredFileName()); + response.put("targetEntity", entityFile.getTargetEntity()); + response.put("targetEntityId", entityFile.getTargetEntityId()); + response.put("targetEntitySId", entityFile.getTargetEntitySId()); + response.put("parentId", entityFile.getParent() != null ? entityFile.getParent().getId() : null); + response.put("version", entityFile.currentVersion()); + response.put("url", entityFile.toURL()); + response.put("valid", true); + return response; + } + + /** + * Builds the most appropriate public URL for an entity file. + *

+ * When the file is not shared, a security token is appended if a security provider is available. + * + * @param entityFile entity file to expose + * @return resolved public URL + */ + private String buildPublicURL(EntityFile entityFile) { + String url = entityFile.toURL(); + if (!entityFile.isShared()) { + EntityFileSecurityProvider securityProvider = Containers.get().findObject(EntityFileSecurityProvider.class); + if (securityProvider != null) { + String token = securityProvider.generateToken(entityFile); + url = entityFile.toURL() + "?token=" + token; + } + } + return url; + } + + /** + * Creates a standardized JSON error response for upload operations. + * + * @param status HTTP status to return + * @param message human-readable error description + * @return response entity containing the error payload + */ + private ResponseEntity> error(HttpStatus status, String message) { + return ResponseEntity.status(status) + .header("X-Error-Message", message) + .body(Map.of("error", message, "valid", false)); + } + + /** + * Checks whether the provided text contains non-blank content. + * + * @param value text to evaluate + * @return {@code true} when the value contains visible characters + */ + private boolean hasText(String value) { + return value != null && !value.isBlank(); + } + + /** + * Returns the first non-blank value from the provided list. + * + * @param values candidate values + * @return the first non-blank value, trimmed, or {@code null} when none is available + */ + private String firstText(String... values) { + if (values == null) { + return null; + } + + for (String value : values) { + if (hasText(value)) { + return value.trim(); + } + } + return null; + } + + /** + * Resolves the final stored file name override. + *

+ * The special value {@code real} means the original file name should be used. + * + * @param storedFileName requested stored file name + * @param fileName original file name + * @return normalized stored file name or {@code null} when not provided + */ + private String normalizeStoredFileName(String storedFileName, String fileName) { + if (!hasText(storedFileName)) { + return null; + } + if ("real".equalsIgnoreCase(storedFileName.trim())) { + return fileName; + } + return storedFileName.trim(); + } + + /** + * Determines whether the provided text can be parsed as a {@link Long} value. + * + * @param value text to evaluate + * @return {@code true} when the value is numeric + */ + private boolean isNumeric(String value) { + if (!hasText(value)) { + return false; + } + + try { + Long.parseLong(value.trim()); + return true; + } catch (NumberFormatException e) { + return false; + } + } + + /** + * Verifies whether the file belongs to the current account when account scoping is enabled. + * + * @param entityFile file to evaluate + * @return {@code true} when the file can be accessed from the current account context + */ private boolean isSameAccount(EntityFile entityFile) { EntityFileAccountProvider accountProvider = Containers.get().findObject(EntityFileAccountProvider.class); if (accountProvider != null) { @@ -154,6 +575,12 @@ private boolean isSameAccount(EntityFile entityFile) { return true; } + /** + * Resolves the media type for the requested file. + * + * @param entityFile file whose media type should be inferred + * @return detected media type or {@link MediaType#APPLICATION_OCTET_STREAM} as fallback + */ private static @NonNull MediaType getMediaType(EntityFile entityFile) { var contentType = MediaType.APPLICATION_OCTET_STREAM; try { @@ -185,6 +612,13 @@ private boolean isSameAccount(EntityFile entityFile) { return contentType; } + /** + * Parses and clamps thumbnail dimensions to a safe range. + * + * @param value requested size value + * @param def fallback size when parsing fails + * @return a value between 1 and 2000 + */ private int safeSize(String value, int def) { try { return Math.min(Math.max(Integer.parseInt(value), 1), 2000); @@ -192,4 +626,165 @@ private int safeSize(String value, int def) { return def; } } + + /** + * JSON request payload used by the Base64 upload endpoint. + *

+ * Supports both {@code fileName} and {@code name}, and both {@code base64} and {@code data}, in order + * to simplify integration with different clients. + */ + public static class Base64UploadRequest { + + private String fileName; + private String name; + private String contentType; + private String base64; + private String data; + private String className; + private String entityId; + private String description; + private Boolean shared; + private String subfolder; + private String storedFileName; + private String parentUuid; + + /** + * Resolves the effective file name using the first available non-blank field. + * + * @return resolved file name or {@code null} when neither field is present + */ + public String getResolvedFileName() { + if (fileName != null && !fileName.isBlank()) { + return fileName.trim(); + } + if (name != null && !name.isBlank()) { + return name.trim(); + } + return null; + } + + public String getFileName() { + return fileName; + } + + public void setFileName(String fileName) { + this.fileName = fileName; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getContentType() { + return contentType; + } + + public void setContentType(String contentType) { + this.contentType = contentType; + } + + public String getBase64() { + return base64; + } + + public void setBase64(String base64) { + this.base64 = base64; + } + + public String getData() { + return data; + } + + public void setData(String data) { + this.data = data; + } + + public String getClassName() { + return className; + } + + public void setClassName(String className) { + this.className = className; + } + + public String getEntityId() { + return entityId; + } + + public void setEntityId(String entityId) { + this.entityId = entityId; + } + + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } + + public Boolean getShared() { + return shared; + } + + public void setShared(Boolean shared) { + this.shared = shared; + } + + public String getSubfolder() { + return subfolder; + } + + public void setSubfolder(String subfolder) { + this.subfolder = subfolder; + } + + public String getStoredFileName() { + return storedFileName; + } + + public void setStoredFileName(String storedFileName) { + this.storedFileName = storedFileName; + } + + public String getParentUuid() { + return parentUuid; + } + + public void setParentUuid(String parentUuid) { + this.parentUuid = parentUuid; + } + } + + /** + * Internal exception used to abort upload processing with a controlled HTTP status code. + */ + private static class UploadRequestException extends RuntimeException { + + private final HttpStatus status; + + /** + * Creates a new upload request exception. + * + * @param status HTTP status associated with the failure + * @param message validation or processing error description + */ + private UploadRequestException(HttpStatus status, String message) { + super(message); + this.status = status; + } + + /** + * Returns the HTTP status associated with this failure. + * + * @return failure status code + */ + public HttpStatus getStatus() { + return status; + } + } } From 57c8cce9daf929bc7414c625b8bf2fe0d152001a Mon Sep 17 00:00:00 2001 From: Mario Serrano Date: Sat, 16 May 2026 00:02:51 -0500 Subject: [PATCH 11/22] chore: bump version to 26.5.0 for multiple SDKs in package.json --- extensions/entity-files/packages/files-sdk/package.json | 2 +- extensions/reports/packages/reports-sdk/package.json | 2 +- extensions/saas/packages/saas-sdk/package.json | 2 +- platform/packages/cli/package-lock.json | 4 ++-- platform/packages/cli/package.json | 2 +- platform/packages/sdk/package-lock.json | 4 ++-- platform/packages/sdk/package.json | 2 +- platform/packages/ui-core/package.json | 2 +- platform/packages/vue/package.json | 2 +- 9 files changed, 11 insertions(+), 11 deletions(-) diff --git a/extensions/entity-files/packages/files-sdk/package.json b/extensions/entity-files/packages/files-sdk/package.json index 0c707dc0..6593b395 100644 --- a/extensions/entity-files/packages/files-sdk/package.json +++ b/extensions/entity-files/packages/files-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@dynamia-tools/files-sdk", - "version": "26.4.1", + "version": "26.5.0", "website": "https://dynamia.tools", "description": "TypeScript/JavaScript client SDK for the Dynamia Entity Files extension REST API", "keywords": [ diff --git a/extensions/reports/packages/reports-sdk/package.json b/extensions/reports/packages/reports-sdk/package.json index 7bd5e313..7e9fcc04 100644 --- a/extensions/reports/packages/reports-sdk/package.json +++ b/extensions/reports/packages/reports-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@dynamia-tools/reports-sdk", - "version": "26.4.1", + "version": "26.5.0", "website": "https://dynamia.tools", "description": "TypeScript/JavaScript client SDK for the Dynamia Reports extension REST API", "keywords": [ diff --git a/extensions/saas/packages/saas-sdk/package.json b/extensions/saas/packages/saas-sdk/package.json index 373b85c1..4496394a 100644 --- a/extensions/saas/packages/saas-sdk/package.json +++ b/extensions/saas/packages/saas-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@dynamia-tools/saas-sdk", - "version": "26.4.1", + "version": "26.5.0", "website": "https://dynamia.tools", "description": "TypeScript/JavaScript client SDK for the Dynamia SaaS extension REST API", "keywords": [ diff --git a/platform/packages/cli/package-lock.json b/platform/packages/cli/package-lock.json index 8ba6d016..7db0111b 100644 --- a/platform/packages/cli/package-lock.json +++ b/platform/packages/cli/package-lock.json @@ -1,12 +1,12 @@ { "name": "@dynamia-tools/cli", - "version": "26.4.1", + "version": "26.5.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@dynamia-tools/cli", - "version": "26.4.1", + "version": "26.5.0", "license": "Apache-2.0", "dependencies": { "@inquirer/prompts": "^7.0.0", diff --git a/platform/packages/cli/package.json b/platform/packages/cli/package.json index b927182c..a5f124ad 100644 --- a/platform/packages/cli/package.json +++ b/platform/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@dynamia-tools/cli", - "version": "26.4.1", + "version": "26.5.0", "description": "Dynamia Tools CLI — Scaffold new Dynamia Platform projects", "keywords": [ "dynamia", diff --git a/platform/packages/sdk/package-lock.json b/platform/packages/sdk/package-lock.json index 4e95bbdd..972682d8 100644 --- a/platform/packages/sdk/package-lock.json +++ b/platform/packages/sdk/package-lock.json @@ -1,12 +1,12 @@ { "name": "@dynamia-tools/sdk", - "version": "26.4.1", + "version": "26.5.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@dynamia-tools/sdk", - "version": "26.4.1", + "version": "26.5.0", "license": "Apache-2.0", "devDependencies": { "@types/node": "^22.0.0", diff --git a/platform/packages/sdk/package.json b/platform/packages/sdk/package.json index 3ed457c3..f05b3d19 100644 --- a/platform/packages/sdk/package.json +++ b/platform/packages/sdk/package.json @@ -1,6 +1,6 @@ { "name": "@dynamia-tools/sdk", - "version": "26.4.1", + "version": "26.5.0", "website": "https://dynamia.tools", "description": "Official JavaScript / TypeScript client SDK for Dynamia Platform REST APIs", "keywords": [ diff --git a/platform/packages/ui-core/package.json b/platform/packages/ui-core/package.json index a317d131..0d773767 100644 --- a/platform/packages/ui-core/package.json +++ b/platform/packages/ui-core/package.json @@ -1,6 +1,6 @@ { "name": "@dynamia-tools/ui-core", - "version": "26.4.1", + "version": "26.5.0", "description": "Framework-agnostic view/viewer/renderer core for Dynamia Platform", "keywords": [ "dynamia", diff --git a/platform/packages/vue/package.json b/platform/packages/vue/package.json index 833195ef..933db10d 100644 --- a/platform/packages/vue/package.json +++ b/platform/packages/vue/package.json @@ -1,6 +1,6 @@ { "name": "@dynamia-tools/vue", - "version": "26.4.1", + "version": "26.5.0", "description": "Vue 3 adapter for Dynamia Platform UI", "keywords": [ "dynamia", From 04e83a1f18c1c5c1b98517c9cccc003c291d7dab Mon Sep 17 00:00:00 2001 From: Mario Serrano Date: Sat, 16 May 2026 00:52:31 -0500 Subject: [PATCH 12/22] feat: add initial implementation of core services and configuration for simple-file-server --- .../packages/simple-file-server/README.md | 148 +++++++++ .../packages/simple-file-server/package.json | 74 +++++ .../src/auth/identity.service.ts | 131 ++++++++ .../simple-file-server/src/cli/index.ts | 222 +++++++++++++ .../src/config/config.service.ts | 77 +++++ .../simple-file-server/src/errors/index.ts | 93 ++++++ .../src/http/plugins/auth.plugin.ts | 74 +++++ .../src/http/routes/objects.route.ts | 243 ++++++++++++++ .../simple-file-server/src/http/server.ts | 85 +++++ .../packages/simple-file-server/src/index.ts | 11 + .../simple-file-server/src/logging/logger.ts | 103 ++++++ .../simple-file-server/src/runtime/index.ts | 83 +++++ .../src/storage/bucket.service.ts | 174 ++++++++++ .../src/storage/storage.service.ts | 298 ++++++++++++++++++ .../src/thumbnail/thumbnail.service.ts | 111 +++++++ .../simple-file-server/src/types/index.ts | 52 +++ .../simple-file-server/test/core.test.ts | 203 ++++++++++++ .../simple-file-server/tsconfig.build.json | 7 + .../packages/simple-file-server/tsconfig.json | 25 ++ .../simple-file-server/vitest.config.ts | 10 + 20 files changed, 2224 insertions(+) create mode 100644 extensions/entity-files/packages/simple-file-server/README.md create mode 100644 extensions/entity-files/packages/simple-file-server/package.json create mode 100644 extensions/entity-files/packages/simple-file-server/src/auth/identity.service.ts create mode 100644 extensions/entity-files/packages/simple-file-server/src/cli/index.ts create mode 100644 extensions/entity-files/packages/simple-file-server/src/config/config.service.ts create mode 100644 extensions/entity-files/packages/simple-file-server/src/errors/index.ts create mode 100644 extensions/entity-files/packages/simple-file-server/src/http/plugins/auth.plugin.ts create mode 100644 extensions/entity-files/packages/simple-file-server/src/http/routes/objects.route.ts create mode 100644 extensions/entity-files/packages/simple-file-server/src/http/server.ts create mode 100644 extensions/entity-files/packages/simple-file-server/src/index.ts create mode 100644 extensions/entity-files/packages/simple-file-server/src/logging/logger.ts create mode 100644 extensions/entity-files/packages/simple-file-server/src/runtime/index.ts create mode 100644 extensions/entity-files/packages/simple-file-server/src/storage/bucket.service.ts create mode 100644 extensions/entity-files/packages/simple-file-server/src/storage/storage.service.ts create mode 100644 extensions/entity-files/packages/simple-file-server/src/thumbnail/thumbnail.service.ts create mode 100644 extensions/entity-files/packages/simple-file-server/src/types/index.ts create mode 100644 extensions/entity-files/packages/simple-file-server/test/core.test.ts create mode 100644 extensions/entity-files/packages/simple-file-server/tsconfig.build.json create mode 100644 extensions/entity-files/packages/simple-file-server/tsconfig.json create mode 100644 extensions/entity-files/packages/simple-file-server/vitest.config.ts diff --git a/extensions/entity-files/packages/simple-file-server/README.md b/extensions/entity-files/packages/simple-file-server/README.md new file mode 100644 index 00000000..dec36b14 --- /dev/null +++ b/extensions/entity-files/packages/simple-file-server/README.md @@ -0,0 +1,148 @@ +# simple-file-server (SFS) + +A standalone filesystem-native file server with a minimal S3-style API optimized for secure server-to-server file operations. + +## Overview + +SFS is designed as the backend for `RemoteSimpleEntityFileStorage` in EntityFiles and optimized for: + +- ERP systems and multi-tenant applications +- Internal services and hybrid infrastructure +- Large file streaming with constant memory usage +- Low operational complexity (no database, no external dependencies) + +## Quick Start + +### Installation + +```bash +npm install -g @dynamia-tools/simple-file-server +``` + +### Initialize and configure + +```bash +# Create a bucket +sfs create bucket documents /mnt/storage/documents + +# Create an identity +sfs create identity erp-prod my-secret-password + +# Grant permissions +sfs grant erp-prod documents --read --write --delete --prefix /tenant-a/ + +# Start the server +sfs serve --host 0.0.0.0 --port 8080 +``` + +## API + +All requests require authentication via `X-SFS-Identity` + `X-SFS-Secret` headers, or HTTP Basic Auth. + +### Download a file +```http +GET /:bucket/:key +X-SFS-Identity: erp-prod +X-SFS-Secret: my-secret-password +``` + +### Download a thumbnail +```http +GET /:bucket/:key?w=300&h=300&fit=cover&format=webp +``` + +### List a directory +```http +GET /:bucket/:path/ +``` + +### List bucket contents (paginated) +```http +GET /:bucket?limit=100&cursor=... +``` + +### Upload a file +```http +PUT /:bucket/:key +Content-Type: application/octet-stream +[body: file stream] +``` + +### Delete a file +```http +DELETE /:bucket/:key +``` + +### Health check +```http +GET /health +``` + +## CLI Reference + +```bash +# Server +sfs serve [--host ] [--port ] [--data-dir

] [--log-level ] + +# Buckets +sfs create bucket +sfs list buckets +sfs remove bucket + +# Identities +sfs create identity +sfs list identities +sfs remove identity + +# Permissions +sfs grant --read --write --delete --prefix +sfs revoke +``` + +## Data Directory Structure + +SFS stores all runtime configuration in the working directory: + +``` +./sfs/ + ├── config/ # Server configuration + ├── identities/ # Identity definitions + hashed secrets + grants + ├── buckets/ # Bucket definitions + ├── logs/ # Operational logs (JSONL format) + │ ├── access.log.jsonl + │ └── error.log.jsonl + ├── runtime/ # Runtime metadata + └── cache/ # (reserved) +``` + +Each bucket also contains an internal `.sfs/` directory: + +``` +/path/to/bucket/ + └── .sfs/ + ├── staging/ # Upload staging area + ├── thumbs/ # Thumbnail cache + └── runtime/ # Temporary artifacts +``` + +## Security + +- **Private by default**: every request requires authentication +- **Argon2id password hashing**: secrets are never stored in plain text +- **Prefix-based authorization**: access restricted by bucket and path prefix +- **Path traversal protection**: canonical path validation on every request +- **No public access**: no anonymous or public bucket support + +## Technology Stack + +- **Node.js 22+** with TypeScript (strict mode) +- **Fastify** for high-throughput HTTP +- **Pino** for structured JSON logging +- **Sharp** for efficient thumbnail generation +- **Argon2** for password hashing +- **file-type** for MIME detection via magic bytes + +## License + +Apache-2.0 © Dynamia Soluciones IT SAS + diff --git a/extensions/entity-files/packages/simple-file-server/package.json b/extensions/entity-files/packages/simple-file-server/package.json new file mode 100644 index 00000000..1883282c --- /dev/null +++ b/extensions/entity-files/packages/simple-file-server/package.json @@ -0,0 +1,74 @@ +{ + "name": "@dynamia-tools/simple-file-server", + "version": "26.5.0", + "description": "Standalone filesystem-native file server with a minimal S3-style API for secure server-to-server file operations", + "keywords": [ + "dynamia", + "entity-files", + "file-server", + "storage", + "s3-like", + "streaming" + ], + "homepage": "https://dynamia.tools", + "bugs": { + "url": "https://github.com/dynamiatools/framework/issues" + }, + "repository": { + "type": "git", + "url": "https://github.com/dynamiatools/framework.git", + "directory": "extensions/entity-files/packages/simple-file-server" + }, + "license": "Apache-2.0", + "author": "Dynamia Soluciones IT SAS", + "type": "module", + "bin": { + "sfs": "./dist/cli/index.js" + }, + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "import": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + } + }, + "files": [ + "dist", + "README.md", + "LICENSE" + ], + "scripts": { + "build": "tsc -p tsconfig.build.json", + "dev": "tsx watch src/cli/index.ts serve", + "start": "node dist/cli/index.js serve", + "test": "vitest run", + "test:watch": "vitest", + "typecheck": "tsc --noEmit", + "clean": "rm -rf dist" + }, + "dependencies": { + "@fastify/sensible": "^5.6.0", + "argon2": "^0.43.0", + "commander": "^13.1.0", + "fastify": "^5.3.0", + "file-type": "^21.0.0", + "pino": "^9.7.0", + "pino-pretty": "^13.0.0", + "sharp": "^0.34.1", + "zod": "^3.24.0" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "@types/sharp": "^0.31.0", + "tsx": "^4.19.0", + "typescript": "^5.7.0", + "vitest": "^3.0.0" + }, + "engines": { + "node": ">=22.0.0" + } +} + diff --git a/extensions/entity-files/packages/simple-file-server/src/auth/identity.service.ts b/extensions/entity-files/packages/simple-file-server/src/auth/identity.service.ts new file mode 100644 index 00000000..b5b0786a --- /dev/null +++ b/extensions/entity-files/packages/simple-file-server/src/auth/identity.service.ts @@ -0,0 +1,131 @@ +import path from 'node:path' +import fsp from 'node:fs/promises' +import * as argon2 from 'argon2' +import { resolveDataPaths, readJsonFile, writeJsonFile } from '../config/config.service.js' +import type { Identity, Grant, Permission } from '../types/index.js' +import { SFSError, SFSErrorCode, notFound } from '../errors/index.js' + +function conflict(message: string, code: SFSErrorCode): SFSError { + return new SFSError(code, message, 409) +} + +export class IdentityService { + private identitiesDir: string + + constructor(dataDir: string) { + this.identitiesDir = resolveDataPaths(dataDir).identitiesDir + } + + private identityPath(identity: string): string { + const safe = identity.replace(/[^a-zA-Z0-9_\-\.]/g, '_') + return path.join(this.identitiesDir, `${safe}.json`) + } + + async create(identity: string, secret: string): Promise { + const existing = await this.find(identity) + if (existing) { + throw conflict(`Identity '${identity}' already exists`, SFSErrorCode.IDENTITY_ALREADY_EXISTS) + } + + const hashedSecret = await argon2.hash(secret, { + type: argon2.argon2id, + memoryCost: 65536, + timeCost: 3, + parallelism: 4, + }) + + const now = new Date().toISOString() + const id: Identity = { + identity, + hashedSecret, + grants: [], + createdAt: now, + updatedAt: now, + } + + await writeJsonFile(this.identityPath(identity), id) + return id + } + + async find(identity: string): Promise { + return readJsonFile(this.identityPath(identity)) + } + + async get(identity: string): Promise { + const id = await this.find(identity) + if (!id) { + throw notFound(`Identity '${identity}'`, SFSErrorCode.IDENTITY_NOT_FOUND) + } + return id + } + + async list(): Promise { + try { + const files = await fsp.readdir(this.identitiesDir) + const identities: Identity[] = [] + for (const file of files) { + if (!file.endsWith('.json')) continue + const id = await readJsonFile(path.join(this.identitiesDir, file)) + if (id) identities.push(id) + } + return identities + } catch { + return [] + } + } + + async verify(identity: string, secret: string): Promise { + const id = await this.find(identity) + if (!id) return null + try { + const valid = await argon2.verify(id.hashedSecret, secret) + return valid ? id : null + } catch { + return null + } + } + + async grant(identity: string, grant: Grant): Promise { + const id = await this.get(identity) + id.grants = id.grants.filter(g => g.bucket !== grant.bucket) + id.grants.push(grant) + id.updatedAt = new Date().toISOString() + await writeJsonFile(this.identityPath(identity), id) + return id + } + + async revoke(identity: string, bucket: string): Promise { + const id = await this.get(identity) + id.grants = id.grants.filter(g => g.bucket !== bucket) + id.updatedAt = new Date().toISOString() + await writeJsonFile(this.identityPath(identity), id) + return id + } + + async delete(identity: string): Promise { + const filePath = this.identityPath(identity) + try { + await fsp.unlink(filePath) + } catch { + throw notFound(`Identity '${identity}'`, SFSErrorCode.IDENTITY_NOT_FOUND) + } + } + + checkPermission(id: Identity, bucket: string, key: string, permission: Permission): Grant | null { + for (const grant of id.grants) { + if (grant.bucket !== bucket) continue + if (!grant.permissions.includes(permission)) continue + + if (grant.prefixes.length === 0) return grant + + const normalizedKey = key.startsWith('/') ? key : `/${key}` + for (const prefix of grant.prefixes) { + const normalizedPrefix = prefix.startsWith('/') ? prefix : `/${prefix}` + if (normalizedKey.startsWith(normalizedPrefix) || normalizedPrefix === '/') { + return grant + } + } + } + return null + } +} diff --git a/extensions/entity-files/packages/simple-file-server/src/cli/index.ts b/extensions/entity-files/packages/simple-file-server/src/cli/index.ts new file mode 100644 index 00000000..7c5cb194 --- /dev/null +++ b/extensions/entity-files/packages/simple-file-server/src/cli/index.ts @@ -0,0 +1,222 @@ +#!/usr/bin/env node + +import { Command } from 'commander' +import { createRuntime, startServer } from '../runtime/index.js' +import { getDataDir } from '../config/config.service.js' +import type { Permission } from '../types/index.js' + +const program = new Command() + +program + .name('sfs') + .description('Simple File Server - filesystem-native file server with S3-style API') + .version('26.5.0') + +// ────────────────────────────────────────────────────────── +// sfs serve +// ────────────────────────────────────────────────────────── +program + .command('serve') + .description('Start the SFS server') + .option('--host ', 'Host to listen on', process.env.SFS_HOST ?? '0.0.0.0') + .option('--port ', 'Port to listen on', process.env.SFS_PORT ?? '8080') + .option('--data-dir ', 'Data directory', getDataDir()) + .option('--log-level ', 'Log level (trace|debug|info|warn|error)', 'info') + .action(async (opts) => { + await startServer({ + host: opts.host, + port: parseInt(opts.port, 10), + dataDir: opts.dataDir, + logLevel: opts.logLevel, + }) + }) + +// ────────────────────────────────────────────────────────── +// sfs create +// ────────────────────────────────────────────────────────── +const create = program.command('create').description('Create resources') + +create + .command('bucket ') + .description('Create a new bucket mapped to an absolute filesystem path') + .option('--data-dir ', 'Data directory', getDataDir()) + .action(async (name: string, absolutePath: string, opts) => { + const runtime = await createRuntime({ dataDir: opts.dataDir }) + try { + const bucket = await runtime.bucketService.create(name, absolutePath) + console.log(JSON.stringify({ ok: true, data: bucket }, null, 2)) + } catch (err: unknown) { + printError(err) + } finally { + await runtime.operationalLogger.close() + } + }) + +create + .command('identity ') + .description('Create a new identity with the given secret') + .option('--data-dir ', 'Data directory', getDataDir()) + .action(async (identity: string, secret: string, opts) => { + const runtime = await createRuntime({ dataDir: opts.dataDir }) + try { + const id = await runtime.identityService.create(identity, secret) + const safeId = { ...id, hashedSecret: '[REDACTED]' } + console.log(JSON.stringify({ ok: true, data: safeId }, null, 2)) + } catch (err: unknown) { + printError(err) + } finally { + await runtime.operationalLogger.close() + } + }) + +// ────────────────────────────────────────────────────────── +// sfs list +// ────────────────────────────────────────────────────────── +const list = program.command('list').description('List resources') + +list + .command('buckets') + .description('List all buckets') + .option('--data-dir ', 'Data directory', getDataDir()) + .action(async (opts) => { + const runtime = await createRuntime({ dataDir: opts.dataDir }) + try { + const buckets = await runtime.bucketService.list() + console.log(JSON.stringify({ ok: true, data: buckets }, null, 2)) + } catch (err: unknown) { + printError(err) + } finally { + await runtime.operationalLogger.close() + } + }) + +list + .command('identities') + .description('List all identities') + .option('--data-dir ', 'Data directory', getDataDir()) + .action(async (opts) => { + const runtime = await createRuntime({ dataDir: opts.dataDir }) + try { + const ids = await runtime.identityService.list() + const safeIds = ids.map(id => ({ ...id, hashedSecret: '[REDACTED]' })) + console.log(JSON.stringify({ ok: true, data: safeIds }, null, 2)) + } catch (err: unknown) { + printError(err) + } finally { + await runtime.operationalLogger.close() + } + }) + +// ────────────────────────────────────────────────────────── +// sfs remove +// ────────────────────────────────────────────────────────── +const remove = program.command('remove').description('Remove resources') + +remove + .command('bucket ') + .description('Remove a bucket definition (does not delete files)') + .option('--data-dir ', 'Data directory', getDataDir()) + .action(async (name: string, opts) => { + const runtime = await createRuntime({ dataDir: opts.dataDir }) + try { + await runtime.bucketService.delete(name) + console.log(JSON.stringify({ ok: true, data: { removed: name } }, null, 2)) + } catch (err: unknown) { + printError(err) + } finally { + await runtime.operationalLogger.close() + } + }) + +remove + .command('identity ') + .description('Remove an identity') + .option('--data-dir ', 'Data directory', getDataDir()) + .action(async (identity: string, opts) => { + const runtime = await createRuntime({ dataDir: opts.dataDir }) + try { + await runtime.identityService.delete(identity) + console.log(JSON.stringify({ ok: true, data: { removed: identity } }, null, 2)) + } catch (err: unknown) { + printError(err) + } finally { + await runtime.operationalLogger.close() + } + }) + +// ────────────────────────────────────────────────────────── +// sfs grant +// ────────────────────────────────────────────────────────── +program + .command('grant ') + .description('Grant permissions to an identity on a bucket') + .option('--read', 'Grant read permission') + .option('--write', 'Grant write permission') + .option('--delete', 'Grant delete permission') + .option('--prefix ', 'Restrict to path prefix (can be used multiple times)', collect, [] as string[]) + .option('--data-dir ', 'Data directory', getDataDir()) + .action(async (identity: string, bucket: string, opts) => { + const permissions: Permission[] = [] + if (opts.read) permissions.push('read') + if (opts.write) permissions.push('write') + if (opts.delete) permissions.push('delete') + + if (permissions.length === 0) { + console.error(JSON.stringify({ ok: false, error: { code: 'SFS_INVALID_ARGS', message: 'At least one permission (--read, --write, --delete) must be specified' } })) + process.exit(1) + } + + const runtime = await createRuntime({ dataDir: opts.dataDir }) + try { + const id = await runtime.identityService.grant(identity, { + bucket, + prefixes: opts.prefix as string[], + permissions, + }) + const safeId = { ...id, hashedSecret: '[REDACTED]' } + console.log(JSON.stringify({ ok: true, data: safeId }, null, 2)) + } catch (err: unknown) { + printError(err) + } finally { + await runtime.operationalLogger.close() + } + }) + +// ────────────────────────────────────────────────────────── +// sfs revoke +// ────────────────────────────────────────────────────────── +program + .command('revoke ') + .description('Revoke all permissions from an identity on a bucket') + .option('--data-dir ', 'Data directory', getDataDir()) + .action(async (identity: string, bucket: string, opts) => { + const runtime = await createRuntime({ dataDir: opts.dataDir }) + try { + const id = await runtime.identityService.revoke(identity, bucket) + const safeId = { ...id, hashedSecret: '[REDACTED]' } + console.log(JSON.stringify({ ok: true, data: safeId }, null, 2)) + } catch (err: unknown) { + printError(err) + } finally { + await runtime.operationalLogger.close() + } + }) + +// ────────────────────────────────────────────────────────── +// Helpers +// ────────────────────────────────────────────────────────── +function collect(value: string, previous: string[]): string[] { + return previous.concat([value]) +} + +function printError(err: unknown): void { + if (err && typeof err === 'object' && 'toJSON' in err && typeof (err as { toJSON: unknown }).toJSON === 'function') { + console.error(JSON.stringify((err as { toJSON: () => unknown }).toJSON(), null, 2)) + } else { + console.error(JSON.stringify({ ok: false, error: { code: 'SFS_ERROR', message: String(err) } }, null, 2)) + } + process.exit(1) +} + +program.parse(process.argv) + diff --git a/extensions/entity-files/packages/simple-file-server/src/config/config.service.ts b/extensions/entity-files/packages/simple-file-server/src/config/config.service.ts new file mode 100644 index 00000000..d408f30a --- /dev/null +++ b/extensions/entity-files/packages/simple-file-server/src/config/config.service.ts @@ -0,0 +1,77 @@ +import path from 'node:path' +import fs from 'node:fs' +import fsp from 'node:fs/promises' +import type { SFSConfig } from '../types/index.js' + +// Default data directory relative to CWD +const DEFAULT_DATA_DIR = path.join(process.cwd(), 'sfs') + +export function getDataDir(): string { + return process.env.SFS_DATA_DIR ?? DEFAULT_DATA_DIR +} + +export function getDefaultConfig(): SFSConfig { + return { + dataDir: getDataDir(), + host: process.env.SFS_HOST ?? '0.0.0.0', + port: parseInt(process.env.SFS_PORT ?? '8080', 10), + logLevel: process.env.SFS_LOG_LEVEL ?? 'info', + } +} + +export function resolveDataPaths(dataDir: string) { + return { + dataDir, + configDir: path.join(dataDir, 'config'), + identitiesDir: path.join(dataDir, 'identities'), + bucketsDir: path.join(dataDir, 'buckets'), + logsDir: path.join(dataDir, 'logs'), + runtimeDir: path.join(dataDir, 'runtime'), + cacheDir: path.join(dataDir, 'cache'), + } +} + +export async function ensureDataDirs(dataDir: string): Promise { + const paths = resolveDataPaths(dataDir) + for (const dir of Object.values(paths)) { + await fsp.mkdir(dir, { recursive: true }) + } +} + +export function ensureDataDirsSync(dataDir: string): void { + const paths = resolveDataPaths(dataDir) + for (const dir of Object.values(paths)) { + fs.mkdirSync(dir, { recursive: true }) + } +} + +export async function readJsonFile(filePath: string): Promise { + try { + const content = await fsp.readFile(filePath, 'utf-8') + return JSON.parse(content) as T + } catch { + return null + } +} + +export async function writeJsonFile(filePath: string, data: T): Promise { + const dir = path.dirname(filePath) + await fsp.mkdir(dir, { recursive: true }) + await fsp.writeFile(filePath, JSON.stringify(data, null, 2), 'utf-8') +} + +export function readJsonFileSync(filePath: string): T | null { + try { + const content = fs.readFileSync(filePath, 'utf-8') + return JSON.parse(content) as T + } catch { + return null + } +} + +export function writeJsonFileSync(filePath: string, data: T): void { + const dir = path.dirname(filePath) + fs.mkdirSync(dir, { recursive: true }) + fs.writeFileSync(filePath, JSON.stringify(data, null, 2), 'utf-8') +} + diff --git a/extensions/entity-files/packages/simple-file-server/src/errors/index.ts b/extensions/entity-files/packages/simple-file-server/src/errors/index.ts new file mode 100644 index 00000000..a21d7772 --- /dev/null +++ b/extensions/entity-files/packages/simple-file-server/src/errors/index.ts @@ -0,0 +1,93 @@ +// SFS Error codes and helpers + +export enum SFSErrorCode { + // Auth + AUTH_REQUIRED = 'SFS_AUTH_REQUIRED', + AUTH_INVALID = 'SFS_AUTH_INVALID', + PERMISSION_DENIED = 'SFS_PERMISSION_DENIED', + + // Objects + OBJECT_NOT_FOUND = 'SFS_OBJECT_NOT_FOUND', + OBJECT_IS_DIRECTORY = 'SFS_OBJECT_IS_DIRECTORY', + PATH_TRAVERSAL = 'SFS_PATH_TRAVERSAL', + PATH_INVALID = 'SFS_PATH_INVALID', + PATH_RESERVED = 'SFS_PATH_RESERVED', + + // Buckets + BUCKET_NOT_FOUND = 'SFS_BUCKET_NOT_FOUND', + BUCKET_ALREADY_EXISTS = 'SFS_BUCKET_ALREADY_EXISTS', + BUCKET_PATH_INVALID = 'SFS_BUCKET_PATH_INVALID', + BUCKET_PATH_NOT_WRITABLE = 'SFS_BUCKET_PATH_NOT_WRITABLE', + + // Identities + IDENTITY_NOT_FOUND = 'SFS_IDENTITY_NOT_FOUND', + IDENTITY_ALREADY_EXISTS = 'SFS_IDENTITY_ALREADY_EXISTS', + + // Uploads + UPLOAD_FAILED = 'SFS_UPLOAD_FAILED', + UPLOAD_STAGING_FAILED = 'SFS_UPLOAD_STAGING_FAILED', + + // General + INTERNAL_ERROR = 'SFS_INTERNAL_ERROR', + NOT_IMPLEMENTED = 'SFS_NOT_IMPLEMENTED', +} + +export interface SFSErrorDetails { + code: SFSErrorCode + message: string + details?: Record +} + +export class SFSError extends Error { + public readonly code: SFSErrorCode + public readonly statusCode: number + public readonly details?: Record + + constructor(code: SFSErrorCode, message: string, statusCode: number = 500, details?: Record) { + super(message) + this.name = 'SFSError' + this.code = code + this.statusCode = statusCode + this.details = details + } + + toJSON(): { ok: false; error: SFSErrorDetails } { + return { + ok: false, + error: { + code: this.code, + message: this.message, + details: this.details, + }, + } + } +} + +export function notFound(resource: string, code: SFSErrorCode = SFSErrorCode.OBJECT_NOT_FOUND): SFSError { + return new SFSError(code, `${resource} not found`, 404) +} + +export function forbidden(message: string, code: SFSErrorCode = SFSErrorCode.PERMISSION_DENIED): SFSError { + return new SFSError(code, message, 403) +} + +export function unauthorized(message: string = 'Authentication required'): SFSError { + return new SFSError(SFSErrorCode.AUTH_REQUIRED, message, 401) +} + +export function badRequest(message: string, code: SFSErrorCode = SFSErrorCode.PATH_INVALID): SFSError { + return new SFSError(code, message, 400) +} + +export function conflict(message: string, code: SFSErrorCode): SFSError { + return new SFSError(code, message, 409) +} + +export function internalError(message: string, details?: Record): SFSError { + return new SFSError(SFSErrorCode.INTERNAL_ERROR, message, 500, details) +} + +export function successResponse(data: T): { ok: true; data: T } { + return { ok: true, data } +} + diff --git a/extensions/entity-files/packages/simple-file-server/src/http/plugins/auth.plugin.ts b/extensions/entity-files/packages/simple-file-server/src/http/plugins/auth.plugin.ts new file mode 100644 index 00000000..08f30bbd --- /dev/null +++ b/extensions/entity-files/packages/simple-file-server/src/http/plugins/auth.plugin.ts @@ -0,0 +1,74 @@ +import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify' +import { IdentityService } from '../../auth/identity.service.js' +import type { Identity } from '../../types/index.js' +import { SFSError, SFSErrorCode, unauthorized } from '../../errors/index.js' +import type { OperationalLogger } from '../../logging/logger.js' + +declare module 'fastify' { + interface FastifyRequest { + authIdentity: Identity + } +} + +interface AuthPluginOptions { + identityService: IdentityService + logger: OperationalLogger +} + +export async function authPlugin(fastify: FastifyInstance, options: AuthPluginOptions): Promise { + const { identityService, logger } = options + + fastify.decorateRequest('authIdentity', { getter: () => null }) + + fastify.addHook('onRequest', async (request: FastifyRequest, reply: FastifyReply) => { + // Skip health check + if (request.url === '/health') return + + const identity = extractIdentity(request) + const secret = extractSecret(request) + + if (!identity || !secret) { + logger.logError('auth_failed', 'Missing credentials', { ip: request.ip }) + const err = unauthorized('Authentication required. Provide X-SFS-Identity and X-SFS-Secret headers.') + return reply.status(err.statusCode).send(err.toJSON()) + } + + const id = await identityService.verify(identity, secret) + if (!id) { + logger.logError('auth_failed', 'Invalid credentials', { identity, ip: request.ip }) + const err = new SFSError(SFSErrorCode.AUTH_INVALID, 'Invalid credentials', 401) + return reply.status(err.statusCode).send(err.toJSON()) + } + + request.authIdentity = id + }) +} + +function extractIdentity(request: FastifyRequest): string | undefined { + const headerIdentity = request.headers['x-sfs-identity'] + if (typeof headerIdentity === 'string' && headerIdentity) return headerIdentity + + const auth = request.headers.authorization + if (auth?.startsWith('Basic ')) { + const decoded = Buffer.from(auth.slice(6), 'base64').toString('utf-8') + const colonIdx = decoded.indexOf(':') + if (colonIdx > 0) return decoded.slice(0, colonIdx) + } + + return undefined +} + +function extractSecret(request: FastifyRequest): string | undefined { + const headerSecret = request.headers['x-sfs-secret'] + if (typeof headerSecret === 'string' && headerSecret) return headerSecret + + const auth = request.headers.authorization + if (auth?.startsWith('Basic ')) { + const decoded = Buffer.from(auth.slice(6), 'base64').toString('utf-8') + const colonIdx = decoded.indexOf(':') + if (colonIdx >= 0) return decoded.slice(colonIdx + 1) + } + + return undefined +} + diff --git a/extensions/entity-files/packages/simple-file-server/src/http/routes/objects.route.ts b/extensions/entity-files/packages/simple-file-server/src/http/routes/objects.route.ts new file mode 100644 index 00000000..efcbeb92 --- /dev/null +++ b/extensions/entity-files/packages/simple-file-server/src/http/routes/objects.route.ts @@ -0,0 +1,243 @@ +import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify' +import type { BucketService } from '../../storage/bucket.service.js' +import type { StorageService } from '../../storage/storage.service.js' +import type { ThumbnailService } from '../../thumbnail/thumbnail.service.js' +import type { IdentityService } from '../../auth/identity.service.js' +import type { OperationalLogger } from '../../logging/logger.js' +import { SFSError, SFSErrorCode, forbidden, successResponse } from '../../errors/index.js' +import type { Permission } from '../../types/index.js' + +interface ObjectRoutesOptions { + bucketService: BucketService + storageService: StorageService + thumbnailService: ThumbnailService + identityService: IdentityService + logger: OperationalLogger +} + +export async function objectRoutes(fastify: FastifyInstance, options: ObjectRoutesOptions): Promise { + const { bucketService, storageService, thumbnailService, identityService, logger } = options + + /** + * Helper: resolve bucket and check permission + */ + async function resolveBucketAndCheck( + request: FastifyRequest, + reply: FastifyReply, + bucketName: string, + key: string, + permission: Permission, + ) { + const bucket = await bucketService.get(bucketName) + const identity = request.authIdentity + + const grant = identityService.checkPermission(identity, bucketName, key, permission) + if (!grant) { + logger.logError('permission_denied', `Identity '${identity.identity}' lacks '${permission}' on '${bucketName}/${key}'`, { + identity: identity.identity, + bucket: bucketName, + key, + ip: request.ip, + }) + throw forbidden(`Permission '${permission}' denied on bucket '${bucketName}' for path '${key}'`) + } + + return bucket + } + + /** + * GET /:bucket - Bucket listing with pagination + */ + fastify.get<{ + Params: { bucket: string } + Querystring: { limit?: string; cursor?: string } + }>('/:bucket', async (request, reply) => { + const { bucket: bucketName } = request.params + const limit = Math.min(parseInt(request.query.limit ?? '100', 10), 1000) + const cursor = request.query.cursor + + const bucket = await resolveBucketAndCheck(request, reply, bucketName, '/', 'read') + const start = Date.now() + + const result = await storageService.listBucket(bucket, limit, cursor) + + logger.log({ + event: 'list', + identity: request.authIdentity.identity, + bucket: bucketName, + elapsedMs: Date.now() - start, + ip: request.ip, + }) + + return reply.send(successResponse(result)) + }) + + /** + * GET /:bucket/*key - Download file or list directory + */ + fastify.get<{ + Params: { bucket: string; '*': string } + Querystring: { w?: string; h?: string; fit?: string; format?: string } + }>('/:bucket/*', async (request, reply) => { + const { bucket: bucketName } = request.params + const key = request.params['*'] ?? '' + + const bucket = await resolveBucketAndCheck(request, reply, bucketName, key, 'read') + const start = Date.now() + + // If key ends with '/', it's a directory listing + if (key.endsWith('/') || key === '') { + const result = await storageService.listDirectory(bucket, key) + logger.log({ + event: 'list', + identity: request.authIdentity.identity, + bucket: bucketName, + key, + elapsedMs: Date.now() - start, + ip: request.ip, + }) + return reply.send(successResponse(result)) + } + + // Check for thumbnail request + const { w, h, fit, format } = request.query + if (w || h) { + const thumbOptions = { + width: w ? parseInt(w, 10) : undefined, + height: h ? parseInt(h, 10) : undefined, + fit: (fit as 'cover' | 'contain' | 'fill' | 'inside' | 'outside') ?? 'cover', + format: (format as 'jpeg' | 'png' | 'webp') ?? 'webp', + } + + const thumb = await thumbnailService.getThumbnail(bucket, key, thumbOptions) + if (thumb) { + reply.header('Content-Type', thumb.mimeType) + reply.header('Content-Length', thumb.size.toString()) + reply.header('Cache-Control', 'public, max-age=86400') + logger.log({ + event: 'download', + identity: request.authIdentity.identity, + bucket: bucketName, + key: `${key}?thumbnail`, + size: thumb.size, + elapsedMs: Date.now() - start, + ip: request.ip, + }) + return reply.send(thumb.stream) + } + } + + // Regular file download + const download = await storageService.download(bucket, key) + + reply.header('Content-Type', download.mimeType) + reply.header('Content-Length', download.size.toString()) + reply.header('ETag', download.etag) + reply.header('Last-Modified', download.lastModified.toUTCString()) + reply.header('Cache-Control', 'private, max-age=0') + + logger.log({ + event: 'download', + identity: request.authIdentity.identity, + bucket: bucketName, + key, + size: download.size, + elapsedMs: Date.now() - start, + ip: request.ip, + }) + + return reply.send(download.stream) + }) + + /** + * PUT /:bucket/*key - Upload file + */ + fastify.put<{ + Params: { bucket: string; '*': string } + }>('/:bucket/*', async (request, reply) => { + const { bucket: bucketName } = request.params + const key = request.params['*'] ?? '' + + if (!key) { + return reply.status(400).send({ + ok: false, + error: { code: SFSErrorCode.PATH_INVALID, message: 'Key is required for upload' }, + }) + } + + const bucket = await resolveBucketAndCheck(request, reply, bucketName, key, 'write') + const start = Date.now() + + logger.log({ + event: 'upload_started', + identity: request.authIdentity.identity, + bucket: bucketName, + key, + ip: request.ip, + }) + + try { + const result = await storageService.upload(bucket, key, request.raw as any) + + logger.log({ + event: 'upload_committed', + identity: request.authIdentity.identity, + bucket: bucketName, + key, + size: result.size, + elapsedMs: Date.now() - start, + ip: request.ip, + }) + + return reply.status(201).send(successResponse({ + bucket: bucketName, + key, + size: result.size, + etag: result.etag, + })) + } catch (err) { + logger.logError('upload_failed', err instanceof Error ? err : String(err), { + identity: request.authIdentity.identity, + bucket: bucketName, + key, + elapsedMs: Date.now() - start, + ip: request.ip, + }) + throw err + } + }) + + /** + * DELETE /:bucket/*key - Delete file + */ + fastify.delete<{ + Params: { bucket: string; '*': string } + }>('/:bucket/*', async (request, reply) => { + const { bucket: bucketName } = request.params + const key = request.params['*'] ?? '' + + if (!key) { + return reply.status(400).send({ + ok: false, + error: { code: SFSErrorCode.PATH_INVALID, message: 'Key is required for delete' }, + }) + } + + const bucket = await resolveBucketAndCheck(request, reply, bucketName, key, 'delete') + const start = Date.now() + + await storageService.delete(bucket, key) + + logger.log({ + event: 'delete', + identity: request.authIdentity.identity, + bucket: bucketName, + key, + elapsedMs: Date.now() - start, + ip: request.ip, + }) + + return reply.send(successResponse({ bucket: bucketName, key, deleted: true })) + }) +} + diff --git a/extensions/entity-files/packages/simple-file-server/src/http/server.ts b/extensions/entity-files/packages/simple-file-server/src/http/server.ts new file mode 100644 index 00000000..29c9b15f --- /dev/null +++ b/extensions/entity-files/packages/simple-file-server/src/http/server.ts @@ -0,0 +1,85 @@ +import Fastify from 'fastify' +import type { FastifyInstance } from 'fastify' +import { authPlugin } from './plugins/auth.plugin.js' +import { objectRoutes } from './routes/objects.route.js' +import type { BucketService } from '../storage/bucket.service.js' +import type { StorageService } from '../storage/storage.service.js' +import type { ThumbnailService } from '../thumbnail/thumbnail.service.js' +import type { IdentityService } from '../auth/identity.service.js' +import type { OperationalLogger } from '../logging/logger.js' +import { SFSError } from '../errors/index.js' +import type { SFSConfig } from '../types/index.js' + +interface ServerOptions { + config: SFSConfig + bucketService: BucketService + storageService: StorageService + thumbnailService: ThumbnailService + identityService: IdentityService + operationalLogger: OperationalLogger +} + +export async function createServer(options: ServerOptions): Promise { + const { config, bucketService, storageService, thumbnailService, identityService, operationalLogger } = options + + const fastify = Fastify({ + logger: { + level: config.logLevel, + transport: process.env.NODE_ENV !== 'production' + ? { target: 'pino-pretty', options: { translateTime: 'HH:MM:ss Z', ignore: 'pid,hostname' } } + : undefined, + }, + disableRequestLogging: false, + }) + + // Register sensible for default error handling + await fastify.register(import('@fastify/sensible')) + + // Error handler + fastify.setErrorHandler((error, request, reply) => { + if (error instanceof SFSError) { + return reply.status(error.statusCode).send(error.toJSON()) + } + + fastify.log.error(error) + return reply.status(500).send({ + ok: false, + error: { + code: 'SFS_INTERNAL_ERROR', + message: process.env.NODE_ENV === 'production' ? 'Internal server error' : (error instanceof Error ? error.message : String(error)), + }, + }) + }) + + // Not found handler + fastify.setNotFoundHandler((request, reply) => { + return reply.status(404).send({ + ok: false, + error: { + code: 'SFS_NOT_FOUND', + message: `Route ${request.method} ${request.url} not found`, + }, + }) + }) + + // Health check (no auth) + fastify.get('/health', async () => { + return { ok: true, data: { status: 'healthy', uptime: process.uptime() } } + }) + + // Auth plugin (applies to all routes below) + await fastify.register(authPlugin, { identityService, logger: operationalLogger }) + + // Object routes + await fastify.register(objectRoutes, { + bucketService, + storageService, + thumbnailService, + identityService, + logger: operationalLogger, + }) + + return fastify +} + + diff --git a/extensions/entity-files/packages/simple-file-server/src/index.ts b/extensions/entity-files/packages/simple-file-server/src/index.ts new file mode 100644 index 00000000..c28cfcac --- /dev/null +++ b/extensions/entity-files/packages/simple-file-server/src/index.ts @@ -0,0 +1,11 @@ +// Public API exports for simple-file-server +export { createRuntime, startServer } from './runtime/index.js' +export { BucketService } from './storage/bucket.service.js' +export { StorageService } from './storage/storage.service.js' +export { ThumbnailService } from './thumbnail/thumbnail.service.js' +export { IdentityService } from './auth/identity.service.js' +export { OperationalLogger } from './logging/logger.js' +export { createServer } from './http/server.js' +export * from './types/index.js' +export * from './errors/index.js' + diff --git a/extensions/entity-files/packages/simple-file-server/src/logging/logger.ts b/extensions/entity-files/packages/simple-file-server/src/logging/logger.ts new file mode 100644 index 00000000..dad41ba8 --- /dev/null +++ b/extensions/entity-files/packages/simple-file-server/src/logging/logger.ts @@ -0,0 +1,103 @@ +import path from 'node:path' +import fsp from 'node:fs/promises' +import fs from 'node:fs' +import { resolveDataPaths } from '../config/config.service.js' + +export type LogEvent = + | 'upload_started' + | 'upload_staged' + | 'upload_committed' + | 'upload_failed' + | 'download' + | 'list' + | 'delete' + | 'auth_failed' + | 'permission_denied' + | 'server_start' + | 'server_stop' + +export interface LogEntry { + ts: string + event: LogEvent + identity?: string + bucket?: string + key?: string + size?: number + elapsedMs?: number + ip?: string + message?: string + error?: string + [key: string]: unknown +} + +export class OperationalLogger { + private accessLogPath: string + private errorLogPath: string + private accessStream: fs.WriteStream | null = null + private errorStream: fs.WriteStream | null = null + + constructor(dataDir: string) { + const logsDir = resolveDataPaths(dataDir).logsDir + this.accessLogPath = path.join(logsDir, 'access.log.jsonl') + this.errorLogPath = path.join(logsDir, 'error.log.jsonl') + } + + async open(): Promise { + const logsDir = path.dirname(this.accessLogPath) + await fsp.mkdir(logsDir, { recursive: true }) + + this.accessStream = fs.createWriteStream(this.accessLogPath, { flags: 'a' }) + this.errorStream = fs.createWriteStream(this.errorLogPath, { flags: 'a' }) + } + + async close(): Promise { + await new Promise((resolve) => { + if (this.accessStream) { + this.accessStream.end(resolve) + } else { + resolve() + } + }) + await new Promise((resolve) => { + if (this.errorStream) { + this.errorStream.end(resolve) + } else { + resolve() + } + }) + } + + log(entry: Omit & { event: LogEvent }): void { + const fullEntry: LogEntry = { + ts: new Date().toISOString(), + ...entry, + } + + const line = JSON.stringify(fullEntry) + '\n' + const isError = entry.event === 'auth_failed' + || entry.event === 'permission_denied' + || entry.event === 'upload_failed' + + if (this.accessStream) { + this.accessStream.write(line) + } + + if (isError && this.errorStream) { + this.errorStream.write(line) + } + } + + logAccess(event: LogEvent, data: Partial): void { + this.log({ event, ...data }) + } + + logError(event: LogEvent, error: Error | string, data: Partial = {}): void { + this.log({ + event, + error: typeof error === 'string' ? error : error.message, + ...data, + }) + } +} + + diff --git a/extensions/entity-files/packages/simple-file-server/src/runtime/index.ts b/extensions/entity-files/packages/simple-file-server/src/runtime/index.ts new file mode 100644 index 00000000..4ab8e2ac --- /dev/null +++ b/extensions/entity-files/packages/simple-file-server/src/runtime/index.ts @@ -0,0 +1,83 @@ +import { ensureDataDirs, getDefaultConfig } from '../config/config.service.js' +import { IdentityService } from '../auth/identity.service.js' +import { BucketService } from '../storage/bucket.service.js' +import { StorageService } from '../storage/storage.service.js' +import { ThumbnailService } from '../thumbnail/thumbnail.service.js' +import { OperationalLogger } from '../logging/logger.js' +import { createServer } from '../http/server.js' +import type { SFSConfig } from '../types/index.js' + +export interface SFSRuntime { + identityService: IdentityService + bucketService: BucketService + storageService: StorageService + thumbnailService: ThumbnailService + operationalLogger: OperationalLogger + config: SFSConfig +} + +export async function createRuntime(overrides?: Partial): Promise { + const config: SFSConfig = { ...getDefaultConfig(), ...overrides } + + // Ensure all data directories exist + await ensureDataDirs(config.dataDir) + + // Create services + const operationalLogger = new OperationalLogger(config.dataDir) + await operationalLogger.open() + + const identityService = new IdentityService(config.dataDir) + const bucketService = new BucketService(config.dataDir) + const storageService = new StorageService(bucketService) + const thumbnailService = new ThumbnailService(bucketService, storageService) + + return { + config, + identityService, + bucketService, + storageService, + thumbnailService, + operationalLogger, + } +} + +export async function startServer(overrides?: Partial): Promise { + const runtime = await createRuntime(overrides) + const { config, bucketService, storageService, thumbnailService, identityService, operationalLogger } = runtime + + // Validate buckets on startup + await bucketService.validateStartup() + + const server = await createServer({ + config, + bucketService, + storageService, + thumbnailService, + identityService, + operationalLogger, + }) + + operationalLogger.log({ event: 'server_start', message: `Starting SFS on ${config.host}:${config.port}` }) + + // Graceful shutdown + const shutdown = async (signal: string) => { + server.log.info(`Received ${signal}, gracefully shutting down...`) + operationalLogger.log({ event: 'server_stop', message: `Shutting down (${signal})` }) + await server.close() + await operationalLogger.close() + process.exit(0) + } + + process.on('SIGTERM', () => shutdown('SIGTERM')) + process.on('SIGINT', () => shutdown('SIGINT')) + + try { + await server.listen({ host: config.host, port: config.port }) + server.log.info(`SFS server listening on http://${config.host}:${config.port}`) + } catch (err) { + server.log.error(err) + await operationalLogger.close() + process.exit(1) + } +} + diff --git a/extensions/entity-files/packages/simple-file-server/src/storage/bucket.service.ts b/extensions/entity-files/packages/simple-file-server/src/storage/bucket.service.ts new file mode 100644 index 00000000..b66f114f --- /dev/null +++ b/extensions/entity-files/packages/simple-file-server/src/storage/bucket.service.ts @@ -0,0 +1,174 @@ +import path from 'node:path' +import fsp from 'node:fs/promises' +import fs from 'node:fs' +import { resolveDataPaths, readJsonFile, writeJsonFile } from '../config/config.service.js' +import type { Bucket } from '../types/index.js' +import { SFSError, SFSErrorCode, notFound } from '../errors/index.js' + +const SFS_INTERNAL_DIR = '.sfs' + +function conflict(message: string, code: SFSErrorCode): SFSError { + return new SFSError(code, message, 409) +} + +export class BucketService { + private bucketsDir: string + + constructor(dataDir: string) { + this.bucketsDir = resolveDataPaths(dataDir).bucketsDir + } + + private bucketFilePath(name: string): string { + const safe = name.replace(/[^a-zA-Z0-9_\-\.]/g, '_') + return path.join(this.bucketsDir, `${safe}.json`) + } + + async create(name: string, absolutePath: string): Promise { + if (!path.isAbsolute(absolutePath)) { + throw new SFSError(SFSErrorCode.BUCKET_PATH_INVALID, `Bucket path must be absolute: ${absolutePath}`, 400) + } + + const existing = await this.find(name) + if (existing) { + throw conflict(`Bucket '${name}' already exists`, SFSErrorCode.BUCKET_ALREADY_EXISTS) + } + + // Ensure bucket dir exists + await fsp.mkdir(absolutePath, { recursive: true }) + + // Validate writable + try { + await fsp.access(absolutePath, fs.constants.W_OK) + } catch { + throw new SFSError(SFSErrorCode.BUCKET_PATH_NOT_WRITABLE, `Bucket path is not writable: ${absolutePath}`, 400) + } + + // Create internal .sfs directory + await this.ensureBucketInternals(absolutePath) + + const bucket: Bucket = { + name, + path: absolutePath, + createdAt: new Date().toISOString(), + } + + await writeJsonFile(this.bucketFilePath(name), bucket) + return bucket + } + + async find(name: string): Promise { + return readJsonFile(this.bucketFilePath(name)) + } + + async get(name: string): Promise { + const bucket = await this.find(name) + if (!bucket) { + throw notFound(`Bucket '${name}'`, SFSErrorCode.BUCKET_NOT_FOUND) + } + return bucket + } + + async list(): Promise { + try { + const files = await fsp.readdir(this.bucketsDir) + const buckets: Bucket[] = [] + for (const file of files) { + if (!file.endsWith('.json')) continue + const b = await readJsonFile(path.join(this.bucketsDir, file)) + if (b) buckets.push(b) + } + return buckets + } catch { + return [] + } + } + + async delete(name: string): Promise { + const filePath = this.bucketFilePath(name) + try { + await fsp.unlink(filePath) + } catch { + throw notFound(`Bucket '${name}'`, SFSErrorCode.BUCKET_NOT_FOUND) + } + } + + /** + * Resolve a key to an absolute filesystem path within the bucket. + * Validates against path traversal attacks. + */ + resolveKey(bucket: Bucket, key: string): string { + // Normalize to remove double slashes, . and .. + const normalized = path.normalize(key).replace(/\\/g, '/') + + // Block access to internal .sfs directory + const parts = normalized.split('/') + if (parts.some(p => p === SFS_INTERNAL_DIR)) { + throw new SFSError(SFSErrorCode.PATH_RESERVED, `Access to internal '${SFS_INTERNAL_DIR}' path is forbidden`, 403) + } + + const resolved = path.resolve(bucket.path, normalized.replace(/^\//, '')) + + // Ensure resolved path is within bucket root + if (!resolved.startsWith(bucket.path + path.sep) && resolved !== bucket.path) { + throw new SFSError(SFSErrorCode.PATH_TRAVERSAL, 'Path traversal detected', 400) + } + + return resolved + } + + /** + * Validate that a key does not point outside bucket boundaries. + */ + validateKey(bucket: Bucket, key: string): string { + return this.resolveKey(bucket, key) + } + + async ensureBucketInternals(bucketPath: string): Promise { + const sfsDir = path.join(bucketPath, SFS_INTERNAL_DIR) + await fsp.mkdir(path.join(sfsDir, 'staging'), { recursive: true }) + await fsp.mkdir(path.join(sfsDir, 'thumbs'), { recursive: true }) + await fsp.mkdir(path.join(sfsDir, 'runtime'), { recursive: true }) + } + + getStagingDir(bucket: Bucket): string { + return path.join(bucket.path, SFS_INTERNAL_DIR, 'staging') + } + + getThumbsDir(bucket: Bucket): string { + return path.join(bucket.path, SFS_INTERNAL_DIR, 'thumbs') + } + + /** + * Validate startup: check all buckets are accessible and writable, clean orphan staging files. + */ + async validateStartup(): Promise { + const buckets = await this.list() + for (const bucket of buckets) { + try { + await fsp.access(bucket.path, fs.constants.W_OK) + await this.ensureBucketInternals(bucket.path) + await this.cleanOrphanStagingFiles(bucket) + } catch (err) { + console.warn(`[SFS] Warning: bucket '${bucket.name}' at '${bucket.path}' is not accessible:`, err) + } + } + } + + private async cleanOrphanStagingFiles(bucket: Bucket): Promise { + const stagingDir = this.getStagingDir(bucket) + try { + const files = await fsp.readdir(stagingDir) + const cutoff = Date.now() - 60 * 60 * 1000 // older than 1 hour + for (const file of files) { + const filePath = path.join(stagingDir, file) + const stat = await fsp.stat(filePath) + if (stat.mtimeMs < cutoff) { + await fsp.unlink(filePath).catch(() => {}) + } + } + } catch { + // Ignore cleanup errors + } + } +} + diff --git a/extensions/entity-files/packages/simple-file-server/src/storage/storage.service.ts b/extensions/entity-files/packages/simple-file-server/src/storage/storage.service.ts new file mode 100644 index 00000000..aeb367fe --- /dev/null +++ b/extensions/entity-files/packages/simple-file-server/src/storage/storage.service.ts @@ -0,0 +1,298 @@ +import path from 'node:path' +import fsp from 'node:fs/promises' +import fs from 'node:fs' +import type { Stats } from 'node:fs' +import crypto from 'node:crypto' +import { pipeline } from 'node:stream/promises' +import type { Readable } from 'node:stream' +import { fileTypeFromBuffer, fileTypeFromFile } from 'file-type' +import type { Bucket, ListResult, ListEntry } from '../types/index.js' +import { SFSError, SFSErrorCode, notFound } from '../errors/index.js' +import { BucketService } from './bucket.service.js' + +export interface DownloadResult { + stream: fs.ReadStream + size: number + mimeType: string + lastModified: Date + etag: string +} + +export interface UploadOptions { + overwrite?: boolean +} + +export class StorageService { + constructor(private readonly bucketService: BucketService) {} + + /** + * Download a file from a bucket. Returns a readable stream. + */ + async download(bucket: Bucket, key: string): Promise { + const filePath = this.bucketService.resolveKey(bucket, key) + + let stat: Stats + try { + stat = await fsp.stat(filePath) + } catch { + throw notFound(`Object '${key}' in bucket '${bucket.name}'`, SFSErrorCode.OBJECT_NOT_FOUND) + } + + if (stat.isDirectory()) { + throw new SFSError(SFSErrorCode.OBJECT_IS_DIRECTORY, `'${key}' is a directory, not a file`, 400) + } + + const mimeType = await this.detectMimeType(filePath) + const etag = `"${stat.size}-${stat.mtimeMs}"` + + return { + stream: fs.createReadStream(filePath), + size: stat.size, + mimeType, + lastModified: stat.mtime, + etag, + } + } + + /** + * Upload a file to a bucket using staging + atomic move. + */ + async upload(bucket: Bucket, key: string, source: Readable, options: UploadOptions = {}): Promise<{ size: number; etag: string }> { + const finalPath = this.bucketService.resolveKey(bucket, key) + const stagingDir = this.bucketService.getStagingDir(bucket) + const stagingFile = path.join(stagingDir, `${crypto.randomUUID()}.tmp`) + + // Ensure parent directory exists + const parentDir = path.dirname(finalPath) + await fsp.mkdir(parentDir, { recursive: true }) + + let bytesWritten = 0 + + try { + // Stream upload to staging + const writeStream = fs.createWriteStream(stagingFile) + writeStream.on('pipe', () => {}) + + await pipeline(source, writeStream) + + // fsync to ensure data is on disk + const fd = await fsp.open(stagingFile, 'r') + try { + await fd.sync() + } finally { + await fd.close() + } + + const stat = await fsp.stat(stagingFile) + bytesWritten = stat.size + + // Atomic move to final destination + try { + await fsp.rename(stagingFile, finalPath) + } catch (err: unknown) { + // Cross-device rename: fallback to copy + unlink + const error = err as NodeJS.ErrnoException + if (error.code === 'EXDEV') { + await pipeline(fs.createReadStream(stagingFile), fs.createWriteStream(finalPath)) + await fsp.unlink(stagingFile) + } else { + throw err + } + } + + const etag = `"${bytesWritten}-${Date.now()}"` + return { size: bytesWritten, etag } + } catch (err) { + // Cleanup staging file on error + await fsp.unlink(stagingFile).catch(() => {}) + throw err + } + } + + /** + * Delete a file from a bucket. + */ + async delete(bucket: Bucket, key: string): Promise { + const filePath = this.bucketService.resolveKey(bucket, key) + + let stat: Stats + try { + stat = await fsp.stat(filePath) + } catch { + throw notFound(`Object '${key}' in bucket '${bucket.name}'`, SFSErrorCode.OBJECT_NOT_FOUND) + } + + if (stat.isDirectory()) { + throw new SFSError(SFSErrorCode.OBJECT_IS_DIRECTORY, `'${key}' is a directory and cannot be deleted`, 400) + } + + await fsp.unlink(filePath) + } + + /** + * List directory contents. + */ + async listDirectory(bucket: Bucket, keyPrefix: string): Promise { + const dirPath = this.bucketService.resolveKey(bucket, keyPrefix || '/') + + let stat: Stats + try { + stat = await fsp.stat(dirPath) + } catch { + throw notFound(`Directory '${keyPrefix}' in bucket '${bucket.name}'`, SFSErrorCode.OBJECT_NOT_FOUND) + } + + if (!stat.isDirectory()) { + throw new SFSError(SFSErrorCode.OBJECT_IS_DIRECTORY, `'${keyPrefix}' is not a directory`, 404) + } + + const entries = await fsp.readdir(dirPath, { withFileTypes: true }) + const result: ListEntry[] = [] + + for (const entry of entries) { + // Skip internal .sfs directory + if (entry.name === '.sfs') continue + + const entryPath = path.join(dirPath, entry.name) + try { + const entryStat = await fsp.stat(entryPath) + const relativeKey = path.join(keyPrefix || '', entry.name).replace(/\\/g, '/') + + result.push({ + key: entry.isDirectory() ? `${relativeKey}/` : relativeKey, + size: entryStat.size, + lastModified: entryStat.mtime.toISOString(), + isDirectory: entry.isDirectory(), + }) + } catch { + // Skip entries we can't stat + } + } + + return { + bucket: bucket.name, + prefix: keyPrefix || '/', + entries: result, + hasMore: false, + total: result.length, + } + } + + /** + * List bucket contents with pagination. + */ + async listBucket(bucket: Bucket, limit: number = 100, cursor?: string): Promise { + const allEntries = await this.collectAllEntries(bucket, bucket.path, '') + + let startIdx = 0 + if (cursor) { + const cursorIdx = allEntries.findIndex(e => e.key === cursor) + if (cursorIdx >= 0) startIdx = cursorIdx + 1 + } + + const page = allEntries.slice(startIdx, startIdx + limit) + const hasMore = startIdx + limit < allEntries.length + const nextCursor = hasMore ? page[page.length - 1]?.key : undefined + + return { + bucket: bucket.name, + prefix: '/', + entries: page, + cursor: nextCursor, + hasMore, + total: allEntries.length, + } + } + + private async collectAllEntries(bucket: Bucket, dirPath: string, prefix: string): Promise { + const result: ListEntry[] = [] + try { + const entries = await fsp.readdir(dirPath, { withFileTypes: true }) + for (const entry of entries) { + if (entry.name === '.sfs') continue + + const entryPath = path.join(dirPath, entry.name) + const relKey = prefix ? `${prefix}/${entry.name}` : entry.name + + try { + const stat = await fsp.stat(entryPath) + if (entry.isDirectory()) { + const subEntries = await this.collectAllEntries(bucket, entryPath, relKey) + result.push(...subEntries) + } else { + result.push({ + key: relKey, + size: stat.size, + lastModified: stat.mtime.toISOString(), + isDirectory: false, + }) + } + } catch { + // skip + } + } + } catch { + // empty + } + return result + } + + /** + * Detect MIME type using magic bytes, with fallback to extension. + */ + async detectMimeType(filePath: string): Promise { + try { + const result = await fileTypeFromFile(filePath) + if (result) return result.mime + } catch { + // fallback + } + return this.mimeFromExtension(filePath) + } + + async detectMimeTypeFromBuffer(buffer: Buffer): Promise { + try { + const result = await fileTypeFromBuffer(buffer) + if (result) return result.mime + } catch { + // fallback + } + return 'application/octet-stream' + } + + private mimeFromExtension(filePath: string): string { + const ext = path.extname(filePath).toLowerCase() + const mimeMap: Record = { + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.png': 'image/png', + '.gif': 'image/gif', + '.webp': 'image/webp', + '.svg': 'image/svg+xml', + '.pdf': 'application/pdf', + '.json': 'application/json', + '.txt': 'text/plain', + '.html': 'text/html', + '.css': 'text/css', + '.js': 'application/javascript', + '.ts': 'application/typescript', + '.xml': 'application/xml', + '.zip': 'application/zip', + '.tar': 'application/x-tar', + '.gz': 'application/gzip', + '.mp4': 'video/mp4', + '.mp3': 'audio/mpeg', + '.wav': 'audio/wav', + } + return mimeMap[ext] ?? 'application/octet-stream' + } + + isImageMime(mimeType: string): boolean { + return mimeType.startsWith('image/') && mimeType !== 'image/svg+xml' + } +} + + + + + diff --git a/extensions/entity-files/packages/simple-file-server/src/thumbnail/thumbnail.service.ts b/extensions/entity-files/packages/simple-file-server/src/thumbnail/thumbnail.service.ts new file mode 100644 index 00000000..2c87eeeb --- /dev/null +++ b/extensions/entity-files/packages/simple-file-server/src/thumbnail/thumbnail.service.ts @@ -0,0 +1,111 @@ +import path from 'node:path' +import fsp from 'node:fs/promises' +import fs from 'node:fs' +import crypto from 'node:crypto' +import sharp from 'sharp' +import type { Bucket } from '../types/index.js' +import { BucketService } from '../storage/bucket.service.js' +import { StorageService } from '../storage/storage.service.js' + +export interface ThumbnailOptions { + width?: number + height?: number + fit?: 'cover' | 'contain' | 'fill' | 'inside' | 'outside' + format?: 'jpeg' | 'png' | 'webp' +} + +export interface ThumbnailResult { + stream: fs.ReadStream + mimeType: string + size: number +} + +export class ThumbnailService { + constructor( + private readonly bucketService: BucketService, + private readonly storageService: StorageService, + ) {} + + /** + * Generate or retrieve a cached thumbnail. + */ + async getThumbnail(bucket: Bucket, key: string, options: ThumbnailOptions): Promise { + const filePath = this.bucketService.resolveKey(bucket, key) + + // Check if file exists + try { + await fsp.stat(filePath) + } catch { + return null + } + + // Detect mime type + const mimeType = await this.storageService.detectMimeType(filePath) + if (!this.storageService.isImageMime(mimeType)) { + return null + } + + const cacheKey = this.buildCacheKey(key, options) + const thumbsDir = this.bucketService.getThumbsDir(bucket) + const thumbPath = path.join(thumbsDir, cacheKey) + const outputFormat = options.format ?? 'webp' + + // Check if cached thumbnail exists + try { + const stat = await fsp.stat(thumbPath) + return { + stream: fs.createReadStream(thumbPath), + mimeType: `image/${outputFormat}`, + size: stat.size, + } + } catch { + // Generate thumbnail + } + + // Generate thumbnail + await fsp.mkdir(path.dirname(thumbPath), { recursive: true }) + + try { + let pipeline = sharp(filePath) + + if (options.width || options.height) { + pipeline = pipeline.resize({ + width: options.width, + height: options.height, + fit: options.fit ?? 'cover', + withoutEnlargement: true, + }) + } + + if (outputFormat === 'webp') { + pipeline = pipeline.webp({ quality: 85 }) + } else if (outputFormat === 'png') { + pipeline = pipeline.png({ compressionLevel: 6 }) + } else { + pipeline = pipeline.jpeg({ quality: 85 }) + } + + await pipeline.toFile(thumbPath) + + const stat = await fsp.stat(thumbPath) + return { + stream: fs.createReadStream(thumbPath), + mimeType: `image/${outputFormat}`, + size: stat.size, + } + } catch (err) { + // Cleanup failed thumb + await fsp.unlink(thumbPath).catch(() => {}) + return null + } + } + + private buildCacheKey(key: string, options: ThumbnailOptions): string { + const normalized = key.replace(/\//g, '_').replace(/\s/g, '_') + const optStr = `${options.width ?? 0}x${options.height ?? 0}-${options.fit ?? 'cover'}-${options.format ?? 'webp'}` + const hash = crypto.createHash('sha1').update(`${normalized}:${optStr}`).digest('hex').slice(0, 12) + const ext = options.format ?? 'webp' + return `${hash}.${ext}` + } +} + diff --git a/extensions/entity-files/packages/simple-file-server/src/types/index.ts b/extensions/entity-files/packages/simple-file-server/src/types/index.ts new file mode 100644 index 00000000..76331c0f --- /dev/null +++ b/extensions/entity-files/packages/simple-file-server/src/types/index.ts @@ -0,0 +1,52 @@ +// Shared types for simple-file-server + +export type Permission = 'read' | 'write' | 'delete' + +export interface Grant { + bucket: string + prefixes: string[] + permissions: Permission[] +} + +export interface Identity { + identity: string + hashedSecret: string + grants: Grant[] + createdAt: string + updatedAt: string +} + +export interface Bucket { + name: string + path: string + createdAt: string +} + +export interface SFSConfig { + dataDir: string + host: string + port: number + logLevel: string +} + +export interface ListEntry { + key: string + size: number + lastModified: string + isDirectory: boolean +} + +export interface ListResult { + bucket: string + prefix: string + entries: ListEntry[] + cursor?: string + hasMore: boolean + total: number +} + +export interface AuthContext { + identity: string + grants: Grant[] +} + diff --git a/extensions/entity-files/packages/simple-file-server/test/core.test.ts b/extensions/entity-files/packages/simple-file-server/test/core.test.ts new file mode 100644 index 00000000..51377a3f --- /dev/null +++ b/extensions/entity-files/packages/simple-file-server/test/core.test.ts @@ -0,0 +1,203 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import path from 'node:path' +import os from 'node:os' +import fsp from 'node:fs/promises' +import { BucketService } from '../src/storage/bucket.service.js' +import { StorageService } from '../src/storage/storage.service.js' +import { IdentityService } from '../src/auth/identity.service.js' +import { ensureDataDirs } from '../src/config/config.service.js' +import { Readable } from 'node:stream' + +let tempDir: string +let bucketService: BucketService +let storageService: StorageService +let identityService: IdentityService + +beforeEach(async () => { + tempDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'sfs-test-')) + await ensureDataDirs(tempDir) + bucketService = new BucketService(tempDir) + storageService = new StorageService(bucketService) + identityService = new IdentityService(tempDir) +}) + +afterEach(async () => { + await fsp.rm(tempDir, { recursive: true, force: true }) +}) + +// ────────────────────────────────────────────────────────── +// BucketService Tests +// ────────────────────────────────────────────────────────── +describe('BucketService', () => { + it('should create a bucket', async () => { + const bucketPath = path.join(tempDir, 'my-bucket') + const bucket = await bucketService.create('test', bucketPath) + expect(bucket.name).toBe('test') + expect(bucket.path).toBe(bucketPath) + expect(bucket.createdAt).toBeDefined() + }) + + it('should reject non-absolute paths', async () => { + await expect(bucketService.create('bad', 'relative/path')).rejects.toThrow('must be absolute') + }) + + it('should prevent duplicate buckets', async () => { + const bucketPath = path.join(tempDir, 'dup-bucket') + await bucketService.create('dup', bucketPath) + await expect(bucketService.create('dup', bucketPath)).rejects.toThrow('already exists') + }) + + it('should list buckets', async () => { + await bucketService.create('b1', path.join(tempDir, 'b1')) + await bucketService.create('b2', path.join(tempDir, 'b2')) + const list = await bucketService.list() + expect(list.length).toBe(2) + }) + + it('should delete a bucket', async () => { + await bucketService.create('del', path.join(tempDir, 'del')) + await bucketService.delete('del') + const bucket = await bucketService.find('del') + expect(bucket).toBeNull() + }) + + it('should detect path traversal attacks', async () => { + const bucketPath = path.join(tempDir, 'safe-bucket') + const bucket = await bucketService.create('safe', bucketPath) + expect(() => bucketService.resolveKey(bucket, '../../../etc/passwd')).toThrow() + }) + + it('should block access to .sfs directory', async () => { + const bucketPath = path.join(tempDir, 'internal-bucket') + const bucket = await bucketService.create('internal', bucketPath) + expect(() => bucketService.resolveKey(bucket, '.sfs/staging/file.txt')).toThrow() + }) +}) + +// ────────────────────────────────────────────────────────── +// IdentityService Tests +// ────────────────────────────────────────────────────────── +describe('IdentityService', () => { + it('should create an identity', async () => { + const identity = await identityService.create('test-user', 'secret123') + expect(identity.identity).toBe('test-user') + expect(identity.hashedSecret).not.toBe('secret123') + expect(identity.grants).toEqual([]) + }) + + it('should verify correct credentials', async () => { + await identityService.create('verifiable', 'my-secret') + const result = await identityService.verify('verifiable', 'my-secret') + expect(result).not.toBeNull() + expect(result?.identity).toBe('verifiable') + }) + + it('should reject incorrect credentials', async () => { + await identityService.create('user1', 'correct-pass') + const result = await identityService.verify('user1', 'wrong-pass') + expect(result).toBeNull() + }) + + it('should return null for unknown identity', async () => { + const result = await identityService.verify('unknown', 'any') + expect(result).toBeNull() + }) + + it('should grant and check permissions', async () => { + const id = await identityService.create('perm-user', 'pass') + await identityService.grant('perm-user', { + bucket: 'docs', + prefixes: ['/tenant-a/'], + permissions: ['read', 'write'], + }) + + const updated = await identityService.get('perm-user') + const grant = identityService.checkPermission(updated, 'docs', '/tenant-a/file.pdf', 'read') + expect(grant).not.toBeNull() + + const denied = identityService.checkPermission(updated, 'docs', '/tenant-b/file.pdf', 'read') + expect(denied).toBeNull() + }) + + it('should revoke permissions', async () => { + await identityService.create('rev-user', 'pass') + await identityService.grant('rev-user', { + bucket: 'docs', + prefixes: [], + permissions: ['read'], + }) + await identityService.revoke('rev-user', 'docs') + + const id = await identityService.get('rev-user') + expect(id.grants).toHaveLength(0) + }) + + it('should delete an identity', async () => { + await identityService.create('deletable', 'pass') + await identityService.delete('deletable') + const found = await identityService.find('deletable') + expect(found).toBeNull() + }) +}) + +// ────────────────────────────────────────────────────────── +// StorageService Tests +// ────────────────────────────────────────────────────────── +describe('StorageService', () => { + let bucket: Awaited> + + beforeEach(async () => { + bucket = await bucketService.create('store', path.join(tempDir, 'store')) + }) + + it('should upload and download a file', async () => { + const content = 'Hello, SFS!' + const source = Readable.from([content]) + await storageService.upload(bucket, 'test/hello.txt', source) + + const download = await storageService.download(bucket, 'test/hello.txt') + const chunks: Buffer[] = [] + for await (const chunk of download.stream) { + chunks.push(Buffer.from(chunk)) + } + expect(Buffer.concat(chunks).toString('utf-8')).toBe(content) + expect(download.size).toBe(content.length) + }) + + it('should delete a file', async () => { + const source = Readable.from(['delete me']) + await storageService.upload(bucket, 'to-delete.txt', source) + await storageService.delete(bucket, 'to-delete.txt') + await expect(storageService.download(bucket, 'to-delete.txt')).rejects.toThrow() + }) + + it('should throw on missing file download', async () => { + await expect(storageService.download(bucket, 'nonexistent.txt')).rejects.toThrow() + }) + + it('should list directory contents', async () => { + await storageService.upload(bucket, 'dir/a.txt', Readable.from(['a'])) + await storageService.upload(bucket, 'dir/b.txt', Readable.from(['b'])) + + const result = await storageService.listDirectory(bucket, 'dir') + expect(result.entries.length).toBe(2) + const keys = result.entries.map(e => e.key) + expect(keys).toContain('dir/a.txt') + expect(keys).toContain('dir/b.txt') + }) + + it('should list a bucket with pagination', async () => { + for (let i = 0; i < 5; i++) { + await storageService.upload(bucket, `file-${i}.txt`, Readable.from([`content-${i}`])) + } + + const page1 = await storageService.listBucket(bucket, 3) + expect(page1.entries.length).toBe(3) + expect(page1.hasMore).toBe(true) + + const page2 = await storageService.listBucket(bucket, 3, page1.cursor) + expect(page2.entries.length).toBe(2) + expect(page2.hasMore).toBe(false) + }) +}) + diff --git a/extensions/entity-files/packages/simple-file-server/tsconfig.build.json b/extensions/entity-files/packages/simple-file-server/tsconfig.build.json new file mode 100644 index 00000000..6f794190 --- /dev/null +++ b/extensions/entity-files/packages/simple-file-server/tsconfig.build.json @@ -0,0 +1,7 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": false + } +} + diff --git a/extensions/entity-files/packages/simple-file-server/tsconfig.json b/extensions/entity-files/packages/simple-file-server/tsconfig.json new file mode 100644 index 00000000..d4d2580b --- /dev/null +++ b/extensions/entity-files/packages/simple-file-server/tsconfig.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ES2022"], + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "resolveJsonModule": true, + "forceConsistentCasingInFileNames": true, + "noUnusedLocals": false, + "noUnusedParameters": false, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src"], + "exclude": ["node_modules", "dist"] +} + diff --git a/extensions/entity-files/packages/simple-file-server/vitest.config.ts b/extensions/entity-files/packages/simple-file-server/vitest.config.ts new file mode 100644 index 00000000..66e5397d --- /dev/null +++ b/extensions/entity-files/packages/simple-file-server/vitest.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + include: ['test/**/*.test.ts'], + environment: 'node', + testTimeout: 30000, + }, +}) + From ec55a39fafaefd7023ca4435f91bbf438f237b00 Mon Sep 17 00:00:00 2001 From: Mario Serrano Date: Sat, 16 May 2026 00:52:38 -0500 Subject: [PATCH 13/22] chore: update pnpm-workspace.yaml to disable sharp builds --- pnpm-lock.yaml | 1256 +++++++++++++++++++++++++++++++++++++++++-- pnpm-workspace.yaml | 3 +- 2 files changed, 1213 insertions(+), 46 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3e4d79b1..acd9e4b2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -13,7 +13,7 @@ importers: version: 22.19.15 '@vitest/coverage-v8': specifier: ^3.0.0 - version: 3.2.4(vitest@3.2.4(@types/node@22.19.15)) + version: 3.2.4(vitest@3.2.4(@types/node@22.19.15)(tsx@4.22.0)) eslint: specifier: ^9.0.0 version: 9.39.4 @@ -25,13 +25,13 @@ importers: version: 5.9.3 vite: specifier: ^6.2.0 - version: 6.4.1(@types/node@22.19.15) + version: 6.4.1(@types/node@22.19.15)(tsx@4.22.0) vite-plugin-dts: specifier: ^4.5.0 - version: 4.5.4(@types/node@22.19.15)(rollup@4.59.0)(typescript@5.9.3)(vite@6.4.1(@types/node@22.19.15)) + version: 4.5.4(@types/node@22.19.15)(rollup@4.59.0)(typescript@5.9.3)(vite@6.4.1(@types/node@22.19.15)(tsx@4.22.0)) vitest: specifier: ^3.0.0 - version: 3.2.4(@types/node@22.19.15) + version: 3.2.4(@types/node@22.19.15)(tsx@4.22.0) extensions/entity-files/packages/files-sdk: devDependencies: @@ -43,19 +43,62 @@ importers: version: 22.19.15 '@vitest/coverage-v8': specifier: ^3.0.0 - version: 3.2.4(vitest@3.2.4(@types/node@22.19.15)) + version: 3.2.4(vitest@3.2.4(@types/node@22.19.15)(tsx@4.22.0)) typescript: specifier: ^5.7.0 version: 5.9.3 vite: specifier: ^6.2.0 - version: 6.4.1(@types/node@22.19.15) + version: 6.4.1(@types/node@22.19.15)(tsx@4.22.0) vite-plugin-dts: specifier: ^4.5.0 - version: 4.5.4(@types/node@22.19.15)(rollup@4.59.0)(typescript@5.9.3)(vite@6.4.1(@types/node@22.19.15)) + version: 4.5.4(@types/node@22.19.15)(rollup@4.59.0)(typescript@5.9.3)(vite@6.4.1(@types/node@22.19.15)(tsx@4.22.0)) vitest: specifier: ^3.0.0 - version: 3.2.4(@types/node@22.19.15) + version: 3.2.4(@types/node@22.19.15)(tsx@4.22.0) + + extensions/entity-files/packages/simple-file-server: + dependencies: + '@hono/node-server': + specifier: ^1.13.7 + version: 1.19.14(hono@4.12.18) + commander: + specifier: ^13.1.0 + version: 13.1.0 + hono: + specifier: ^4.7.10 + version: 4.12.18 + mime-types: + specifier: ^2.1.35 + version: 2.1.35 + sharp: + specifier: ^0.34.1 + version: 0.34.5 + uuid: + specifier: ^11.1.0 + version: 11.1.1 + devDependencies: + '@types/mime-types': + specifier: ^2.1.4 + version: 2.1.4 + '@types/node': + specifier: ^22.0.0 + version: 22.19.15 + '@types/uuid': + specifier: ^10.0.0 + version: 10.0.0 + tsup: + specifier: ^8.4.0 + version: 8.5.1(@microsoft/api-extractor@7.57.7(@types/node@22.19.15))(postcss@8.5.8)(tsx@4.22.0)(typescript@5.9.3) + tsx: + specifier: ^4.19.0 + version: 4.22.0 + typescript: + specifier: ^5.7.0 + version: 5.9.3 + vitest: + specifier: ^3.0.0 + version: 3.2.4(@types/node@22.19.15)(tsx@4.22.0) extensions/reports/packages/reports-sdk: devDependencies: @@ -67,19 +110,19 @@ importers: version: 22.19.15 '@vitest/coverage-v8': specifier: ^3.0.0 - version: 3.2.4(vitest@3.2.4(@types/node@22.19.15)) + version: 3.2.4(vitest@3.2.4(@types/node@22.19.15)(tsx@4.22.0)) typescript: specifier: ^5.7.0 version: 5.9.3 vite: specifier: ^6.2.0 - version: 6.4.1(@types/node@22.19.15) + version: 6.4.1(@types/node@22.19.15)(tsx@4.22.0) vite-plugin-dts: specifier: ^4.5.0 - version: 4.5.4(@types/node@22.19.15)(rollup@4.59.0)(typescript@5.9.3)(vite@6.4.1(@types/node@22.19.15)) + version: 4.5.4(@types/node@22.19.15)(rollup@4.59.0)(typescript@5.9.3)(vite@6.4.1(@types/node@22.19.15)(tsx@4.22.0)) vitest: specifier: ^3.0.0 - version: 3.2.4(@types/node@22.19.15) + version: 3.2.4(@types/node@22.19.15)(tsx@4.22.0) extensions/saas/packages/saas-sdk: devDependencies: @@ -91,19 +134,19 @@ importers: version: 22.19.15 '@vitest/coverage-v8': specifier: ^3.0.0 - version: 3.2.4(vitest@3.2.4(@types/node@22.19.15)) + version: 3.2.4(vitest@3.2.4(@types/node@22.19.15)(tsx@4.22.0)) typescript: specifier: ^5.7.0 version: 5.9.3 vite: specifier: ^6.2.0 - version: 6.4.1(@types/node@22.19.15) + version: 6.4.1(@types/node@22.19.15)(tsx@4.22.0) vite-plugin-dts: specifier: ^4.5.0 - version: 4.5.4(@types/node@22.19.15)(rollup@4.59.0)(typescript@5.9.3)(vite@6.4.1(@types/node@22.19.15)) + version: 4.5.4(@types/node@22.19.15)(rollup@4.59.0)(typescript@5.9.3)(vite@6.4.1(@types/node@22.19.15)(tsx@4.22.0)) vitest: specifier: ^3.0.0 - version: 3.2.4(@types/node@22.19.15) + version: 3.2.4(@types/node@22.19.15)(tsx@4.22.0) platform/packages/cli: dependencies: @@ -134,19 +177,19 @@ importers: version: 22.19.15 '@vitest/coverage-v8': specifier: ^3.0.0 - version: 3.2.4(vitest@3.2.4(@types/node@22.19.15)) + version: 3.2.4(vitest@3.2.4(@types/node@22.19.15)(tsx@4.22.0)) typescript: specifier: ^5.7.0 version: 5.9.3 vite: specifier: ^6.2.0 - version: 6.4.1(@types/node@22.19.15) + version: 6.4.1(@types/node@22.19.15)(tsx@4.22.0) vite-plugin-dts: specifier: ^4.5.0 - version: 4.5.4(@types/node@22.19.15)(rollup@4.59.0)(typescript@5.9.3)(vite@6.4.1(@types/node@22.19.15)) + version: 4.5.4(@types/node@22.19.15)(rollup@4.59.0)(typescript@5.9.3)(vite@6.4.1(@types/node@22.19.15)(tsx@4.22.0)) vitest: specifier: ^3.0.0 - version: 3.2.4(@types/node@22.19.15) + version: 3.2.4(@types/node@22.19.15)(tsx@4.22.0) platform/packages/ui-core: dependencies: @@ -162,13 +205,13 @@ importers: version: 5.9.3 vite: specifier: ^6.2.0 - version: 6.4.1(@types/node@22.19.15) + version: 6.4.1(@types/node@22.19.15)(tsx@4.22.0) vite-plugin-dts: specifier: ^4.5.0 - version: 4.5.4(@types/node@22.19.15)(rollup@4.59.0)(typescript@5.9.3)(vite@6.4.1(@types/node@22.19.15)) + version: 4.5.4(@types/node@22.19.15)(rollup@4.59.0)(typescript@5.9.3)(vite@6.4.1(@types/node@22.19.15)(tsx@4.22.0)) vitest: specifier: ^3.0.0 - version: 3.2.4(@types/node@22.19.15) + version: 3.2.4(@types/node@22.19.15)(tsx@4.22.0) platform/packages/vue: dependencies: @@ -184,16 +227,16 @@ importers: version: 22.19.15 '@vitejs/plugin-vue': specifier: ^5.0.0 - version: 5.2.4(vite@6.4.1(@types/node@22.19.15))(vue@3.5.30(typescript@5.9.3)) + version: 5.2.4(vite@6.4.1(@types/node@22.19.15)(tsx@4.22.0))(vue@3.5.30(typescript@5.9.3)) typescript: specifier: ^5.7.0 version: 5.9.3 vite: specifier: ^6.2.0 - version: 6.4.1(@types/node@22.19.15) + version: 6.4.1(@types/node@22.19.15)(tsx@4.22.0) vite-plugin-dts: specifier: ^4.5.0 - version: 4.5.4(@types/node@22.19.15)(rollup@4.59.0)(typescript@5.9.3)(vite@6.4.1(@types/node@22.19.15)) + version: 4.5.4(@types/node@22.19.15)(rollup@4.59.0)(typescript@5.9.3)(vite@6.4.1(@types/node@22.19.15)(tsx@4.22.0)) vue: specifier: ^3.4.0 version: 3.5.30(typescript@5.9.3) @@ -228,162 +271,477 @@ packages: resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} engines: {node: '>=18'} + '@emnapi/runtime@1.10.0': + resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==} + '@esbuild/aix-ppc64@0.25.12': resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} engines: {node: '>=18'} cpu: [ppc64] os: [aix] + '@esbuild/aix-ppc64@0.27.7': + resolution: {integrity: sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/aix-ppc64@0.28.0': + resolution: {integrity: sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + '@esbuild/android-arm64@0.25.12': resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==} engines: {node: '>=18'} cpu: [arm64] os: [android] + '@esbuild/android-arm64@0.27.7': + resolution: {integrity: sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm64@0.28.0': + resolution: {integrity: sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + '@esbuild/android-arm@0.25.12': resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==} engines: {node: '>=18'} cpu: [arm] os: [android] + '@esbuild/android-arm@0.27.7': + resolution: {integrity: sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-arm@0.28.0': + resolution: {integrity: sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + '@esbuild/android-x64@0.25.12': resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==} engines: {node: '>=18'} cpu: [x64] os: [android] + '@esbuild/android-x64@0.27.7': + resolution: {integrity: sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/android-x64@0.28.0': + resolution: {integrity: sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + '@esbuild/darwin-arm64@0.25.12': resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==} engines: {node: '>=18'} cpu: [arm64] os: [darwin] + '@esbuild/darwin-arm64@0.27.7': + resolution: {integrity: sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-arm64@0.28.0': + resolution: {integrity: sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + '@esbuild/darwin-x64@0.25.12': resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==} engines: {node: '>=18'} cpu: [x64] os: [darwin] + '@esbuild/darwin-x64@0.27.7': + resolution: {integrity: sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/darwin-x64@0.28.0': + resolution: {integrity: sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + '@esbuild/freebsd-arm64@0.25.12': resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==} engines: {node: '>=18'} cpu: [arm64] os: [freebsd] + '@esbuild/freebsd-arm64@0.27.7': + resolution: {integrity: sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-arm64@0.28.0': + resolution: {integrity: sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + '@esbuild/freebsd-x64@0.25.12': resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==} engines: {node: '>=18'} cpu: [x64] os: [freebsd] + '@esbuild/freebsd-x64@0.27.7': + resolution: {integrity: sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.28.0': + resolution: {integrity: sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + '@esbuild/linux-arm64@0.25.12': resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==} engines: {node: '>=18'} cpu: [arm64] os: [linux] + '@esbuild/linux-arm64@0.27.7': + resolution: {integrity: sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm64@0.28.0': + resolution: {integrity: sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + '@esbuild/linux-arm@0.25.12': resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==} engines: {node: '>=18'} cpu: [arm] os: [linux] + '@esbuild/linux-arm@0.27.7': + resolution: {integrity: sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-arm@0.28.0': + resolution: {integrity: sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + '@esbuild/linux-ia32@0.25.12': resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==} engines: {node: '>=18'} cpu: [ia32] os: [linux] + '@esbuild/linux-ia32@0.27.7': + resolution: {integrity: sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-ia32@0.28.0': + resolution: {integrity: sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + '@esbuild/linux-loong64@0.25.12': resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==} engines: {node: '>=18'} cpu: [loong64] os: [linux] + '@esbuild/linux-loong64@0.27.7': + resolution: {integrity: sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-loong64@0.28.0': + resolution: {integrity: sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + '@esbuild/linux-mips64el@0.25.12': resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==} engines: {node: '>=18'} cpu: [mips64el] os: [linux] + '@esbuild/linux-mips64el@0.27.7': + resolution: {integrity: sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-mips64el@0.28.0': + resolution: {integrity: sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + '@esbuild/linux-ppc64@0.25.12': resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==} engines: {node: '>=18'} cpu: [ppc64] os: [linux] + '@esbuild/linux-ppc64@0.27.7': + resolution: {integrity: sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-ppc64@0.28.0': + resolution: {integrity: sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + '@esbuild/linux-riscv64@0.25.12': resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==} engines: {node: '>=18'} cpu: [riscv64] os: [linux] + '@esbuild/linux-riscv64@0.27.7': + resolution: {integrity: sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-riscv64@0.28.0': + resolution: {integrity: sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + '@esbuild/linux-s390x@0.25.12': resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==} engines: {node: '>=18'} cpu: [s390x] os: [linux] + '@esbuild/linux-s390x@0.27.7': + resolution: {integrity: sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-s390x@0.28.0': + resolution: {integrity: sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + '@esbuild/linux-x64@0.25.12': resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==} engines: {node: '>=18'} cpu: [x64] os: [linux] + '@esbuild/linux-x64@0.27.7': + resolution: {integrity: sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/linux-x64@0.28.0': + resolution: {integrity: sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + '@esbuild/netbsd-arm64@0.25.12': resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==} engines: {node: '>=18'} cpu: [arm64] os: [netbsd] + '@esbuild/netbsd-arm64@0.27.7': + resolution: {integrity: sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-arm64@0.28.0': + resolution: {integrity: sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + '@esbuild/netbsd-x64@0.25.12': resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==} engines: {node: '>=18'} cpu: [x64] os: [netbsd] + '@esbuild/netbsd-x64@0.27.7': + resolution: {integrity: sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.28.0': + resolution: {integrity: sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + '@esbuild/openbsd-arm64@0.25.12': resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==} engines: {node: '>=18'} cpu: [arm64] os: [openbsd] + '@esbuild/openbsd-arm64@0.27.7': + resolution: {integrity: sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-arm64@0.28.0': + resolution: {integrity: sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + '@esbuild/openbsd-x64@0.25.12': resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==} engines: {node: '>=18'} cpu: [x64] os: [openbsd] + '@esbuild/openbsd-x64@0.27.7': + resolution: {integrity: sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.28.0': + resolution: {integrity: sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + '@esbuild/openharmony-arm64@0.25.12': resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==} engines: {node: '>=18'} cpu: [arm64] os: [openharmony] + '@esbuild/openharmony-arm64@0.27.7': + resolution: {integrity: sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/openharmony-arm64@0.28.0': + resolution: {integrity: sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + '@esbuild/sunos-x64@0.25.12': resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==} engines: {node: '>=18'} cpu: [x64] os: [sunos] + '@esbuild/sunos-x64@0.27.7': + resolution: {integrity: sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/sunos-x64@0.28.0': + resolution: {integrity: sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + '@esbuild/win32-arm64@0.25.12': resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==} engines: {node: '>=18'} cpu: [arm64] os: [win32] + '@esbuild/win32-arm64@0.27.7': + resolution: {integrity: sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-arm64@0.28.0': + resolution: {integrity: sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + '@esbuild/win32-ia32@0.25.12': resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==} engines: {node: '>=18'} cpu: [ia32] os: [win32] + '@esbuild/win32-ia32@0.27.7': + resolution: {integrity: sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-ia32@0.28.0': + resolution: {integrity: sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + '@esbuild/win32-x64@0.25.12': resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==} engines: {node: '>=18'} cpu: [x64] os: [win32] + '@esbuild/win32-x64@0.27.7': + resolution: {integrity: sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@esbuild/win32-x64@0.28.0': + resolution: {integrity: sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + '@eslint-community/eslint-utils@4.9.1': resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} @@ -422,6 +780,12 @@ packages: resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@hono/node-server@1.19.14': + resolution: {integrity: sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==} + engines: {node: '>=18.14.1'} + peerDependencies: + hono: ^4 + '@humanfs/core@0.19.1': resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==} engines: {node: '>=18.18.0'} @@ -438,6 +802,159 @@ packages: resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} engines: {node: '>=18.18'} + '@img/colour@1.1.0': + resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} + engines: {node: '>=18'} + + '@img/sharp-darwin-arm64@0.34.5': + resolution: {integrity: sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [darwin] + + '@img/sharp-darwin-x64@0.34.5': + resolution: {integrity: sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-darwin-arm64@1.2.4': + resolution: {integrity: sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==} + cpu: [arm64] + os: [darwin] + + '@img/sharp-libvips-darwin-x64@1.2.4': + resolution: {integrity: sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-linux-arm64@1.2.4': + resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-arm@1.2.4': + resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-ppc64@1.2.4': + resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-riscv64@1.2.4': + resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-s390x@1.2.4': + resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-x64@1.2.4': + resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linuxmusl-arm64@1.2.4': + resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@img/sharp-libvips-linuxmusl-x64@1.2.4': + resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@img/sharp-linux-arm64@0.34.5': + resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-arm@0.34.5': + resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-ppc64@0.34.5': + resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-riscv64@0.34.5': + resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-s390x@0.34.5': + resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-x64@0.34.5': + resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@img/sharp-linuxmusl-arm64@0.34.5': + resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@img/sharp-linuxmusl-x64@0.34.5': + resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@img/sharp-wasm32@0.34.5': + resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [wasm32] + + '@img/sharp-win32-arm64@0.34.5': + resolution: {integrity: sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [win32] + + '@img/sharp-win32-ia32@0.34.5': + resolution: {integrity: sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [ia32] + os: [win32] + + '@img/sharp-win32-x64@0.34.5': + resolution: {integrity: sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [win32] + '@inquirer/ansi@1.0.2': resolution: {integrity: sha512-S8qNSZiYzFd0wAcyG5AXCvUHC5Sr7xpZ9wZ2py9XR88jUz8wooStVx5M6dRzczbBWjic9NP7+rY0Xi7qqK/aMQ==} engines: {node: '>=18'} @@ -809,12 +1326,18 @@ packages: '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + '@types/mime-types@2.1.4': + resolution: {integrity: sha512-lfU4b34HOri+kAY5UheuFMWPDOI+OPceBSHZKp69gEyTL/mmJ4cnU6Y/rlme3UL3GyOn6Y42hyIEw0/q8sWx5w==} + '@types/node@22.19.15': resolution: {integrity: sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==} '@types/node@24.12.2': resolution: {integrity: sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g==} + '@types/uuid@10.0.0': + resolution: {integrity: sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==} + '@vitejs/plugin-vue@5.2.4': resolution: {integrity: sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==} engines: {node: ^18.0.0 || >=20.0.0} @@ -980,6 +1503,9 @@ packages: resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} engines: {node: '>=12'} + any-promise@1.3.0: + resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==} + argparse@1.0.10: resolution: {integrity: sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==} @@ -1010,6 +1536,12 @@ packages: resolution: {integrity: sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==} engines: {node: 18 || 20 || >=22} + bundle-require@5.1.0: + resolution: {integrity: sha512-3WrrOuZiyaaZPWiEt4G3+IffISVC9HYlWueJEBWED4ZH4aIAC2PnkdnuRrR94M+w6yGWn4AglWtJtBI8YqvgoA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + peerDependencies: + esbuild: '>=0.18' + cac@6.7.14: resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} engines: {node: '>=8'} @@ -1037,6 +1569,10 @@ packages: resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} engines: {node: '>= 16'} + chokidar@4.0.3: + resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} + engines: {node: '>= 14.16.0'} + cli-cursor@5.0.0: resolution: {integrity: sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==} engines: {node: '>=18'} @@ -1056,6 +1592,14 @@ packages: color-name@1.1.4: resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + commander@13.1.0: + resolution: {integrity: sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==} + engines: {node: '>=18'} + + commander@4.1.1: + resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} + engines: {node: '>= 6'} + compare-versions@6.1.1: resolution: {integrity: sha512-4hm4VPpIecmlg59CHXnRDnqGplJFrbLG4aFEl5vl6cK1u76ws3LLvX7ikFnTDl5vo39sjWD6AaDPYodJp/NNHg==} @@ -1068,6 +1612,10 @@ packages: confbox@0.2.4: resolution: {integrity: sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==} + consola@3.4.2: + resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==} + engines: {node: ^14.18.0 || >=16.10.0} + cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} @@ -1094,6 +1642,10 @@ packages: deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + diff@8.0.3: resolution: {integrity: sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ==} engines: {node: '>=0.3.1'} @@ -1122,6 +1674,16 @@ packages: engines: {node: '>=18'} hasBin: true + esbuild@0.27.7: + resolution: {integrity: sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==} + engines: {node: '>=18'} + hasBin: true + + esbuild@0.28.0: + resolution: {integrity: sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==} + engines: {node: '>=18'} + hasBin: true + escape-string-regexp@4.0.0: resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} engines: {node: '>=10'} @@ -1218,6 +1780,9 @@ packages: resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} engines: {node: '>=10'} + fix-dts-default-cjs-exports@1.0.1: + resolution: {integrity: sha512-pVIECanWFC61Hzl2+oOCtoJ3F17kglZC/6N94eRWycFgBH35hHx0Li604ZIzhseh97mf2p0cv7vVrOZGoqhlEg==} + flat-cache@4.0.1: resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} engines: {node: '>=16'} @@ -1277,6 +1842,10 @@ packages: resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==} hasBin: true + hono@4.12.18: + resolution: {integrity: sha512-RWzP96k/yv0PQfyXnWjs6zot20TqfpfsNXhOnev8d1InAxubW93L11/oNUc3tQqn2G0bSdAOBpX+2uDFHV7kdQ==} + engines: {node: '>=16.9.0'} + html-escaper@2.0.2: resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} @@ -1365,6 +1934,10 @@ packages: jju@1.4.0: resolution: {integrity: sha512-8wb9Yw966OSxApiCt0K3yNJL8pnNeIv+OEq2YMidz4FKP6nonSRoOXc80iXY4JaN2FC11B9qsNmDsm+ZOfMROA==} + joycon@3.1.1: + resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} + engines: {node: '>=10'} + js-tokens@10.0.0: resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==} @@ -1400,6 +1973,17 @@ packages: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} + lilconfig@3.1.3: + resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==} + engines: {node: '>=14'} + + lines-and-columns@1.2.4: + resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} + + load-tsconfig@0.2.5: + resolution: {integrity: sha512-IXO6OCs9yg8tMKzfPZ1YmheJbZCiEsnBdcB03l0OcfK9prKnJb96siuHCr5Fl37/yo9DnKU+TLpxzTUspw9shg==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + local-pkg@1.1.2: resolution: {integrity: sha512-arhlxbFRmoQHl33a0Zkle/YWlmNwoyt6QNZEIJcqNbdrsix5Lvc4HyyI3EnwxTYlZYc32EbYrQ8SzEZ7dqgg9A==} engines: {node: '>=14'} @@ -1438,6 +2022,14 @@ packages: resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} engines: {node: '>=10'} + mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + + mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + mimic-function@5.0.1: resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==} engines: {node: '>=18'} @@ -1474,6 +2066,9 @@ packages: resolution: {integrity: sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==} engines: {node: ^18.17.0 || >=20.5.0} + mz@2.7.0: + resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} + nanoid@3.3.11: resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} @@ -1486,6 +2081,10 @@ packages: resolution: {integrity: sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA==} engines: {node: '>=18'} + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + onetime@7.0.0: resolution: {integrity: sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==} engines: {node: '>=18'} @@ -1553,12 +2152,34 @@ packages: resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} engines: {node: '>=12'} + pirates@4.0.7: + resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} + engines: {node: '>= 6'} + pkg-types@1.3.1: resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} pkg-types@2.3.0: resolution: {integrity: sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==} + postcss-load-config@6.0.1: + resolution: {integrity: sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==} + engines: {node: '>= 18'} + peerDependencies: + jiti: '>=1.21.0' + postcss: '>=8.0.9' + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + jiti: + optional: true + postcss: + optional: true + tsx: + optional: true + yaml: + optional: true + postcss@8.5.8: resolution: {integrity: sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==} engines: {node: ^10 || ^12 || >=14} @@ -1583,6 +2204,10 @@ packages: quansync@0.2.11: resolution: {integrity: sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==} + readdirp@4.1.2: + resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} + engines: {node: '>= 14.18.0'} + require-from-string@2.0.2: resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} engines: {node: '>=0.10.0'} @@ -1591,6 +2216,10 @@ packages: resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} engines: {node: '>=4'} + resolve-from@5.0.0: + resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} + engines: {node: '>=8'} + resolve@1.22.11: resolution: {integrity: sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==} engines: {node: '>= 0.4'} @@ -1618,6 +2247,10 @@ packages: engines: {node: '>=10'} hasBin: true + sharp@0.34.5: + resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + shebang-command@2.0.0: resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} engines: {node: '>=8'} @@ -1641,6 +2274,10 @@ packages: resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} engines: {node: '>=0.10.0'} + source-map@0.7.6: + resolution: {integrity: sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==} + engines: {node: '>= 12'} + sprintf-js@1.0.3: resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} @@ -1689,6 +2326,11 @@ packages: strip-literal@3.1.0: resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==} + sucrase@3.35.1: + resolution: {integrity: sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==} + engines: {node: '>=16 || 14 >=14.17'} + hasBin: true + supports-color@7.2.0: resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} engines: {node: '>=8'} @@ -1705,6 +2347,13 @@ packages: resolution: {integrity: sha512-u9E6A+ZDYdp7a4WnarkXPZOx8Ilz46+kby6p1yZ8zsGTz9gYa6FIS7lj2oezzNKmtdyyJNNmmXDppga5GB7kSw==} engines: {node: '>=18'} + thenify-all@1.6.0: + resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==} + engines: {node: '>=0.8'} + + thenify@3.3.1: + resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==} + tinybench@2.9.0: resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} @@ -1723,9 +2372,43 @@ packages: resolution: {integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==} engines: {node: '>=14.0.0'} - tinyspy@4.0.4: - resolution: {integrity: sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==} - engines: {node: '>=14.0.0'} + tinyspy@4.0.4: + resolution: {integrity: sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==} + engines: {node: '>=14.0.0'} + + tree-kill@1.2.2: + resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} + hasBin: true + + ts-interface-checker@0.1.13: + resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + tsup@8.5.1: + resolution: {integrity: sha512-xtgkqwdhpKWr3tKPmCkvYmS9xnQK3m3XgxZHwSUjvfTjp7YfXe5tT3GgWi0F2N+ZSMsOeWeZFh7ZZFg5iPhing==} + engines: {node: '>=18'} + hasBin: true + peerDependencies: + '@microsoft/api-extractor': ^7.36.0 + '@swc/core': ^1 + postcss: ^8.4.12 + typescript: '>=4.5.0' + peerDependenciesMeta: + '@microsoft/api-extractor': + optional: true + '@swc/core': + optional: true + postcss: + optional: true + typescript: + optional: true + + tsx@4.22.0: + resolution: {integrity: sha512-8ccZMPD69s1AbKXx0C5ddTNZfNjwV04iIKgjZmKfKxMynEtSYcK0Lh7iQFh53fI5Yu4pb9usgAiqyPmEONaALg==} + engines: {node: '>=18.0.0'} + hasBin: true type-check@0.4.0: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} @@ -1761,6 +2444,10 @@ packages: uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + uuid@11.1.1: + resolution: {integrity: sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ==} + hasBin: true + vite-node@3.2.4: resolution: {integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==} engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} @@ -1923,84 +2610,245 @@ snapshots: '@bcoe/v8-coverage@1.0.2': {} + '@emnapi/runtime@1.10.0': + dependencies: + tslib: 2.8.1 + optional: true + '@esbuild/aix-ppc64@0.25.12': optional: true + '@esbuild/aix-ppc64@0.27.7': + optional: true + + '@esbuild/aix-ppc64@0.28.0': + optional: true + '@esbuild/android-arm64@0.25.12': optional: true + '@esbuild/android-arm64@0.27.7': + optional: true + + '@esbuild/android-arm64@0.28.0': + optional: true + '@esbuild/android-arm@0.25.12': optional: true + '@esbuild/android-arm@0.27.7': + optional: true + + '@esbuild/android-arm@0.28.0': + optional: true + '@esbuild/android-x64@0.25.12': optional: true + '@esbuild/android-x64@0.27.7': + optional: true + + '@esbuild/android-x64@0.28.0': + optional: true + '@esbuild/darwin-arm64@0.25.12': optional: true + '@esbuild/darwin-arm64@0.27.7': + optional: true + + '@esbuild/darwin-arm64@0.28.0': + optional: true + '@esbuild/darwin-x64@0.25.12': optional: true + '@esbuild/darwin-x64@0.27.7': + optional: true + + '@esbuild/darwin-x64@0.28.0': + optional: true + '@esbuild/freebsd-arm64@0.25.12': optional: true + '@esbuild/freebsd-arm64@0.27.7': + optional: true + + '@esbuild/freebsd-arm64@0.28.0': + optional: true + '@esbuild/freebsd-x64@0.25.12': optional: true + '@esbuild/freebsd-x64@0.27.7': + optional: true + + '@esbuild/freebsd-x64@0.28.0': + optional: true + '@esbuild/linux-arm64@0.25.12': optional: true + '@esbuild/linux-arm64@0.27.7': + optional: true + + '@esbuild/linux-arm64@0.28.0': + optional: true + '@esbuild/linux-arm@0.25.12': optional: true + '@esbuild/linux-arm@0.27.7': + optional: true + + '@esbuild/linux-arm@0.28.0': + optional: true + '@esbuild/linux-ia32@0.25.12': optional: true + '@esbuild/linux-ia32@0.27.7': + optional: true + + '@esbuild/linux-ia32@0.28.0': + optional: true + '@esbuild/linux-loong64@0.25.12': optional: true + '@esbuild/linux-loong64@0.27.7': + optional: true + + '@esbuild/linux-loong64@0.28.0': + optional: true + '@esbuild/linux-mips64el@0.25.12': optional: true + '@esbuild/linux-mips64el@0.27.7': + optional: true + + '@esbuild/linux-mips64el@0.28.0': + optional: true + '@esbuild/linux-ppc64@0.25.12': optional: true + '@esbuild/linux-ppc64@0.27.7': + optional: true + + '@esbuild/linux-ppc64@0.28.0': + optional: true + '@esbuild/linux-riscv64@0.25.12': optional: true + '@esbuild/linux-riscv64@0.27.7': + optional: true + + '@esbuild/linux-riscv64@0.28.0': + optional: true + '@esbuild/linux-s390x@0.25.12': optional: true + '@esbuild/linux-s390x@0.27.7': + optional: true + + '@esbuild/linux-s390x@0.28.0': + optional: true + '@esbuild/linux-x64@0.25.12': optional: true + '@esbuild/linux-x64@0.27.7': + optional: true + + '@esbuild/linux-x64@0.28.0': + optional: true + '@esbuild/netbsd-arm64@0.25.12': optional: true + '@esbuild/netbsd-arm64@0.27.7': + optional: true + + '@esbuild/netbsd-arm64@0.28.0': + optional: true + '@esbuild/netbsd-x64@0.25.12': optional: true + '@esbuild/netbsd-x64@0.27.7': + optional: true + + '@esbuild/netbsd-x64@0.28.0': + optional: true + '@esbuild/openbsd-arm64@0.25.12': optional: true + '@esbuild/openbsd-arm64@0.27.7': + optional: true + + '@esbuild/openbsd-arm64@0.28.0': + optional: true + '@esbuild/openbsd-x64@0.25.12': optional: true + '@esbuild/openbsd-x64@0.27.7': + optional: true + + '@esbuild/openbsd-x64@0.28.0': + optional: true + '@esbuild/openharmony-arm64@0.25.12': optional: true + '@esbuild/openharmony-arm64@0.27.7': + optional: true + + '@esbuild/openharmony-arm64@0.28.0': + optional: true + '@esbuild/sunos-x64@0.25.12': optional: true + '@esbuild/sunos-x64@0.27.7': + optional: true + + '@esbuild/sunos-x64@0.28.0': + optional: true + '@esbuild/win32-arm64@0.25.12': optional: true + '@esbuild/win32-arm64@0.27.7': + optional: true + + '@esbuild/win32-arm64@0.28.0': + optional: true + '@esbuild/win32-ia32@0.25.12': optional: true + '@esbuild/win32-ia32@0.27.7': + optional: true + + '@esbuild/win32-ia32@0.28.0': + optional: true + '@esbuild/win32-x64@0.25.12': optional: true + '@esbuild/win32-x64@0.27.7': + optional: true + + '@esbuild/win32-x64@0.28.0': + optional: true + '@eslint-community/eslint-utils@4.9.1(eslint@9.39.4)': dependencies: eslint: 9.39.4 @@ -2047,6 +2895,10 @@ snapshots: '@eslint/core': 0.17.0 levn: 0.4.1 + '@hono/node-server@1.19.14(hono@4.12.18)': + dependencies: + hono: 4.12.18 + '@humanfs/core@0.19.1': {} '@humanfs/node@0.16.7': @@ -2058,6 +2910,102 @@ snapshots: '@humanwhocodes/retry@0.4.3': {} + '@img/colour@1.1.0': {} + + '@img/sharp-darwin-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-darwin-arm64': 1.2.4 + optional: true + + '@img/sharp-darwin-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-darwin-x64': 1.2.4 + optional: true + + '@img/sharp-libvips-darwin-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-darwin-x64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-arm@1.2.4': + optional: true + + '@img/sharp-libvips-linux-ppc64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-riscv64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-s390x@1.2.4': + optional: true + + '@img/sharp-libvips-linux-x64@1.2.4': + optional: true + + '@img/sharp-libvips-linuxmusl-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-linuxmusl-x64@1.2.4': + optional: true + + '@img/sharp-linux-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm64': 1.2.4 + optional: true + + '@img/sharp-linux-arm@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm': 1.2.4 + optional: true + + '@img/sharp-linux-ppc64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-ppc64': 1.2.4 + optional: true + + '@img/sharp-linux-riscv64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-riscv64': 1.2.4 + optional: true + + '@img/sharp-linux-s390x@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-s390x': 1.2.4 + optional: true + + '@img/sharp-linux-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-x64': 1.2.4 + optional: true + + '@img/sharp-linuxmusl-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 + optional: true + + '@img/sharp-linuxmusl-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-x64': 1.2.4 + optional: true + + '@img/sharp-wasm32@0.34.5': + dependencies: + '@emnapi/runtime': 1.10.0 + optional: true + + '@img/sharp-win32-arm64@0.34.5': + optional: true + + '@img/sharp-win32-ia32@0.34.5': + optional: true + + '@img/sharp-win32-x64@0.34.5': + optional: true + '@inquirer/ansi@1.0.2': {} '@inquirer/checkbox@4.3.2(@types/node@24.12.2)': @@ -2386,6 +3334,8 @@ snapshots: '@types/json-schema@7.0.15': {} + '@types/mime-types@2.1.4': {} + '@types/node@22.19.15': dependencies: undici-types: 6.21.0 @@ -2394,12 +3344,14 @@ snapshots: dependencies: undici-types: 7.16.0 - '@vitejs/plugin-vue@5.2.4(vite@6.4.1(@types/node@22.19.15))(vue@3.5.30(typescript@5.9.3))': + '@types/uuid@10.0.0': {} + + '@vitejs/plugin-vue@5.2.4(vite@6.4.1(@types/node@22.19.15)(tsx@4.22.0))(vue@3.5.30(typescript@5.9.3))': dependencies: - vite: 6.4.1(@types/node@22.19.15) + vite: 6.4.1(@types/node@22.19.15)(tsx@4.22.0) vue: 3.5.30(typescript@5.9.3) - '@vitest/coverage-v8@3.2.4(vitest@3.2.4(@types/node@22.19.15))': + '@vitest/coverage-v8@3.2.4(vitest@3.2.4(@types/node@22.19.15)(tsx@4.22.0))': dependencies: '@ampproject/remapping': 2.3.0 '@bcoe/v8-coverage': 1.0.2 @@ -2414,7 +3366,7 @@ snapshots: std-env: 3.10.0 test-exclude: 7.0.2 tinyrainbow: 2.0.0 - vitest: 3.2.4(@types/node@22.19.15) + vitest: 3.2.4(@types/node@22.19.15)(tsx@4.22.0) transitivePeerDependencies: - supports-color @@ -2426,13 +3378,13 @@ snapshots: chai: 5.3.3 tinyrainbow: 2.0.0 - '@vitest/mocker@3.2.4(vite@6.4.1(@types/node@22.19.15))': + '@vitest/mocker@3.2.4(vite@6.4.1(@types/node@22.19.15)(tsx@4.22.0))': dependencies: '@vitest/spy': 3.2.4 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 6.4.1(@types/node@22.19.15) + vite: 6.4.1(@types/node@22.19.15)(tsx@4.22.0) '@vitest/pretty-format@3.2.4': dependencies: @@ -2611,6 +3563,8 @@ snapshots: ansi-styles@6.2.3: {} + any-promise@1.3.0: {} + argparse@1.0.10: dependencies: sprintf-js: 1.0.3 @@ -2642,6 +3596,11 @@ snapshots: dependencies: balanced-match: 4.0.4 + bundle-require@5.1.0(esbuild@0.27.7): + dependencies: + esbuild: 0.27.7 + load-tsconfig: 0.2.5 + cac@6.7.14: {} callsites@3.1.0: {} @@ -2665,6 +3624,10 @@ snapshots: check-error@2.1.3: {} + chokidar@4.0.3: + dependencies: + readdirp: 4.1.2 + cli-cursor@5.0.0: dependencies: restore-cursor: 5.1.0 @@ -2679,6 +3642,10 @@ snapshots: color-name@1.1.4: {} + commander@13.1.0: {} + + commander@4.1.1: {} + compare-versions@6.1.1: {} concat-map@0.0.1: {} @@ -2687,6 +3654,8 @@ snapshots: confbox@0.2.4: {} + consola@3.4.2: {} + cross-spawn@7.0.6: dependencies: path-key: 3.1.1 @@ -2705,6 +3674,8 @@ snapshots: deep-is@0.1.4: {} + detect-libc@2.1.2: {} + diff@8.0.3: {} eastasianwidth@0.2.0: {} @@ -2748,6 +3719,64 @@ snapshots: '@esbuild/win32-ia32': 0.25.12 '@esbuild/win32-x64': 0.25.12 + esbuild@0.27.7: + optionalDependencies: + '@esbuild/aix-ppc64': 0.27.7 + '@esbuild/android-arm': 0.27.7 + '@esbuild/android-arm64': 0.27.7 + '@esbuild/android-x64': 0.27.7 + '@esbuild/darwin-arm64': 0.27.7 + '@esbuild/darwin-x64': 0.27.7 + '@esbuild/freebsd-arm64': 0.27.7 + '@esbuild/freebsd-x64': 0.27.7 + '@esbuild/linux-arm': 0.27.7 + '@esbuild/linux-arm64': 0.27.7 + '@esbuild/linux-ia32': 0.27.7 + '@esbuild/linux-loong64': 0.27.7 + '@esbuild/linux-mips64el': 0.27.7 + '@esbuild/linux-ppc64': 0.27.7 + '@esbuild/linux-riscv64': 0.27.7 + '@esbuild/linux-s390x': 0.27.7 + '@esbuild/linux-x64': 0.27.7 + '@esbuild/netbsd-arm64': 0.27.7 + '@esbuild/netbsd-x64': 0.27.7 + '@esbuild/openbsd-arm64': 0.27.7 + '@esbuild/openbsd-x64': 0.27.7 + '@esbuild/openharmony-arm64': 0.27.7 + '@esbuild/sunos-x64': 0.27.7 + '@esbuild/win32-arm64': 0.27.7 + '@esbuild/win32-ia32': 0.27.7 + '@esbuild/win32-x64': 0.27.7 + + esbuild@0.28.0: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.0 + '@esbuild/android-arm': 0.28.0 + '@esbuild/android-arm64': 0.28.0 + '@esbuild/android-x64': 0.28.0 + '@esbuild/darwin-arm64': 0.28.0 + '@esbuild/darwin-x64': 0.28.0 + '@esbuild/freebsd-arm64': 0.28.0 + '@esbuild/freebsd-x64': 0.28.0 + '@esbuild/linux-arm': 0.28.0 + '@esbuild/linux-arm64': 0.28.0 + '@esbuild/linux-ia32': 0.28.0 + '@esbuild/linux-loong64': 0.28.0 + '@esbuild/linux-mips64el': 0.28.0 + '@esbuild/linux-ppc64': 0.28.0 + '@esbuild/linux-riscv64': 0.28.0 + '@esbuild/linux-s390x': 0.28.0 + '@esbuild/linux-x64': 0.28.0 + '@esbuild/netbsd-arm64': 0.28.0 + '@esbuild/netbsd-x64': 0.28.0 + '@esbuild/openbsd-arm64': 0.28.0 + '@esbuild/openbsd-x64': 0.28.0 + '@esbuild/openharmony-arm64': 0.28.0 + '@esbuild/sunos-x64': 0.28.0 + '@esbuild/win32-arm64': 0.28.0 + '@esbuild/win32-ia32': 0.28.0 + '@esbuild/win32-x64': 0.28.0 + escape-string-regexp@4.0.0: {} eslint-scope@8.4.0: @@ -2866,6 +3895,12 @@ snapshots: locate-path: 6.0.0 path-exists: 4.0.0 + fix-dts-default-cjs-exports@1.0.1: + dependencies: + magic-string: 0.30.21 + mlly: 1.8.1 + rollup: 4.59.0 + flat-cache@4.0.1: dependencies: flatted: 3.4.1 @@ -2921,6 +3956,8 @@ snapshots: he@1.2.0: {} + hono@4.12.18: {} + html-escaper@2.0.2: {} human-signals@8.0.1: {} @@ -2993,6 +4030,8 @@ snapshots: jju@1.4.0: {} + joycon@3.1.1: {} + js-tokens@10.0.0: {} js-tokens@9.0.1: {} @@ -3026,6 +4065,12 @@ snapshots: prelude-ls: 1.2.1 type-check: 0.4.0 + lilconfig@3.1.3: {} + + lines-and-columns@1.2.4: {} + + load-tsconfig@0.2.5: {} + local-pkg@1.1.2: dependencies: mlly: 1.8.1 @@ -3067,6 +4112,12 @@ snapshots: dependencies: semver: 7.7.4 + mime-db@1.52.0: {} + + mime-types@2.1.35: + dependencies: + mime-db: 1.52.0 + mimic-function@5.0.1: {} minimatch@10.2.3: @@ -3100,6 +4151,12 @@ snapshots: mute-stream@2.0.0: {} + mz@2.7.0: + dependencies: + any-promise: 1.3.0 + object-assign: 4.1.1 + thenify-all: 1.6.0 + nanoid@3.3.11: {} natural-compare@1.4.0: {} @@ -3109,6 +4166,8 @@ snapshots: path-key: 4.0.0 unicorn-magic: 0.3.0 + object-assign@4.1.1: {} + onetime@7.0.0: dependencies: mimic-function: 5.0.1 @@ -3173,6 +4232,8 @@ snapshots: picomatch@4.0.3: {} + pirates@4.0.7: {} + pkg-types@1.3.1: dependencies: confbox: 0.1.8 @@ -3185,6 +4246,13 @@ snapshots: exsolve: 1.0.8 pathe: 2.0.3 + postcss-load-config@6.0.1(postcss@8.5.8)(tsx@4.22.0): + dependencies: + lilconfig: 3.1.3 + optionalDependencies: + postcss: 8.5.8 + tsx: 4.22.0 + postcss@8.5.8: dependencies: nanoid: 3.3.11 @@ -3203,10 +4271,14 @@ snapshots: quansync@0.2.11: {} + readdirp@4.1.2: {} + require-from-string@2.0.2: {} resolve-from@4.0.0: {} + resolve-from@5.0.0: {} + resolve@1.22.11: dependencies: is-core-module: 2.16.1 @@ -3257,6 +4329,37 @@ snapshots: semver@7.7.4: {} + sharp@0.34.5: + dependencies: + '@img/colour': 1.1.0 + detect-libc: 2.1.2 + semver: 7.7.4 + optionalDependencies: + '@img/sharp-darwin-arm64': 0.34.5 + '@img/sharp-darwin-x64': 0.34.5 + '@img/sharp-libvips-darwin-arm64': 1.2.4 + '@img/sharp-libvips-darwin-x64': 1.2.4 + '@img/sharp-libvips-linux-arm': 1.2.4 + '@img/sharp-libvips-linux-arm64': 1.2.4 + '@img/sharp-libvips-linux-ppc64': 1.2.4 + '@img/sharp-libvips-linux-riscv64': 1.2.4 + '@img/sharp-libvips-linux-s390x': 1.2.4 + '@img/sharp-libvips-linux-x64': 1.2.4 + '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 + '@img/sharp-libvips-linuxmusl-x64': 1.2.4 + '@img/sharp-linux-arm': 0.34.5 + '@img/sharp-linux-arm64': 0.34.5 + '@img/sharp-linux-ppc64': 0.34.5 + '@img/sharp-linux-riscv64': 0.34.5 + '@img/sharp-linux-s390x': 0.34.5 + '@img/sharp-linux-x64': 0.34.5 + '@img/sharp-linuxmusl-arm64': 0.34.5 + '@img/sharp-linuxmusl-x64': 0.34.5 + '@img/sharp-wasm32': 0.34.5 + '@img/sharp-win32-arm64': 0.34.5 + '@img/sharp-win32-ia32': 0.34.5 + '@img/sharp-win32-x64': 0.34.5 + shebang-command@2.0.0: dependencies: shebang-regex: 3.0.0 @@ -3271,6 +4374,8 @@ snapshots: source-map@0.6.1: {} + source-map@0.7.6: {} + sprintf-js@1.0.3: {} stackback@0.0.2: {} @@ -3315,6 +4420,16 @@ snapshots: dependencies: js-tokens: 9.0.1 + sucrase@3.35.1: + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + commander: 4.1.1 + lines-and-columns: 1.2.4 + mz: 2.7.0 + pirates: 4.0.7 + tinyglobby: 0.2.15 + ts-interface-checker: 0.1.13 + supports-color@7.2.0: dependencies: has-flag: 4.0.0 @@ -3331,6 +4446,14 @@ snapshots: glob: 10.5.0 minimatch: 10.2.4 + thenify-all@1.6.0: + dependencies: + thenify: 3.3.1 + + thenify@3.3.1: + dependencies: + any-promise: 1.3.0 + tinybench@2.9.0: {} tinyexec@0.3.2: {} @@ -3346,6 +4469,48 @@ snapshots: tinyspy@4.0.4: {} + tree-kill@1.2.2: {} + + ts-interface-checker@0.1.13: {} + + tslib@2.8.1: + optional: true + + tsup@8.5.1(@microsoft/api-extractor@7.57.7(@types/node@22.19.15))(postcss@8.5.8)(tsx@4.22.0)(typescript@5.9.3): + dependencies: + bundle-require: 5.1.0(esbuild@0.27.7) + cac: 6.7.14 + chokidar: 4.0.3 + consola: 3.4.2 + debug: 4.4.3 + esbuild: 0.27.7 + fix-dts-default-cjs-exports: 1.0.1 + joycon: 3.1.1 + picocolors: 1.1.1 + postcss-load-config: 6.0.1(postcss@8.5.8)(tsx@4.22.0) + resolve-from: 5.0.0 + rollup: 4.59.0 + source-map: 0.7.6 + sucrase: 3.35.1 + tinyexec: 0.3.2 + tinyglobby: 0.2.15 + tree-kill: 1.2.2 + optionalDependencies: + '@microsoft/api-extractor': 7.57.7(@types/node@22.19.15) + postcss: 8.5.8 + typescript: 5.9.3 + transitivePeerDependencies: + - jiti + - supports-color + - tsx + - yaml + + tsx@4.22.0: + dependencies: + esbuild: 0.28.0 + optionalDependencies: + fsevents: 2.3.3 + type-check@0.4.0: dependencies: prelude-ls: 1.2.1 @@ -3368,13 +4533,15 @@ snapshots: dependencies: punycode: 2.3.1 - vite-node@3.2.4(@types/node@22.19.15): + uuid@11.1.1: {} + + vite-node@3.2.4(@types/node@22.19.15)(tsx@4.22.0): dependencies: cac: 6.7.14 debug: 4.4.3 es-module-lexer: 1.7.0 pathe: 2.0.3 - vite: 6.4.1(@types/node@22.19.15) + vite: 6.4.1(@types/node@22.19.15)(tsx@4.22.0) transitivePeerDependencies: - '@types/node' - jiti @@ -3389,7 +4556,7 @@ snapshots: - tsx - yaml - vite-plugin-dts@4.5.4(@types/node@22.19.15)(rollup@4.59.0)(typescript@5.9.3)(vite@6.4.1(@types/node@22.19.15)): + vite-plugin-dts@4.5.4(@types/node@22.19.15)(rollup@4.59.0)(typescript@5.9.3)(vite@6.4.1(@types/node@22.19.15)(tsx@4.22.0)): dependencies: '@microsoft/api-extractor': 7.57.7(@types/node@22.19.15) '@rollup/pluginutils': 5.3.0(rollup@4.59.0) @@ -3402,13 +4569,13 @@ snapshots: magic-string: 0.30.21 typescript: 5.9.3 optionalDependencies: - vite: 6.4.1(@types/node@22.19.15) + vite: 6.4.1(@types/node@22.19.15)(tsx@4.22.0) transitivePeerDependencies: - '@types/node' - rollup - supports-color - vite@6.4.1(@types/node@22.19.15): + vite@6.4.1(@types/node@22.19.15)(tsx@4.22.0): dependencies: esbuild: 0.25.12 fdir: 6.5.0(picomatch@4.0.3) @@ -3419,12 +4586,13 @@ snapshots: optionalDependencies: '@types/node': 22.19.15 fsevents: 2.3.3 + tsx: 4.22.0 - vitest@3.2.4(@types/node@22.19.15): + vitest@3.2.4(@types/node@22.19.15)(tsx@4.22.0): dependencies: '@types/chai': 5.2.3 '@vitest/expect': 3.2.4 - '@vitest/mocker': 3.2.4(vite@6.4.1(@types/node@22.19.15)) + '@vitest/mocker': 3.2.4(vite@6.4.1(@types/node@22.19.15)(tsx@4.22.0)) '@vitest/pretty-format': 3.2.4 '@vitest/runner': 3.2.4 '@vitest/snapshot': 3.2.4 @@ -3442,8 +4610,8 @@ snapshots: tinyglobby: 0.2.15 tinypool: 1.1.1 tinyrainbow: 2.0.0 - vite: 6.4.1(@types/node@22.19.15) - vite-node: 3.2.4(@types/node@22.19.15) + vite: 6.4.1(@types/node@22.19.15)(tsx@4.22.0) + vite-node: 3.2.4(@types/node@22.19.15)(tsx@4.22.0) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 22.19.15 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 324743d4..be01758a 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -4,7 +4,6 @@ packages: # Extension SDK packages - 'extensions/*/packages/*' - allowBuilds: esbuild: true - + sharp: false From c09161ffaf410bace46dedc0610674b379df5b85 Mon Sep 17 00:00:00 2001 From: Mario Serrano Date: Sat, 16 May 2026 02:49:56 -0500 Subject: [PATCH 14/22] feat: add shell tab-completion support for sfs CLI and enhance file management commands --- .../packages/simple-file-server/README.md | 117 +++++- .../packages/simple-file-server/package.json | 6 +- .../simple-file-server/src/cli/completion.ts | 375 ++++++++++++++++++ .../simple-file-server/src/cli/index.ts | 164 ++++++++ .../src/config/config.service.ts | 2 +- .../src/storage/bucket.service.ts | 6 +- 6 files changed, 663 insertions(+), 7 deletions(-) create mode 100644 extensions/entity-files/packages/simple-file-server/src/cli/completion.ts diff --git a/extensions/entity-files/packages/simple-file-server/README.md b/extensions/entity-files/packages/simple-file-server/README.md index dec36b14..a00f99fb 100644 --- a/extensions/entity-files/packages/simple-file-server/README.md +++ b/extensions/entity-files/packages/simple-file-server/README.md @@ -78,8 +78,113 @@ DELETE /:bucket/:key GET /health ``` +## curl Examples + +All examples assume the server is running at `http://localhost:8080` with identity `erp-prod` and secret `my-secret-password`. + +### Health check + +```bash +curl http://localhost:8080/health +``` + +### Upload a file + +```bash +curl -X PUT http://localhost:8080/documents/tenant-a/invoice.pdf \ + -H "X-SFS-Identity: erp-prod" \ + -H "X-SFS-Secret: my-secret-password" \ + -H "Content-Type: application/octet-stream" \ + --data-binary @/path/to/invoice.pdf +``` + +### Upload a file using HTTP Basic Auth + +```bash +curl -X PUT http://localhost:8080/documents/tenant-a/report.xlsx \ + -u "erp-prod:my-secret-password" \ + -H "Content-Type: application/octet-stream" \ + --data-binary @/path/to/report.xlsx +``` + +### Download a file + +```bash +curl http://localhost:8080/documents/tenant-a/invoice.pdf \ + -H "X-SFS-Identity: erp-prod" \ + -H "X-SFS-Secret: my-secret-password" \ + -o invoice.pdf +``` + +### Download a thumbnail (image resize on the fly) + +```bash +# 300×300 WebP thumbnail with cover fit +curl "http://localhost:8080/documents/tenant-a/photo.jpg?w=300&h=300&fit=cover&format=webp" \ + -H "X-SFS-Identity: erp-prod" \ + -H "X-SFS-Secret: my-secret-password" \ + -o thumbnail.webp +``` + +### List a directory + +```bash +curl http://localhost:8080/documents/tenant-a/ \ + -H "X-SFS-Identity: erp-prod" \ + -H "X-SFS-Secret: my-secret-password" +``` + +### List bucket contents (paginated) + +```bash +# First page +curl "http://localhost:8080/documents?limit=50" \ + -H "X-SFS-Identity: erp-prod" \ + -H "X-SFS-Secret: my-secret-password" + +# Next page using the cursor returned from the previous response +curl "http://localhost:8080/documents?limit=50&cursor=" \ + -H "X-SFS-Identity: erp-prod" \ + -H "X-SFS-Secret: my-secret-password" +``` + +### Delete a file + +```bash +curl -X DELETE http://localhost:8080/documents/tenant-a/invoice.pdf \ + -H "X-SFS-Identity: erp-prod" \ + -H "X-SFS-Secret: my-secret-password" +``` + +### Pretty-print JSON responses + +Pipe responses through `jq` for readable output: + +```bash +curl -s "http://localhost:8080/documents/tenant-a/" \ + -H "X-SFS-Identity: erp-prod" \ + -H "X-SFS-Secret: my-secret-password" | jq +``` + ## CLI Reference +### Tab Completion + +SFS supports tab completion for Bash, Zsh and Fish. + +```bash +# Bash — add to ~/.bashrc +eval "$(sfs completion bash)" + +# Zsh — add to ~/.zshrc (compinit must already be loaded) +eval "$(sfs completion zsh)" + +# Fish — save to completions directory +sfs completion fish > ~/.config/fish/completions/sfs.fish +``` + +Completion is dynamic: bucket names and identity names are resolved at completion time by querying the local SFS runtime. + ```bash # Server sfs serve [--host ] [--port ] [--data-dir ] [--log-level ] @@ -97,14 +202,22 @@ sfs remove identity # Permissions sfs grant --read --write --delete --prefix sfs revoke + +# Files +sfs list files [path] # List files in a bucket or directory +sfs upload # Upload a local file to a bucket +sfs copy # Copy a file between buckets + +# Provisioning (auto-generate identity + secret + grant in one step) +sfs provision [--identity ] [--prefix ] [--read] [--write] [--delete] ``` ## Data Directory Structure -SFS stores all runtime configuration in the working directory: +SFS stores all runtime configuration under a `.sfs/` directory in the working directory: ``` -./sfs/ +.sfs/ ├── config/ # Server configuration ├── identities/ # Identity definitions + hashed secrets + grants ├── buckets/ # Bucket definitions diff --git a/extensions/entity-files/packages/simple-file-server/package.json b/extensions/entity-files/packages/simple-file-server/package.json index 1883282c..8023b647 100644 --- a/extensions/entity-files/packages/simple-file-server/package.json +++ b/extensions/entity-files/packages/simple-file-server/package.json @@ -23,7 +23,8 @@ "author": "Dynamia Soluciones IT SAS", "type": "module", "bin": { - "sfs": "./dist/cli/index.js" + "sfs": "./dist/cli/index.js", + "sfs-test": "./dist/cli/index.js" }, "main": "./dist/index.js", "types": "./dist/index.d.ts", @@ -42,6 +43,7 @@ ], "scripts": { "build": "tsc -p tsconfig.build.json", + "rebuild": "tsc -p tsconfig.build.json && echo '✅ sfs-test actualizado'", "dev": "tsx watch src/cli/index.ts serve", "start": "node dist/cli/index.js serve", "test": "vitest run", @@ -50,7 +52,7 @@ "clean": "rm -rf dist" }, "dependencies": { - "@fastify/sensible": "^5.6.0", + "@fastify/sensible": "^6.0.4", "argon2": "^0.43.0", "commander": "^13.1.0", "fastify": "^5.3.0", diff --git a/extensions/entity-files/packages/simple-file-server/src/cli/completion.ts b/extensions/entity-files/packages/simple-file-server/src/cli/completion.ts new file mode 100644 index 00000000..0a8c6415 --- /dev/null +++ b/extensions/entity-files/packages/simple-file-server/src/cli/completion.ts @@ -0,0 +1,375 @@ +/** + * Shell tab-completion script generators for SFS CLI. + * + * Usage: + * # Bash — add to ~/.bashrc + * eval "$(sfs completion bash)" + * + * # Zsh — add to ~/.zshrc (compinit must already be loaded) + * eval "$(sfs completion zsh)" + * + * # Fish — save to completions dir + * sfs completion fish > ~/.config/fish/completions/sfs.fish + * + * Dynamic completion: bucket and identity names are resolved at completion + * time by calling `sfs list buckets` / `sfs list identities`, so they always + * reflect the current runtime state. + */ + +export function bashCompletion(): string { + return ` +_sfs_buckets() { + sfs list buckets 2>/dev/null | grep '"name"' | awk -F'"' '{print $4}' +} + +_sfs_identities() { + sfs list identities 2>/dev/null | grep '"identity"' | awk -F'"' '{print $4}' +} + +_sfs_complete() { + local cur prev words cword + _init_completion 2>/dev/null || { + COMPREPLY=() + cur="\${COMP_WORDS[COMP_CWORD]}" + prev="\${COMP_WORDS[COMP_CWORD-1]}" + words=("\${COMP_WORDS[@]}") + cword=\$COMP_CWORD + } + + local top_commands="serve create list remove grant revoke upload copy provision completion" + + # Handle --data-dir path completion + if [[ "\$prev" == "--data-dir" ]]; then + COMPREPLY=( $(compgen -d -- "\$cur") ) + return + fi + + case "\${words[1]}" in + serve) + COMPREPLY=( $(compgen -W "--host --port --data-dir --log-level" -- "\$cur") ) + ;; + create) + case "\$cword" in + 2) COMPREPLY=( $(compgen -W "bucket identity" -- "\$cur") ) ;; + esac + ;; + list) + case "\$cword" in + 2) COMPREPLY=( $(compgen -W "buckets identities files" -- "\$cur") ) ;; + 3) + if [[ "\${words[2]}" == "files" ]]; then + COMPREPLY=( $(compgen -W "$(_sfs_buckets)" -- "\$cur") ) + fi + ;; + 4) + # path inside bucket — fall back to filesystem + if [[ "\${words[2]}" == "files" ]]; then + COMPREPLY=( $(compgen -f -- "\$cur") ) + fi + ;; + esac + ;; + remove) + case "\$cword" in + 2) COMPREPLY=( $(compgen -W "bucket identity" -- "\$cur") ) ;; + 3) + if [[ "\${words[2]}" == "bucket" ]]; then + COMPREPLY=( $(compgen -W "$(_sfs_buckets)" -- "\$cur") ) + elif [[ "\${words[2]}" == "identity" ]]; then + COMPREPLY=( $(compgen -W "$(_sfs_identities)" -- "\$cur") ) + fi + ;; + esac + ;; + grant) + case "\$cword" in + 2) COMPREPLY=( $(compgen -W "$(_sfs_identities)" -- "\$cur") ) ;; + 3) COMPREPLY=( $(compgen -W "$(_sfs_buckets)" -- "\$cur") ) ;; + *) COMPREPLY=( $(compgen -W "--read --write --delete --prefix --data-dir" -- "\$cur") ) ;; + esac + ;; + revoke) + case "\$cword" in + 2) COMPREPLY=( $(compgen -W "$(_sfs_identities)" -- "\$cur") ) ;; + 3) COMPREPLY=( $(compgen -W "$(_sfs_buckets)" -- "\$cur") ) ;; + esac + ;; + upload) + case "\$cword" in + 2) COMPREPLY=( $(compgen -W "$(_sfs_buckets)" -- "\$cur") ) ;; + 4) COMPREPLY=( $(compgen -f -- "\$cur") ) ;; # local file + *) COMPREPLY=( $(compgen -W "--overwrite --data-dir" -- "\$cur") ) ;; + esac + ;; + copy) + case "\$cword" in + 2|4) COMPREPLY=( $(compgen -W "$(_sfs_buckets)" -- "\$cur") ) ;; + *) COMPREPLY=( $(compgen -W "--overwrite --data-dir" -- "\$cur") ) ;; + esac + ;; + provision) + case "\$cword" in + 2) COMPREPLY=( $(compgen -W "$(_sfs_buckets)" -- "\$cur") ) ;; + *) COMPREPLY=( $(compgen -W "--identity --prefix --read --write --delete --data-dir" -- "\$cur") ) ;; + esac + ;; + completion) + COMPREPLY=( $(compgen -W "bash zsh fish" -- "\$cur") ) + ;; + *) + COMPREPLY=( $(compgen -W "\$top_commands" -- "\$cur") ) + ;; + esac +} + +complete -F _sfs_complete sfs +complete -F _sfs_complete sfs-test +`.trim() +} + +export function zshCompletion(): string { + return ` +#compdef sfs sfs-test + +_sfs_buckets() { + local -a buckets + buckets=( \${(f)"$(sfs list buckets 2>/dev/null | grep '"name"' | awk -F'"' '{print $4}')"} ) + echo "\${buckets[@]}" +} + +_sfs_identities() { + local -a ids + ids=( \${(f)"$(sfs list identities 2>/dev/null | grep '"identity"' | awk -F'"' '{print $4}')"} ) + echo "\${ids[@]}" +} + +_sfs() { + local state line + typeset -A opt_args + + _arguments -C \\ + '1: :->command' \\ + '*:: :->args' + + case \$state in + command) + local commands + commands=( + 'serve:Start the SFS HTTP server' + 'create:Create a bucket or identity' + 'list:List buckets, identities or files' + 'remove:Remove a bucket or identity' + 'grant:Grant permissions to an identity on a bucket' + 'revoke:Revoke permissions from an identity on a bucket' + 'upload:Upload a local file to a bucket' + 'copy:Copy a file between buckets' + 'provision:Auto-generate identity + secret and grant access to a bucket' + 'completion:Print shell completion script' + ) + _describe 'command' commands + ;; + args) + case \$line[1] in + serve) + _arguments \\ + '--host[Host to listen on]:host' \\ + '--port[Port to listen on]:port' \\ + '--data-dir[Data directory]:dir:_files -/' \\ + '--log-level[Log level]:level:(trace debug info warn error)' + ;; + create) + case \$line[2] in + bucket) + _arguments \\ + '2:bucket name' \\ + '3:absolute path:_files -/' + ;; + identity) + _arguments \\ + '2:identity name' \\ + '3:secret' + ;; + *) + local subcmds; subcmds=('bucket:Create a bucket' 'identity:Create an identity') + _describe 'subcommand' subcmds + ;; + esac + ;; + list) + case \$line[2] in + files) + local buckets; buckets=( \$(_sfs_buckets) ) + _arguments \\ + '2:bucket:(\${buckets[@]})' \\ + '3:path:_files' + ;; + *) + local subcmds; subcmds=('buckets:List all buckets' 'identities:List all identities' 'files:List files in a bucket') + _describe 'subcommand' subcmds + ;; + esac + ;; + remove) + case \$line[2] in + bucket) + local buckets; buckets=( \$(_sfs_buckets) ) + _arguments '3:bucket name:(\${buckets[@]})' + ;; + identity) + local ids; ids=( \$(_sfs_identities) ) + _arguments '3:identity:(\${ids[@]})' + ;; + *) + local subcmds; subcmds=('bucket:Remove a bucket' 'identity:Remove an identity') + _describe 'subcommand' subcmds + ;; + esac + ;; + grant) + local ids; ids=( \$(_sfs_identities) ) + local buckets; buckets=( \$(_sfs_buckets) ) + _arguments \\ + '2:identity:(\${ids[@]})' \\ + '3:bucket:(\${buckets[@]})' \\ + '--read[Grant read]' \\ + '--write[Grant write]' \\ + '--delete[Grant delete]' \\ + '--prefix[Path prefix]:prefix' \\ + '--data-dir[Data directory]:dir:_files -/' + ;; + revoke) + local ids; ids=( \$(_sfs_identities) ) + local buckets; buckets=( \$(_sfs_buckets) ) + _arguments \\ + '2:identity:(\${ids[@]})' \\ + '3:bucket:(\${buckets[@]})' \\ + '--data-dir[Data directory]:dir:_files -/' + ;; + upload) + local buckets; buckets=( \$(_sfs_buckets) ) + _arguments \\ + '2:bucket:(\${buckets[@]})' \\ + '3:key' \\ + '4:local file:_files' \\ + '--overwrite[Overwrite existing file]' \\ + '--data-dir[Data directory]:dir:_files -/' + ;; + copy) + local buckets; buckets=( \$(_sfs_buckets) ) + _arguments \\ + '2:source bucket:(\${buckets[@]})' \\ + '3:source key' \\ + '4:destination bucket:(\${buckets[@]})' \\ + '5:destination key' \\ + '--overwrite[Overwrite existing file]' \\ + '--data-dir[Data directory]:dir:_files -/' + ;; + provision) + local buckets; buckets=( \$(_sfs_buckets) ) + _arguments \\ + '2:bucket:(\${buckets[@]})' \\ + '--identity[Identity name]:name' \\ + '--prefix[Path prefix]:prefix' \\ + '--read[Grant read]' \\ + '--write[Grant write]' \\ + '--delete[Grant delete]' \\ + '--data-dir[Data directory]:dir:_files -/' + ;; + completion) + _arguments '2:shell:(bash zsh fish)' + ;; + esac + ;; + esac +} + +_sfs +`.trim() +} + +export function fishCompletion(): string { + return ` +# Fish completion for sfs / sfs-test +# Install: sfs completion fish > ~/.config/fish/completions/sfs.fish + +function __sfs_buckets + sfs list buckets 2>/dev/null | string match -r '"name": "([^"]+)"' | string replace -r '.*"name": "([^"]+)".*' '$1' +end + +function __sfs_identities + sfs list identities 2>/dev/null | string match -r '"identity": "([^"]+)"' | string replace -r '.*"identity": "([^"]+)".*' '$1' +end + +function __sfs_using_command + set cmd (commandline -opc) + if test (count $cmd) -eq 1 + return 0 + end + return 1 +end + +function __sfs_using_subcommand + set cmd (commandline -opc) + if test (count $cmd) -ge 2; and test $cmd[2] = $argv[1] + return 0 + end + return 1 +end + +# Top-level commands +complete -c sfs -f -n '__sfs_using_command' -a serve -d 'Start the SFS HTTP server' +complete -c sfs -f -n '__sfs_using_command' -a create -d 'Create a bucket or identity' +complete -c sfs -f -n '__sfs_using_command' -a list -d 'List resources' +complete -c sfs -f -n '__sfs_using_command' -a remove -d 'Remove a resource' +complete -c sfs -f -n '__sfs_using_command' -a grant -d 'Grant permissions' +complete -c sfs -f -n '__sfs_using_command' -a revoke -d 'Revoke permissions' +complete -c sfs -f -n '__sfs_using_command' -a upload -d 'Upload a local file' +complete -c sfs -f -n '__sfs_using_command' -a copy -d 'Copy a file between buckets' +complete -c sfs -f -n '__sfs_using_command' -a provision -d 'Auto-generate identity and grant access' +complete -c sfs -f -n '__sfs_using_command' -a completion -d 'Print shell completion script' + +# serve options +complete -c sfs -f -n '__sfs_using_subcommand serve' -l host -d 'Host to listen on' +complete -c sfs -f -n '__sfs_using_subcommand serve' -l port -d 'Port to listen on' +complete -c sfs -f -n '__sfs_using_subcommand serve' -l data-dir -d 'Data directory' +complete -c sfs -f -n '__sfs_using_subcommand serve' -l log-level -a 'trace debug info warn error' -d 'Log level' + +# create subcommands +complete -c sfs -f -n '__sfs_using_subcommand create' -a bucket -d 'Create a bucket' +complete -c sfs -f -n '__sfs_using_subcommand create' -a identity -d 'Create an identity' + +# list subcommands +complete -c sfs -f -n '__sfs_using_subcommand list' -a buckets -d 'List all buckets' +complete -c sfs -f -n '__sfs_using_subcommand list' -a identities -d 'List all identities' +complete -c sfs -f -n '__sfs_using_subcommand list' -a files -d 'List files in a bucket' + +# remove subcommands +complete -c sfs -f -n '__sfs_using_subcommand remove' -a bucket -d 'Remove a bucket' +complete -c sfs -f -n '__sfs_using_subcommand remove' -a identity -d 'Remove an identity' + +# grant / revoke options +complete -c sfs -f -n '__sfs_using_subcommand grant' -l read -d 'Grant read' +complete -c sfs -f -n '__sfs_using_subcommand grant' -l write -d 'Grant write' +complete -c sfs -f -n '__sfs_using_subcommand grant' -l delete -d 'Grant delete' +complete -c sfs -f -n '__sfs_using_subcommand grant' -l prefix -d 'Path prefix' + +# provision options +complete -c sfs -f -n '__sfs_using_subcommand provision' -l identity -d 'Identity name' +complete -c sfs -f -n '__sfs_using_subcommand provision' -l prefix -d 'Path prefix' +complete -c sfs -f -n '__sfs_using_subcommand provision' -l read -d 'Grant read' +complete -c sfs -f -n '__sfs_using_subcommand provision' -l write -d 'Grant write' +complete -c sfs -f -n '__sfs_using_subcommand provision' -l delete -d 'Grant delete' + +# completion shells +complete -c sfs -f -n '__sfs_using_subcommand completion' -a 'bash zsh fish' + +# Dynamic bucket completions +complete -c sfs -f -n '__sfs_using_subcommand provision' -a '(__sfs_buckets)' +complete -c sfs -f -n '__sfs_using_subcommand upload' -a '(__sfs_buckets)' +complete -c sfs -f -n '__sfs_using_subcommand copy' -a '(__sfs_buckets)' + +# Also apply to sfs-test alias +complete -c sfs-test --wraps sfs +`.trim() +} + diff --git a/extensions/entity-files/packages/simple-file-server/src/cli/index.ts b/extensions/entity-files/packages/simple-file-server/src/cli/index.ts index 7c5cb194..0d6c619e 100644 --- a/extensions/entity-files/packages/simple-file-server/src/cli/index.ts +++ b/extensions/entity-files/packages/simple-file-server/src/cli/index.ts @@ -1,8 +1,12 @@ #!/usr/bin/env node import { Command } from 'commander' +import { createReadStream } from 'node:fs' +import { stat } from 'node:fs/promises' +import { randomBytes } from 'node:crypto' import { createRuntime, startServer } from '../runtime/index.js' import { getDataDir } from '../config/config.service.js' +import { bashCompletion, zshCompletion, fishCompletion } from './completion.js' import type { Permission } from '../types/index.js' const program = new Command() @@ -107,6 +111,30 @@ list } }) +list + .command('files [path]') + .description('List files in a bucket or directory path') + .option('--data-dir ', 'Data directory', getDataDir()) + .option('--limit ', 'Max entries to return (bucket root only)', '100') + .option('--cursor ', 'Pagination cursor (bucket root only)') + .action(async (bucketName: string, keyPath: string | undefined, opts) => { + const runtime = await createRuntime({ dataDir: opts.dataDir }) + try { + const bucket = await runtime.bucketService.get(bucketName) + let result + if (keyPath) { + result = await runtime.storageService.listDirectory(bucket, keyPath) + } else { + result = await runtime.storageService.listBucket(bucket, parseInt(opts.limit, 10), opts.cursor) + } + console.log(JSON.stringify({ ok: true, data: result }, null, 2)) + } catch (err: unknown) { + printError(err) + } finally { + await runtime.operationalLogger.close() + } + }) + // ────────────────────────────────────────────────────────── // sfs remove // ────────────────────────────────────────────────────────── @@ -202,6 +230,142 @@ program } }) +// ────────────────────────────────────────────────────────── +// sfs upload +// ────────────────────────────────────────────────────────── +program + .command('upload ') + .description('Upload a local file to a bucket') + .option('--data-dir ', 'Data directory', getDataDir()) + .option('--overwrite', 'Overwrite existing file', false) + .action(async (bucketName: string, key: string, localFile: string, opts) => { + const runtime = await createRuntime({ dataDir: opts.dataDir }) + try { + await stat(localFile) + const bucket = await runtime.bucketService.get(bucketName) + const stream = createReadStream(localFile) + const result = await runtime.storageService.upload(bucket, key, stream, { overwrite: opts.overwrite }) + console.log(JSON.stringify({ ok: true, data: { bucket: bucketName, key, ...result } }, null, 2)) + } catch (err: unknown) { + printError(err) + } finally { + await runtime.operationalLogger.close() + } + }) + +// ────────────────────────────────────────────────────────── +// sfs copy +// ────────────────────────────────────────────────────────── +program + .command('copy ') + .description('Copy a file from one bucket/key to another') + .option('--data-dir ', 'Data directory', getDataDir()) + .option('--overwrite', 'Overwrite existing file', false) + .action(async (srcBucketName: string, srcKey: string, dstBucketName: string, dstKey: string, opts) => { + const runtime = await createRuntime({ dataDir: opts.dataDir }) + try { + const srcBucket = await runtime.bucketService.get(srcBucketName) + const dstBucket = await runtime.bucketService.get(dstBucketName) + const { stream } = await runtime.storageService.download(srcBucket, srcKey) + const result = await runtime.storageService.upload(dstBucket, dstKey, stream, { overwrite: opts.overwrite }) + console.log(JSON.stringify({ ok: true, data: { from: `${srcBucketName}/${srcKey}`, to: `${dstBucketName}/${dstKey}`, ...result } }, null, 2)) + } catch (err: unknown) { + printError(err) + } finally { + await runtime.operationalLogger.close() + } + }) + +// ────────────────────────────────────────────────────────── +// sfs provision +// ────────────────────────────────────────────────────────── +program + .command('provision ') + .description('Auto-generate an identity + secret, grant permissions on a bucket, and print the credentials') + .option('--identity ', 'Identity name (auto-generated if omitted)') + .option('--prefix ', 'Restrict access to a path prefix (can be repeated)', collect, [] as string[]) + .option('--read', 'Grant read permission (default: all permissions)') + .option('--write', 'Grant write permission (default: all permissions)') + .option('--delete', 'Grant delete permission (default: all permissions)') + .option('--data-dir ', 'Data directory', getDataDir()) + .action(async (bucketName: string, opts) => { + const runtime = await createRuntime({ dataDir: opts.dataDir }) + try { + // Resolve identity name + const identity: string = opts.identity ?? `sfs-${randomBytes(4).toString('hex')}` + // Generate a secure random secret (32 hex chars) + const secret: string = randomBytes(24).toString('base64url') + + // Determine permissions (default to all three if none specified) + const permissions: Permission[] = [] + if (opts.read) permissions.push('read') + if (opts.write) permissions.push('write') + if (opts.delete) permissions.push('delete') + if (permissions.length === 0) permissions.push('read', 'write', 'delete') + + // Ensure bucket exists + await runtime.bucketService.get(bucketName) + + // Create identity and grant + await runtime.identityService.create(identity, secret) + await runtime.identityService.grant(identity, { + bucket: bucketName, + prefixes: opts.prefix as string[], + permissions, + }) + + console.log(JSON.stringify({ + ok: true, + data: { + identity, + secret, + bucket: bucketName, + permissions, + prefixes: opts.prefix as string[], + note: 'Save the secret now — it will not be shown again.', + }, + }, null, 2)) + } catch (err: unknown) { + printError(err) + } finally { + await runtime.operationalLogger.close() + } + }) + +// ────────────────────────────────────────────────────────── +// sfs completion +// ────────────────────────────────────────────────────────── +program + .command('completion ') + .description('Print shell tab-completion script (bash | zsh | fish)') + .addHelpText('after', ` +Examples: + # Bash — add to ~/.bashrc + eval "$(sfs completion bash)" + + # Zsh — add to ~/.zshrc (compinit must already be loaded) + eval "$(sfs completion zsh)" + + # Fish — save to completions dir + sfs completion fish > ~/.config/fish/completions/sfs.fish +`) + .action((shell: string) => { + switch (shell.toLowerCase()) { + case 'bash': + console.log(bashCompletion()) + break + case 'zsh': + console.log(zshCompletion()) + break + case 'fish': + console.log(fishCompletion()) + break + default: + console.error(JSON.stringify({ ok: false, error: { code: 'SFS_INVALID_ARGS', message: `Unsupported shell '${shell}'. Use: bash | zsh | fish` } })) + process.exit(1) + } + }) + // ────────────────────────────────────────────────────────── // Helpers // ────────────────────────────────────────────────────────── diff --git a/extensions/entity-files/packages/simple-file-server/src/config/config.service.ts b/extensions/entity-files/packages/simple-file-server/src/config/config.service.ts index d408f30a..999f8941 100644 --- a/extensions/entity-files/packages/simple-file-server/src/config/config.service.ts +++ b/extensions/entity-files/packages/simple-file-server/src/config/config.service.ts @@ -4,7 +4,7 @@ import fsp from 'node:fs/promises' import type { SFSConfig } from '../types/index.js' // Default data directory relative to CWD -const DEFAULT_DATA_DIR = path.join(process.cwd(), 'sfs') +const DEFAULT_DATA_DIR = path.join(process.cwd(), '.sfs') export function getDataDir(): string { return process.env.SFS_DATA_DIR ?? DEFAULT_DATA_DIR diff --git a/extensions/entity-files/packages/simple-file-server/src/storage/bucket.service.ts b/extensions/entity-files/packages/simple-file-server/src/storage/bucket.service.ts index b66f114f..f3415df4 100644 --- a/extensions/entity-files/packages/simple-file-server/src/storage/bucket.service.ts +++ b/extensions/entity-files/packages/simple-file-server/src/storage/bucket.service.ts @@ -106,10 +106,12 @@ export class BucketService { throw new SFSError(SFSErrorCode.PATH_RESERVED, `Access to internal '${SFS_INTERNAL_DIR}' path is forbidden`, 403) } - const resolved = path.resolve(bucket.path, normalized.replace(/^\//, '')) + // Normalize bucket root to remove any trailing separator + const bucketRoot = path.resolve(bucket.path) + const resolved = path.resolve(bucketRoot, normalized.replace(/^\//, '')) // Ensure resolved path is within bucket root - if (!resolved.startsWith(bucket.path + path.sep) && resolved !== bucket.path) { + if (!resolved.startsWith(bucketRoot + path.sep) && resolved !== bucketRoot) { throw new SFSError(SFSErrorCode.PATH_TRAVERSAL, 'Path traversal detected', 400) } From 7159c6513ae1143d8cbc820e034c030a9e22ccd9 Mon Sep 17 00:00:00 2001 From: Mario Serrano Date: Sat, 16 May 2026 02:50:05 -0500 Subject: [PATCH 15/22] chore: downgrade springboot version from 4.0.6 to 4.0.5 in pom.xml --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index 2b1bcf62..e9729938 100644 --- a/pom.xml +++ b/pom.xml @@ -57,7 +57,7 @@ ${project.baseUri} - 4.0.6 + 4.0.5 2.2.46 1 From e1788711f7af9e02a6fe736e1e89675cf1359438 Mon Sep 17 00:00:00 2001 From: Mario Serrano Date: Sat, 16 May 2026 03:16:54 -0500 Subject: [PATCH 16/22] feat: implement RemoteEntityFileStorage for handling files in a remote simple-file-server instance --- .../remote/RemoteEntityFileStorage.java | 332 ++++++++++++++++++ .../entityfiles/s3/S3EntityFileStorage.java | 31 +- .../META-INF/descriptors/EntityFileConfig.yml | 26 ++ 3 files changed, 363 insertions(+), 26 deletions(-) create mode 100644 extensions/entity-files/sources/core/src/main/java/tools/dynamia/modules/entityfile/remote/RemoteEntityFileStorage.java diff --git a/extensions/entity-files/sources/core/src/main/java/tools/dynamia/modules/entityfile/remote/RemoteEntityFileStorage.java b/extensions/entity-files/sources/core/src/main/java/tools/dynamia/modules/entityfile/remote/RemoteEntityFileStorage.java new file mode 100644 index 00000000..cacb2d8a --- /dev/null +++ b/extensions/entity-files/sources/core/src/main/java/tools/dynamia/modules/entityfile/remote/RemoteEntityFileStorage.java @@ -0,0 +1,332 @@ +/* + * Copyright (C) 2023 Dynamia Soluciones IT S.A.S - NIT 900302344-1 + * Colombia / South America + * + * 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. + */ + +package tools.dynamia.modules.entityfile.remote; + +import org.springframework.core.env.Environment; +import org.springframework.core.io.Resource; +import org.springframework.core.io.UrlResource; +import org.springframework.web.client.RestClient; +import tools.dynamia.commons.logger.LoggingService; +import tools.dynamia.commons.logger.SLF4JLoggingService; +import tools.dynamia.domain.query.Parameters; +import tools.dynamia.domain.services.CrudService; +import tools.dynamia.integration.sterotypes.Service; +import tools.dynamia.modules.entityfile.EntityFileException; +import tools.dynamia.modules.entityfile.EntityFileStorage; +import tools.dynamia.modules.entityfile.StoredEntityFile; +import tools.dynamia.modules.entityfile.UploadedFileInfo; +import tools.dynamia.modules.entityfile.domain.EntityFile; +import tools.dynamia.modules.entityfile.domain.enums.EntityFileState; + +import java.io.File; +import java.io.Serial; + +/** + * {@link EntityFileStorage} implementation that stores files in a remote + * simple-file-server (SFS) instance. + * + *

Configuration parameters (read from Spring {@link Environment} first, then from application + * {@link Parameters}): + *

    + *
  • {@code SFS_URL} – base URL of the SFS server, e.g. {@code http://files.example.com:8080}
  • + *
  • {@code SFS_BUCKET} – bucket name
  • + *
  • {@code SFS_IDENTITY} – SFS identity (used in {@code X-SFS-Identity} header)
  • + *
  • {@code SFS_SECRET} – SFS secret (used in {@code X-SFS-Secret} header)
  • + *
+ * + *

Files are served to the browser through the local proxy endpoint + * {@link #PROXY_PATH} so that SFS credentials are never exposed to the client.

+ * + *

The remote file key follows the same convention as {@code LocalEntityFileStorage}: + * {@code Account{accountId}/{subfolder}/{storedFileName|uuid}}

+ * + * @author Dynamia Soluciones IT + */ +@Service +public class RemoteEntityFileStorage implements EntityFileStorage { + + private final LoggingService logger = new SLF4JLoggingService(RemoteEntityFileStorage.class, "SFS: "); + + public static final String ID = "RemoteSimpleFileStorage"; + + /** + * Base path of the local proxy handler that serves SFS files to the browser. + */ + public static final String PROXY_PATH = "/storage/remote/"; + + // ── Parameter names ────────────────────────────────────────────────────── + public static final String SFS_URL = "SFS_URL"; + public static final String SFS_BUCKET = "SFS_BUCKET"; + public static final String SFS_IDENTITY = "SFS_IDENTITY"; + public static final String SFS_SECRET = "SFS_SECRET"; + + // ── Header names ───────────────────────────────────────────────────────── + static final String HEADER_IDENTITY = "X-SFS-Identity"; + static final String HEADER_SECRET = "X-SFS-Secret"; + + private final Parameters appParams; + private final CrudService crudService; + private final Environment environment; + + /** + * Lazily-built RestClient; rebuilt whenever {@link #reloadParams()} is invoked. + */ + private volatile RestClient restClient; + + public RemoteEntityFileStorage(Parameters appParams, CrudService crudService, Environment environment) { + this.appParams = appParams; + this.crudService = crudService; + this.environment = environment; + } + + // ── EntityFileStorage ──────────────────────────────────────────────────── + + @Override + public String getId() { + return ID; + } + + @Override + public String getName() { + return "Remote Simple File Storage"; + } + + @Override + public void upload(EntityFile entityFile, UploadedFileInfo fileInfo) { + String key = buildKey(entityFile); + String bucket = getBucket(); + + logger.info("Uploading " + entityFile.getName() + " → sfs://" + bucket + "/" + key); + + try { + byte[] body = fileInfo.getInputStream().readAllBytes(); + + client().put() + .uri("/{bucket}/{key}", bucket, key) + .header(HEADER_IDENTITY, getIdentity()) + .header(HEADER_SECRET, getSecret()) + .header("Content-Type", contentType(fileInfo)) + .body(body) + .retrieve() + .toBodilessEntity(); + + entityFile.setSize((long) body.length); + logger.info("Uploaded successfully: " + key); + } catch (Exception e) { + logger.error("Error uploading entity file to SFS: " + key, e); + throw new EntityFileException("Error uploading entity file to SFS: " + key, e); + } + } + + @Override + public StoredEntityFile download(EntityFile entityFile) { + // Return a URL that goes through the local proxy handler — SFS credentials + // are never sent to the browser. + String url = buildRemoteUrl(entityFile); + return new RemoteStoredEntityFile(entityFile, url); + } + + @Override + public void delete(EntityFile entityFile) { + String key = buildKey(entityFile); + String bucket = getBucket(); + + logger.info("Deleting from SFS: " + key); + + try { + client().delete() + .uri("/{bucket}/{key}", bucket, key) + .header(HEADER_IDENTITY, getIdentity()) + .header(HEADER_SECRET, getSecret()) + .retrieve() + .toBodilessEntity(); + + entityFile.setState(EntityFileState.DELETED); + crudService.update(entityFile); + } catch (Exception e) { + throw new EntityFileException("Error deleting entity file from SFS: " + key, e); + } + } + + @Override + public void reloadParams() { + // Force recreation of the RestClient on next call so the new base URL is picked up. + restClient = null; + } + + // ── Internal helpers ───────────────────────────────────────────────────── + + /** + * Lazily builds (and caches) a {@link RestClient} pointed at {@link #getSfsUrl()}. + */ + RestClient client() { + if (restClient == null) { + synchronized (this) { + if (restClient == null) { + String base = getSfsUrl(); + logger.info("Building RestClient for SFS base URL: " + base); + restClient = RestClient.builder() + .baseUrl(base) + .build(); + } + } + } + return restClient; + } + + public static String getFileName(EntityFile entityFile) { + String subfolder = ""; + if (entityFile.getSubfolder() != null) { + subfolder = entityFile.getSubfolder() + "/"; + } + + var name = entityFile.getName().toLowerCase().trim() + .replace(" ", "_") + .replace("-", "_") + .replace("\u00F1", "n") + .replace("\u00E1", "a") + .replace("\u00E9", "e") + .replace("\u00ED", "i") + .replace("\u00F3", "o") + .replace("\u00FA", "u"); + String storedFileName = entityFile.getUuid() + "_" + name; + if (entityFile.getStoredFileName() != null && !entityFile.getStoredFileName().isEmpty()) { + storedFileName = entityFile.getStoredFileName(); + } + + return subfolder + storedFileName; + } + + public static String getAccountFolderName(Long accountId) { + return "account" + accountId + "/"; + } + + /** + * Builds the SFS file key using the same folder structure as {@code LocalEntityFileStorage}: + * {@code Account{accountId}/{subfolder}/{storedFileName|uuid}} + */ + String buildKey(EntityFile entityFile) { + String folder = getAccountFolderName(entityFile.getAccountId()); + String fileName = getFileName(entityFile); + return folder + fileName; + } + + /** + * Builds the URL of the local proxy handler that will serve the file to the browser. + * Format: {@code /storage/remote/{uuid}/{encodedFileName}} + */ + String buildRemoteUrl(EntityFile entityFile) { + + return getSfsUrl() + "/" + getBucket() + "/" + buildKey(entityFile); + } + + // ── Parameter accessors ────────────────────────────────────────────────── + + String getSfsUrl() { + return param(SFS_URL, "http://localhost:8081"); + } + + String getBucket() { + return param(SFS_BUCKET, "files"); + } + + String getIdentity() { + return param(SFS_IDENTITY, ""); + } + + String getSecret() { + return param(SFS_SECRET, ""); + } + + /** + * Reads a parameter from the Spring {@link Environment} first; falls back to + * application {@link Parameters} (same strategy as {@code LocalEntityFileStorage}). + */ + private String param(String name, String defaultValue) { + String value = environment.getProperty(name); + if (value != null && !value.isBlank()) { + return value; + } + try { + return appParams.getValue(name, defaultValue); + } catch (Exception e) { + return defaultValue; + } + } + + private static String contentType(UploadedFileInfo fileInfo) { + return fileInfo.getContentType() != null ? fileInfo.getContentType() : "application/octet-stream"; + } + + // ── Inner class: RemoteStoredEntityFile ────────────────────────────────── + + /** + * {@link StoredEntityFile} for files hosted on a remote SFS instance. + * There is no local {@link File} — {@link #getRealFile()} returns {@code null}. + * Thumbnails are generated server-side by SFS and requested through the proxy. + */ + public static class RemoteStoredEntityFile extends StoredEntityFile { + + @Serial + private static final long serialVersionUID = 1L; + + public RemoteStoredEntityFile(EntityFile entityFile, String removeUrl) { + super(entityFile, removeUrl, null); + } + + @Override + public String getThumbnailUrl(int width, int height) { + return getUrl() + "?w=" + width + "&h=" + height; + } + + /** + * No local thumbnail file available; SFS handles resizing transparently. + */ + @Override + public File getThumbnailFile(int width, int height) { + return null; + } + + @Override + public Resource toResource() { + try { + return new UrlResource(getUrl()); + } catch (Exception e) { + return null; + } + } + + @Override + public Resource toThumbnailResource(int width, int height) { + try { + return new UrlResource(getThumbnailUrl(width, height)); + } catch (Exception e) { + return null; + } + } + } + + @Override + public String toString() { + return getName(); + } +} + + + + diff --git a/extensions/entity-files/sources/s3/src/main/java/tools/dynamia/modules/entityfiles/s3/S3EntityFileStorage.java b/extensions/entity-files/sources/s3/src/main/java/tools/dynamia/modules/entityfiles/s3/S3EntityFileStorage.java index 01caca64..ff626ecc 100644 --- a/extensions/entity-files/sources/s3/src/main/java/tools/dynamia/modules/entityfiles/s3/S3EntityFileStorage.java +++ b/extensions/entity-files/sources/s3/src/main/java/tools/dynamia/modules/entityfiles/s3/S3EntityFileStorage.java @@ -45,6 +45,7 @@ import tools.dynamia.modules.entityfile.UploadedFileInfo; import tools.dynamia.modules.entityfile.domain.EntityFile; import tools.dynamia.modules.entityfile.enums.EntityFileType; +import tools.dynamia.modules.entityfile.remote.RemoteEntityFileStorage; import java.io.File; import java.net.URL; @@ -57,6 +58,9 @@ import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; +import static tools.dynamia.modules.entityfile.remote.RemoteEntityFileStorage.getAccountFolderName; +import static tools.dynamia.modules.entityfile.remote.RemoteEntityFileStorage.getFileName; + /** * {@link EntityFileStorage} implementation that store files in Amazon S3 service. * The following environment variables are required AWS_ACCESS_KEY_ID, AWS_SECRET_KEY, AWS_S3_ENDPOINT, AWS_S3_BUCKET. The @@ -220,29 +224,6 @@ private String generateStaticURL(String bucketName, String fileName) { } - private String getFileName(EntityFile entityFile) { - String subfolder = ""; - if (entityFile.getSubfolder() != null) { - subfolder = entityFile.getSubfolder() + "/"; - } - - var name = entityFile.getName().toLowerCase().trim() - .replace(" ", "_") - .replace("-", "_") - .replace("\u00F1", "n") - .replace("\u00E1", "a") - .replace("\u00E9", "e") - .replace("\u00ED", "i") - .replace("\u00F3", "o") - .replace("\u00FA", "u"); - String storedFileName = entityFile.getUuid() + "_" + name; - if (entityFile.getStoredFileName() != null && !entityFile.getStoredFileName().isEmpty()) { - storedFileName = entityFile.getStoredFileName(); - } - - return subfolder + storedFileName; - } - /** * Get or build a S3 async client using static credentials * @@ -264,9 +245,7 @@ protected S3AsyncClient getClient() { } - protected String getAccountFolderName(Long accountId) { - return "account" + accountId + "/"; - } + /** * Generate thumbnail url diff --git a/extensions/entity-files/sources/ui/src/main/resources/META-INF/descriptors/EntityFileConfig.yml b/extensions/entity-files/sources/ui/src/main/resources/META-INF/descriptors/EntityFileConfig.yml index 493f18ae..23720908 100644 --- a/extensions/entity-files/sources/ui/src/main/resources/META-INF/descriptors/EntityFileConfig.yml +++ b/extensions/entity-files/sources/ui/src/main/resources/META-INF/descriptors/EntityFileConfig.yml @@ -64,10 +64,36 @@ fields: cacheable: true span: 2 + sfsUrl: + label: Server URL + description: Base URL of the simple-file-server instance, e.g. http://files.example.com:8080 + params: + parameterName: SFS_URL + cacheable: true + span: 2 + sfsBucket: + label: Bucket + params: + parameterName: SFS_BUCKET + cacheable: true + sfsIdentity: + label: Identity + params: + parameterName: SFS_IDENTITY + cacheable: true + sfsSecret: + label: Secret + params: + parameterName: SFS_SECRET + cacheable: true + groups: s3Config: label: S3 Compatible Storage fields: [ s3BucketName,s3Region,s3User,s3Secret,s3Endpoint ] + sfsConfig: + label: Remote Simple File Storage + fields: [ sfsUrl,sfsBucket,sfsIdentity,sfsSecret ] layout: columns: 4 \ No newline at end of file From d6c56fbd7746c00b8f22a78d49a4343fa729a1ee Mon Sep 17 00:00:00 2001 From: Mario Serrano Date: Sat, 16 May 2026 03:35:34 -0500 Subject: [PATCH 17/22] feat: enhance auth plugin with fastify-plugin and improve file upload handling --- .../packages/simple-file-server/package.json | 4 ++- .../src/http/plugins/auth.plugin.ts | 16 +++++++-- .../simple-file-server/src/http/server.ts | 5 +++ .../remote/RemoteEntityFileStorage.java | 35 ++++++++++++++----- 4 files changed, 49 insertions(+), 11 deletions(-) diff --git a/extensions/entity-files/packages/simple-file-server/package.json b/extensions/entity-files/packages/simple-file-server/package.json index 8023b647..3c8739b2 100644 --- a/extensions/entity-files/packages/simple-file-server/package.json +++ b/extensions/entity-files/packages/simple-file-server/package.json @@ -56,6 +56,9 @@ "argon2": "^0.43.0", "commander": "^13.1.0", "fastify": "^5.3.0", + "fastify-plugin": "^5.0.0", + "file-type": "^21.0.0", + "fastify-plugin": "^5.1.0", "file-type": "^21.0.0", "pino": "^9.7.0", "pino-pretty": "^13.0.0", @@ -73,4 +76,3 @@ "node": ">=22.0.0" } } - diff --git a/extensions/entity-files/packages/simple-file-server/src/http/plugins/auth.plugin.ts b/extensions/entity-files/packages/simple-file-server/src/http/plugins/auth.plugin.ts index 08f30bbd..cb98f9b8 100644 --- a/extensions/entity-files/packages/simple-file-server/src/http/plugins/auth.plugin.ts +++ b/extensions/entity-files/packages/simple-file-server/src/http/plugins/auth.plugin.ts @@ -1,4 +1,5 @@ import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify' +import fp from 'fastify-plugin' import { IdentityService } from '../../auth/identity.service.js' import type { Identity } from '../../types/index.js' import { SFSError, SFSErrorCode, unauthorized } from '../../errors/index.js' @@ -15,7 +16,18 @@ interface AuthPluginOptions { logger: OperationalLogger } -export async function authPlugin(fastify: FastifyInstance, options: AuthPluginOptions): Promise { +/** + * Authentication plugin wrapped with fastify-plugin so that: + * - The `authIdentity` request decoration is visible in all sibling/child plugins. + * - The `onRequest` hook applies to ALL routes registered on the root instance. + * + * Without fastify-plugin, Fastify's encapsulation would isolate the decoration + * and hook to routes within this plugin's scope only. + */ +export const authPlugin = fp(async function authPlugin( + fastify: FastifyInstance, + options: AuthPluginOptions, +): Promise { const { identityService, logger } = options fastify.decorateRequest('authIdentity', { getter: () => null }) @@ -42,7 +54,7 @@ export async function authPlugin(fastify: FastifyInstance, options: AuthPluginOp request.authIdentity = id }) -} +}, { name: 'sfs-auth' }) function extractIdentity(request: FastifyRequest): string | undefined { const headerIdentity = request.headers['x-sfs-identity'] diff --git a/extensions/entity-files/packages/simple-file-server/src/http/server.ts b/extensions/entity-files/packages/simple-file-server/src/http/server.ts index 29c9b15f..2199b78c 100644 --- a/extensions/entity-files/packages/simple-file-server/src/http/server.ts +++ b/extensions/entity-files/packages/simple-file-server/src/http/server.ts @@ -67,6 +67,11 @@ export async function createServer(options: ServerOptions): Promise done(null)) + // Auth plugin (applies to all routes below) await fastify.register(authPlugin, { identityService, logger: operationalLogger }) diff --git a/extensions/entity-files/sources/core/src/main/java/tools/dynamia/modules/entityfile/remote/RemoteEntityFileStorage.java b/extensions/entity-files/sources/core/src/main/java/tools/dynamia/modules/entityfile/remote/RemoteEntityFileStorage.java index cacb2d8a..5fea52fb 100644 --- a/extensions/entity-files/sources/core/src/main/java/tools/dynamia/modules/entityfile/remote/RemoteEntityFileStorage.java +++ b/extensions/entity-files/sources/core/src/main/java/tools/dynamia/modules/entityfile/remote/RemoteEntityFileStorage.java @@ -19,6 +19,8 @@ import org.springframework.core.env.Environment; import org.springframework.core.io.Resource; +import org.springframework.core.env.Environment; +import org.springframework.core.io.InputStreamResource; import org.springframework.core.io.UrlResource; import org.springframework.web.client.RestClient; import tools.dynamia.commons.logger.LoggingService; @@ -114,19 +116,36 @@ public void upload(EntityFile entityFile, UploadedFileInfo fileInfo) { logger.info("Uploading " + entityFile.getName() + " → sfs://" + bucket + "/" + key); try { - byte[] body = fileInfo.getInputStream().readAllBytes(); + // Stream the InputStream directly — InputStreamResource avoids loading + // the entire file into memory (ResourceHttpMessageConverter streams it). + var resource = new InputStreamResource(fileInfo.getInputStream()) { + @Override + public long contentLength() { + // Return the known length so the server gets a correct Content-Length + // header; -1 means unknown (chunked transfer will be used instead). + return fileInfo.getLength() > 0 ? fileInfo.getLength() : -1; + } - client().put() - .uri("/{bucket}/{key}", bucket, key) + @Override + public String getFilename() { + return fileInfo.getFullName(); + } + }; + + var response = client().put() + .uri(uriBuilder -> uriBuilder.replacePath("/" + bucket + "/" + key).build()) .header(HEADER_IDENTITY, getIdentity()) .header(HEADER_SECRET, getSecret()) - .header("Content-Type", contentType(fileInfo)) - .body(body) + .contentType(org.springframework.http.MediaType.parseMediaType(contentType(fileInfo))) + .body(resource) .retrieve() .toBodilessEntity(); - entityFile.setSize((long) body.length); - logger.info("Uploaded successfully: " + key); + // Use the length from fileInfo; if 0 fall back to what was set by the caller. + if (fileInfo.getLength() > 0) { + entityFile.setSize(fileInfo.getLength()); + } + logger.info("Uploaded successfully [" + response.getStatusCode() + "]: " + key); } catch (Exception e) { logger.error("Error uploading entity file to SFS: " + key, e); throw new EntityFileException("Error uploading entity file to SFS: " + key, e); @@ -150,7 +169,7 @@ public void delete(EntityFile entityFile) { try { client().delete() - .uri("/{bucket}/{key}", bucket, key) + .uri(uriBuilder -> uriBuilder.replacePath("/" + bucket + "/" + key).build()) .header(HEADER_IDENTITY, getIdentity()) .header(HEADER_SECRET, getSecret()) .retrieve() From ce36263b73f4908c10301f314f6cf32f72bf1a60 Mon Sep 17 00:00:00 2001 From: Mario Serrano Date: Sat, 16 May 2026 03:38:14 -0500 Subject: [PATCH 18/22] fix: remove content type setting in RemoteEntityFileStorage for file upload --- .../modules/entityfile/remote/RemoteEntityFileStorage.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/extensions/entity-files/sources/core/src/main/java/tools/dynamia/modules/entityfile/remote/RemoteEntityFileStorage.java b/extensions/entity-files/sources/core/src/main/java/tools/dynamia/modules/entityfile/remote/RemoteEntityFileStorage.java index 5fea52fb..24053af3 100644 --- a/extensions/entity-files/sources/core/src/main/java/tools/dynamia/modules/entityfile/remote/RemoteEntityFileStorage.java +++ b/extensions/entity-files/sources/core/src/main/java/tools/dynamia/modules/entityfile/remote/RemoteEntityFileStorage.java @@ -136,7 +136,7 @@ public String getFilename() { .uri(uriBuilder -> uriBuilder.replacePath("/" + bucket + "/" + key).build()) .header(HEADER_IDENTITY, getIdentity()) .header(HEADER_SECRET, getSecret()) - .contentType(org.springframework.http.MediaType.parseMediaType(contentType(fileInfo))) + // .contentType(org.springframework.http.MediaType.parseMediaType(contentType(fileInfo))) .body(resource) .retrieve() .toBodilessEntity(); From faa24d0709d1c0100cc7cce59ccf0d2a70ca8a22 Mon Sep 17 00:00:00 2001 From: Mario Serrano Date: Sun, 17 May 2026 21:53:32 -0500 Subject: [PATCH 19/22] fix: update authIdentity decoration to use null value for compatibility with Fastify v5 feat: add integration tests for RemoteEntityFileStorage functionality --- .../src/http/plugins/auth.plugin.ts | 7 +- .../remote/RemoteEntityFileStorageTest.java | 407 ++++++++++++++++++ 2 files changed, 413 insertions(+), 1 deletion(-) create mode 100644 extensions/entity-files/sources/core/src/test/java/tools/dynamia/modules/entityfile/remote/RemoteEntityFileStorageTest.java diff --git a/extensions/entity-files/packages/simple-file-server/src/http/plugins/auth.plugin.ts b/extensions/entity-files/packages/simple-file-server/src/http/plugins/auth.plugin.ts index cb98f9b8..a7e63dc9 100644 --- a/extensions/entity-files/packages/simple-file-server/src/http/plugins/auth.plugin.ts +++ b/extensions/entity-files/packages/simple-file-server/src/http/plugins/auth.plugin.ts @@ -30,7 +30,12 @@ export const authPlugin = fp(async function authPlugin( ): Promise { const { identityService, logger } = options - fastify.decorateRequest('authIdentity', { getter: () => null }) + // Use a plain null initial value — NOT { getter: () => null }. + // In Fastify v5 the getter-object form defines the property as read-only + // (getter-only, no setter), which causes: + // "Cannot set property authIdentity of #<_Request> which has only a getter" + // when the onRequest hook does `request.authIdentity = id`. + fastify.decorateRequest('authIdentity', null) fastify.addHook('onRequest', async (request: FastifyRequest, reply: FastifyReply) => { // Skip health check diff --git a/extensions/entity-files/sources/core/src/test/java/tools/dynamia/modules/entityfile/remote/RemoteEntityFileStorageTest.java b/extensions/entity-files/sources/core/src/test/java/tools/dynamia/modules/entityfile/remote/RemoteEntityFileStorageTest.java new file mode 100644 index 00000000..6240c2a7 --- /dev/null +++ b/extensions/entity-files/sources/core/src/test/java/tools/dynamia/modules/entityfile/remote/RemoteEntityFileStorageTest.java @@ -0,0 +1,407 @@ +/* + * Copyright (C) 2023 Dynamia Soluciones IT S.A.S - NIT 900302344-1 + * Colombia / South America + * + * 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. + */ + +package tools.dynamia.modules.entityfile.remote; + +import org.junit.Assume; +import org.junit.Before; +import org.junit.BeforeClass; +import org.junit.Test; +import org.springframework.mock.env.MockEnvironment; +import tools.dynamia.domain.InMemoryCrudService; +import tools.dynamia.domain.query.Parameter; +import tools.dynamia.domain.query.Parameters; +import tools.dynamia.domain.query.QueryParameters; +import tools.dynamia.modules.entityfile.StoredEntityFile; +import tools.dynamia.modules.entityfile.UploadedFileInfo; +import tools.dynamia.modules.entityfile.domain.EntityFile; +import tools.dynamia.modules.entityfile.domain.enums.EntityFileState; +import tools.dynamia.modules.entityfile.enums.EntityFileType; + +import java.io.ByteArrayInputStream; +import java.net.HttpURLConnection; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.util.Collection; +import java.util.List; + +import static org.junit.Assert.*; + +/** + * Integration tests for {@link RemoteEntityFileStorage}. + * + *

Pure-logic tests (buildKey, getFileName, etc.) always run. + * HTTP tests are skipped automatically via {@code Assume.assumeTrue} + * when the SFS server is not reachable.

+ * + *

Server configuration via system properties or environment variables: + *

    + *
  • {@code SFS_URL} – SFS server base URL (default: {@code http://localhost:8081})
  • + *
  • {@code SFS_BUCKET} – bucket name (default: {@code test})
  • + *
  • {@code SFS_IDENTITY} – SFS identity (default: empty)
  • + *
  • {@code SFS_SECRET} – SFS secret (default: empty)
  • + *
+ * Maven example: {@code mvn test -DSFS_URL=http://my-sfs:8081 -DSFS_BUCKET=test} + *

+ */ +public class RemoteEntityFileStorageTest { + + private static String sfsUrl; + private static String sfsBucket; + private static String sfsIdentity; + private static String sfsSecret; + + private RemoteEntityFileStorage storage; + + // ── Setup ───────────────────────────────────────────────────────────────── + + @BeforeClass + public static void readConfiguration() { + sfsUrl = systemOrEnv(RemoteEntityFileStorage.SFS_URL, "http://localhost:8500"); + sfsBucket = systemOrEnv(RemoteEntityFileStorage.SFS_BUCKET, "test"); + sfsIdentity = systemOrEnv(RemoteEntityFileStorage.SFS_IDENTITY, "test"); + sfsSecret = systemOrEnv(RemoteEntityFileStorage.SFS_SECRET, "test"); + + System.out.println("[SFS Test] URL=" + sfsUrl + " | BUCKET=" + sfsBucket); + } + + @Before + public void setUp() { + MockEnvironment env = new MockEnvironment(); + env.setProperty(RemoteEntityFileStorage.SFS_URL, sfsUrl); + env.setProperty(RemoteEntityFileStorage.SFS_BUCKET, sfsBucket); + env.setProperty(RemoteEntityFileStorage.SFS_IDENTITY, sfsIdentity); + env.setProperty(RemoteEntityFileStorage.SFS_SECRET, sfsSecret); + + storage = new RemoteEntityFileStorage(noOpParameters(), new InMemoryCrudService(), env); + } + + // ── Pure-logic tests (no server required) ───────────────────────────────── + + @Test + public void testGetId() { + assertEquals(RemoteEntityFileStorage.ID, storage.getId()); + } + + @Test + public void testGetName() { + assertNotNull(storage.getName()); + assertFalse("Storage name must not be blank", storage.getName().isBlank()); + } + + @Test + public void testBuildKey_withoutSubfolder() { + EntityFile ef = buildEntityFile("report.pdf", null, 10L); + String key = storage.buildKey(ef); + + assertTrue("Key must start with account10/", key.startsWith("account10/")); + assertTrue("Key must contain the uuid", key.contains(ef.getUuid())); + } + + @Test + public void testBuildKey_withSubfolder() { + EntityFile ef = buildEntityFile("image.jpg", "photos/2026", 5L); + String key = storage.buildKey(ef); + + assertTrue("Key must start with account5/", key.startsWith("account5/")); + assertTrue("Key must contain the subfolder path", key.contains("photos/2026/")); + } + + @Test + public void testGetFileName_withSpacesAndDashes() { + EntityFile ef = buildEntityFile("My File-Final.pdf", null, 1L); + String name = RemoteEntityFileStorage.getFileName(ef); + + assertFalse("File name must not contain spaces", name.contains(" ")); + assertFalse("File name base must not contain dashes", + name.substring(name.lastIndexOf('/') + 1).replace(ef.getUuid(), "").contains("-")); + } + + @Test + public void testGetFileName_withAccentsAndSpecialChars() { + EntityFile ef = buildEntityFile("Ñoño Ávido Murió.pdf", null, 1L); + String name = RemoteEntityFileStorage.getFileName(ef); + + assertFalse("File name must not contain ñ", name.contains("ñ")); + assertFalse("File name must not contain á", name.contains("á")); + assertFalse("File name must not contain ó", name.contains("ó")); + assertFalse("File name must not contain spaces", name.contains(" ")); + } + + @Test + public void testGetFileName_usesStoredFileNameWhenSet() { + EntityFile ef = buildEntityFile("original.pdf", null, 1L); + ef.setStoredFileName("custom_stored_name.pdf"); + + String name = RemoteEntityFileStorage.getFileName(ef); + + assertEquals("Must use storedFileName when it is set", "custom_stored_name.pdf", name); + } + + @Test + public void testGetFileName_withoutSubfolder() { + EntityFile ef = buildEntityFile("doc.txt", null, 1L); + String name = RemoteEntityFileStorage.getFileName(ef); + + assertFalse("Without subfolder the name must not start with /", name.startsWith("/")); + assertTrue("Name must contain the uuid", name.contains(ef.getUuid())); + } + + @Test + public void testGetAccountFolderName() { + assertEquals("account42/", RemoteEntityFileStorage.getAccountFolderName(42L)); + assertEquals("account1/", RemoteEntityFileStorage.getAccountFolderName(1L)); + assertEquals("account999/", RemoteEntityFileStorage.getAccountFolderName(999L)); + } + + @Test + public void testBuildRemoteUrl_containsUrlBucketAndKey() { + EntityFile ef = buildEntityFile("document.pdf", null, 3L); + String url = storage.buildRemoteUrl(ef); + + assertTrue("URL must start with the SFS base URL", url.startsWith(sfsUrl)); + assertTrue("URL must contain the bucket name", url.contains(sfsBucket)); + assertTrue("URL must contain the account folder", url.contains("account3/")); + assertTrue("URL must contain the file uuid", url.contains(ef.getUuid())); + } + + @Test + public void testDownload_returnsRemoteStoredEntityFile() { + EntityFile ef = buildEntityFile("file.txt", null, 1L); + StoredEntityFile stored = storage.download(ef); + + assertNotNull("StoredEntityFile must not be null", stored); + assertNotNull("URL must not be null", stored.getUrl()); + assertNull("Remote file must not have a local real file", stored.getRealFile()); + } + + @Test + public void testThumbnailUrl_containsDimensionParameters() { + EntityFile ef = buildEntityFile("photo.jpg", null, 1L); + StoredEntityFile stored = storage.download(ef); + + String thumb100 = stored.getThumbnailUrl(100, 100); + assertTrue("Thumbnail URL must contain w=100", thumb100.contains("w=100")); + assertTrue("Thumbnail URL must contain h=100", thumb100.contains("h=100")); + + String thumb200 = stored.getThumbnailUrl(200, 300); + assertTrue("Thumbnail URL must contain w=200", thumb200.contains("w=200")); + assertTrue("Thumbnail URL must contain h=300", thumb200.contains("h=300")); + } + + @Test + public void testReloadParams_resetsAndRebuildsClient() { + // Force initial client build + storage.client(); + + // reloadParams must clear the internal client + storage.reloadParams(); + + // First call after reload must rebuild the client without throwing + assertNotNull("Client must be rebuilt after reloadParams", storage.client()); + } + + @Test + public void testToResource_returnsUrlResource() { + EntityFile ef = buildEntityFile("file.pdf", null, 1L); + StoredEntityFile stored = storage.download(ef); + + // toResource() must not throw even if the URL is not reachable + // (UrlResource accepts any syntactically valid URL without connecting) + assertNotNull("toResource() must not throw or return null", stored.toResource()); + } + + @Test + public void testToThumbnailResource_returnsUrlResource() { + EntityFile ef = buildEntityFile("image.png", null, 1L); + StoredEntityFile stored = storage.download(ef); + + assertNotNull("toThumbnailResource() must not throw or return null", + stored.toThumbnailResource(200, 200)); + } + + // ── Integration tests (require a live SFS server) ───────────────────────── + + @Test + public void testUpload_textFile() { + Assume.assumeTrue("SFS server not available at " + sfsUrl, isServerReachable()); + + EntityFile ef = buildEntityFile("test-upload-" + System.currentTimeMillis() + ".txt", null, 1L); + String content = "Hello SFS from automated test - " + System.currentTimeMillis(); + byte[] bytes = content.getBytes(StandardCharsets.UTF_8); + + UploadedFileInfo info = new UploadedFileInfo( + ef.getName(), "text/plain", new ByteArrayInputStream(bytes)); + info.setLength(bytes.length); + + storage.upload(ef, info); + + assertTrue("File size must be > 0 after a successful upload", ef.getSize() > 0); + } + + @Test + public void testUpload_withSubfolder() { + Assume.assumeTrue("SFS server not available at " + sfsUrl, isServerReachable()); + + EntityFile ef = buildEntityFile("document.txt", "subfolder/tests", 1L); + byte[] bytes = "content with subfolder".getBytes(StandardCharsets.UTF_8); + + UploadedFileInfo info = new UploadedFileInfo( + ef.getName(), "text/plain", new ByteArrayInputStream(bytes)); + info.setLength(bytes.length); + + storage.upload(ef, info); + + String key = storage.buildKey(ef); + assertTrue("Key must include the subfolder path", key.contains("subfolder/tests/")); + } + + @Test + public void testUpload_nameWithSpacesDoesNotFail() { + Assume.assumeTrue("SFS server not available at " + sfsUrl, isServerReachable()); + + EntityFile ef = buildEntityFile("file with spaces and ñ.txt", null, 1L); + byte[] bytes = "content".getBytes(StandardCharsets.UTF_8); + + UploadedFileInfo info = new UploadedFileInfo( + ef.getName(), "text/plain", new ByteArrayInputStream(bytes)); + info.setLength(bytes.length); + + // Must not throw — the name is sanitised before being sent to SFS + storage.upload(ef, info); + } + + @Test + public void testDelete_changesStateToDeleted() { + Assume.assumeTrue("SFS server not available at " + sfsUrl, isServerReachable()); + + // 1. Upload a file first + EntityFile ef = buildEntityFile("test-delete-" + System.currentTimeMillis() + ".txt", null, 1L); + byte[] bytes = "temporary file to delete".getBytes(StandardCharsets.UTF_8); + + UploadedFileInfo info = new UploadedFileInfo( + ef.getName(), "text/plain", new ByteArrayInputStream(bytes)); + info.setLength(bytes.length); + + storage.upload(ef, info); + + // 2. Delete it + storage.delete(ef); + + // 3. Verify state + assertEquals("State must change to DELETED", EntityFileState.DELETED, ef.getState()); + } + + @Test + public void testUploadAndDownloadUrl_areConsistent() { + Assume.assumeTrue("SFS server not available at " + sfsUrl, isServerReachable()); + + EntityFile ef = buildEntityFile("consistency-" + System.currentTimeMillis() + ".txt", null, 1L); + byte[] bytes = "URL consistency check content".getBytes(StandardCharsets.UTF_8); + + UploadedFileInfo info = new UploadedFileInfo( + ef.getName(), "text/plain", new ByteArrayInputStream(bytes)); + info.setLength(bytes.length); + + storage.upload(ef, info); + + StoredEntityFile stored = storage.download(ef); + String url = stored.getUrl(); + + // The URL returned by download() must point to the same resource that was uploaded + assertNotNull(url); + assertTrue("URL must contain the bucket name", url.contains(sfsBucket)); + assertTrue("URL must contain the key of the uploaded file", url.contains(storage.buildKey(ef))); + } + + // ── Helpers ─────────────────────────────────────────────────────────────── + + /** + * Builds a minimal {@link EntityFile} suitable for testing. + */ + private EntityFile buildEntityFile(String name, String subfolder, Long accountId) { + EntityFile ef = new EntityFile(); + ef.setName(name); + ef.setSubfolder(subfolder); + ef.setAccountId(accountId); + ef.setType(EntityFileType.FILE); + ef.setExtension("txt"); + ef.setTargetEntity("TestEntity"); + ef.setTargetEntityId(1L); + return ef; + } + + /** + * Attempts to open a short-lived connection to the SFS server. + * Returns {@code true} if the server responds, {@code false} otherwise. + */ + private boolean isServerReachable() { + try { + HttpURLConnection con = (HttpURLConnection) URI.create(sfsUrl).toURL().openConnection(); + con.setConnectTimeout(2000); + con.setReadTimeout(2000); + con.setRequestMethod("GET"); + con.connect(); + int responseCode = con.getResponseCode(); + con.disconnect(); + return responseCode > 0; + } catch (Exception e) { + System.out.println("[SFS Test] Server not reachable: " + e.getMessage()); + return false; + } + } + + /** + * Reads a value from system properties ({@code -Dkey=value}) first, + * then from environment variables, falling back to {@code defaultValue}. + */ + private static String systemOrEnv(String key, String defaultValue) { + String value = System.getProperty(key); + if (value != null && !value.isBlank()) return value; + value = System.getenv(key); + if (value != null && !value.isBlank()) return value; + return defaultValue; + } + + /** + * Minimal no-op implementation of {@link Parameters} used as a fallback. + * In tests, {@link MockEnvironment} already supplies all SFS values, so + * this implementation is never actually invoked except on unexpected errors. + */ + @SuppressWarnings({"rawtypes", "unchecked"}) + private static Parameters noOpParameters() { + return new Parameters() { + @Override public List getParameters(List n) { return List.of(); } + @Override public List getParameters(Class c, List n) { return List.of(); } + @Override public List all() { return List.of(); } + @Override public Parameter getParameter(String name) { return null; } + @Override public String getValue(String p) { return null; } + @Override public String getValue(Class c, String p) { return null; } + @Override public String getValue(String p, String def) { return def; } + @Override public String getValue(Class c, String p, String def) { return def; } + @Override public void save(Parameter p) {} + @Override public void save(Collection params) {} + @Override public void setParameter(Class c, String n, Object v) {} + @Override public void setParameter(String n, Object v) {} + @Override public Parameter getParameter(Class c, String n) { return null; } + @Override public void increaseCounter(Parameter p) {} + @Override public long findNextCounterValue(Parameter p) { return 0; } + @Override public Parameter findParameter(Class c, String n, QueryParameters f) { return null; } + }; + } +} + From d80ed8cb778cabf820eb736aedd9d2fdfc88e1b7 Mon Sep 17 00:00:00 2001 From: Mario Serrano Date: Sun, 17 May 2026 21:53:54 -0500 Subject: [PATCH 20/22] feat: add comprehensive documentation for simple-file-server architecture and API --- .../simple-file-server/package-lock.json | 3801 +++++++++++++++++ .../simple-file-server-full-plan.md | 842 ++++ 2 files changed, 4643 insertions(+) create mode 100644 extensions/entity-files/packages/simple-file-server/package-lock.json create mode 100644 extensions/entity-files/packages/simple-file-server/simple-file-server-full-plan.md diff --git a/extensions/entity-files/packages/simple-file-server/package-lock.json b/extensions/entity-files/packages/simple-file-server/package-lock.json new file mode 100644 index 00000000..0f43b32e --- /dev/null +++ b/extensions/entity-files/packages/simple-file-server/package-lock.json @@ -0,0 +1,3801 @@ +{ + "name": "@dynamia-tools/simple-file-server", + "version": "26.5.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@dynamia-tools/simple-file-server", + "version": "26.5.0", + "license": "Apache-2.0", + "dependencies": { + "@fastify/sensible": "^6.0.4", + "argon2": "^0.43.0", + "commander": "^13.1.0", + "fastify": "^5.3.0", + "fastify-plugin": "^5.1.0", + "file-type": "^21.0.0", + "pino": "^9.7.0", + "pino-pretty": "^13.0.0", + "sharp": "^0.34.1", + "zod": "^3.24.0" + }, + "bin": { + "sfs": "dist/cli/index.js", + "sfs-test": "dist/cli/index.js" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "@types/sharp": "^0.31.0", + "tsx": "^4.19.0", + "typescript": "^5.7.0", + "vitest": "^3.0.0" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@borewit/text-codec": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/@borewit/text-codec/-/text-codec-0.2.2.tgz", + "integrity": "sha512-DDaRehssg1aNrH4+2hnj1B7vnUGEjU6OIlyRdkMd0aUdIUvKXrJfXsy8LVtXAy7DRvYVluWbMspsRhz2lcW0mQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz", + "integrity": "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.0.tgz", + "integrity": "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.0.tgz", + "integrity": "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.0.tgz", + "integrity": "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.0.tgz", + "integrity": "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.0.tgz", + "integrity": "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.0.tgz", + "integrity": "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.0.tgz", + "integrity": "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.0.tgz", + "integrity": "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.0.tgz", + "integrity": "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.0.tgz", + "integrity": "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.0.tgz", + "integrity": "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.0.tgz", + "integrity": "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.0.tgz", + "integrity": "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.0.tgz", + "integrity": "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.0.tgz", + "integrity": "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.0.tgz", + "integrity": "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.0.tgz", + "integrity": "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.0.tgz", + "integrity": "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.0.tgz", + "integrity": "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.0.tgz", + "integrity": "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.0.tgz", + "integrity": "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.0.tgz", + "integrity": "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.0.tgz", + "integrity": "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.0.tgz", + "integrity": "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.0.tgz", + "integrity": "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@fastify/ajv-compiler": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@fastify/ajv-compiler/-/ajv-compiler-4.0.5.tgz", + "integrity": "sha512-KoWKW+MhvfTRWL4qrhUwAAZoaChluo0m0vbiJlGMt2GXvL4LVPQEjt8kSpHI3IBq5Rez8fg+XeH3cneztq+C7A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "ajv": "^8.12.0", + "ajv-formats": "^3.0.1", + "fast-uri": "^3.0.0" + } + }, + "node_modules/@fastify/error": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@fastify/error/-/error-4.2.0.tgz", + "integrity": "sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/@fastify/fast-json-stringify-compiler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/@fastify/fast-json-stringify-compiler/-/fast-json-stringify-compiler-5.0.3.tgz", + "integrity": "sha512-uik7yYHkLr6fxd8hJSZ8c+xF4WafPK+XzneQDPU+D10r5X19GW8lJcom2YijX2+qtFF1ENJlHXKFM9ouXNJYgQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "fast-json-stringify": "^6.0.0" + } + }, + "node_modules/@fastify/forwarded": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@fastify/forwarded/-/forwarded-3.0.1.tgz", + "integrity": "sha512-JqDochHFqXs3C3Ml3gOY58zM7OqO9ENqPo0UqAjAjH8L01fRZqwX9iLeX34//kiJubF7r2ZQHtBRU36vONbLlw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/@fastify/merge-json-schemas": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/@fastify/merge-json-schemas/-/merge-json-schemas-0.2.1.tgz", + "integrity": "sha512-OA3KGBCy6KtIvLf8DINC5880o5iBlDX4SxzLQS8HorJAbqluzLRn80UXU0bxZn7UOFhFgpRJDasfwn9nG4FG4A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/@fastify/proxy-addr": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@fastify/proxy-addr/-/proxy-addr-5.1.0.tgz", + "integrity": "sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/forwarded": "^3.0.0", + "ipaddr.js": "^2.1.0" + } + }, + "node_modules/@fastify/sensible": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/@fastify/sensible/-/sensible-6.0.4.tgz", + "integrity": "sha512-1vxcCUlPMew6WroK8fq+LVOwbsLtX+lmuRuqpcp6eYqu6vmkLwbKTdBWAZwbeaSgCfW4tzUpTIHLLvTiQQ1BwQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@lukeed/ms": "^2.0.2", + "dequal": "^2.0.3", + "fastify-plugin": "^5.0.0", + "forwarded": "^0.2.0", + "http-errors": "^2.0.0", + "type-is": "^2.0.1", + "vary": "^1.1.2" + } + }, + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", + "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", + "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", + "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", + "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", + "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", + "cpu": [ + "arm" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", + "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", + "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", + "cpu": [ + "ppc64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", + "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", + "cpu": [ + "riscv64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", + "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", + "cpu": [ + "s390x" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", + "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", + "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", + "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", + "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", + "cpu": [ + "arm" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", + "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", + "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", + "cpu": [ + "ppc64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", + "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", + "cpu": [ + "riscv64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", + "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", + "cpu": [ + "s390x" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", + "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", + "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", + "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", + "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.7.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", + "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", + "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", + "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@lukeed/ms": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@lukeed/ms/-/ms-2.0.2.tgz", + "integrity": "sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@phc/format": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@phc/format/-/format-1.0.0.tgz", + "integrity": "sha512-m7X9U6BG2+J+R1lSOdCiITLLrxm+cWlNI3HUFA92oLO77ObGNzaKdh8pMLqdZcshtkKuV84olNNXDfMc4FezBQ==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/@pinojs/redact": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", + "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==", + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.4.tgz", + "integrity": "sha512-F5QXMSiFebS9hKZj02XhWLLnRpJ3B3AROP0tWbFBSj+6kCbg5m9j5JoHKd4mmSVy5mS/IMQloYgYxCuJC0fxEQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.4.tgz", + "integrity": "sha512-GxxTKApUpzRhof7poWvCJHRF51C67u1R7D6DiluBE8wKU1u5GWE8t+v81JvJYtbawoBFX1hLv5Ei4eVjkWokaw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.4.tgz", + "integrity": "sha512-tua0TaJxMOB1R0V0RS1jFZ/RpURFDJIOR2A6jWwQeawuFyS4gBW+rntLRaQd0EQ4bd6Vp44Z2rXW+YYDBsj6IA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.4.tgz", + "integrity": "sha512-CSKq7MsP+5PFIcydhAiR1K0UhEI1A2jWXVKHPCBZ151yOutENwvnPocgVHkivu2kviURtCEB6zUQw0vs8RrhMg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.4.tgz", + "integrity": "sha512-+O8OkVdyvXMtJEciu2wS/pzm1IxntEEQx3z5TAVy4l32G0etZn+RsA48ARRrFm6Ri8fvqPQfgrvNxSjKAbnd3g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.4.tgz", + "integrity": "sha512-Iw3oMskH3AfNuhU0MSN7vNbdi4me/NiYo2azqPz/Le16zHSa+3RRmliCMWWQmh4lcndccU40xcJuTYJZxNo/lw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.4.tgz", + "integrity": "sha512-EIPRXTVQpHyF8WOo219AD2yEltPehLTcTMz2fn6JsatLYSzQf00hj3rulF+yauOlF9/FtM2WpkT/hJh/KJFGhA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.4.tgz", + "integrity": "sha512-J3Yh9PzzF1Ovah2At+lHiGQdsYgArxBbXv/zHfSyaiFQEqvNv7DcW98pCrmdjCZBrqBiKrKKe2V+aaSGWuBe/w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.4.tgz", + "integrity": "sha512-BFDEZMYfUvLn37ONE1yMBojPxnMlTFsdyNoqncT0qFq1mAfllL+ATMMJd8TeuVMiX84s1KbcxcZbXInmcO2mRg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.4.tgz", + "integrity": "sha512-pc9EYOSlOgdQ2uPl1o9PF6/kLSgaUosia7gOuS8mB69IxJvlclko1MECXysjs5ryez1/5zjYqx3+xYU0TU6R1A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.4.tgz", + "integrity": "sha512-NxnomyxYerDh5n4iLrNa+sH+Z+U4BMEE46V2PgQ/hoB909i8gV1M5wPojWg9fk1jWpO3IQnOs20K4wyZuFLEFQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.4.tgz", + "integrity": "sha512-nbJnQ8a3z1mtmrwImCYhc6BGpThAyYVRQxw9uKSKG4wR6aAYno9sVjJ0zaZcW9BPJX1GbrDPf+SvdWjgTuDmnw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.4.tgz", + "integrity": "sha512-2EU6acNrQLd8tYvo/LXW535wupT3m6fo7HKo6lr7ktQoItxTyOL1ZCR/GfGCuXl2vR+zmfI6eRXkSemafv+iVg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.4.tgz", + "integrity": "sha512-WeBtoMuaMxiiIrO2IYP3xs6GMWkJP2C0EoT8beTLkUPmzV1i/UcOSVw1d5r9KBODtHKilG5yFxsGRnBbK3wJ4A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.4.tgz", + "integrity": "sha512-FJHFfqpKUI3A10WrWKiFbBZ7yVbGT4q4B5o1qKFFojqpaYoh9LrQgqWCmmcxQzVSXYtyB5bzkXrYzlHTs21MYA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.4.tgz", + "integrity": "sha512-mcEl6CUT5IAUmQf1m9FYSmVqCJlpQ8r8eyftFUHG8i9OhY7BkBXSUdnLH5DOf0wCOjcP9v/QO93zpmF1SptCCw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.4.tgz", + "integrity": "sha512-ynt3JxVd2w2buzoKDWIyiV1pJW93xlQic1THVLXilz429oijRpSHivZAgp65KBu+cMcgf1eVVjdnTLvPxgCuoQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.4.tgz", + "integrity": "sha512-Boiz5+MsaROEWDf+GGEwF8VMHGhlUoQMtIPjOgA5fv4osupqTVnJteQNKJwUcnUog2G55jYXH7KZFFiJe0TEzQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.4.tgz", + "integrity": "sha512-+qfSY27qIrFfI/Hom04KYFw3GKZSGU4lXus51wsb5EuySfFlWRwjkKWoE9emgRw/ukoT4Udsj4W/+xxG8VbPKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.4.tgz", + "integrity": "sha512-VpTfOPHgVXEBeeR8hZ2O0F3aSso+JDWqTWmTmzcQKted54IAdUVbxE+j/MVxUsKa8L20HJhv3vUezVPoquqWjA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.4.tgz", + "integrity": "sha512-IPOsh5aRYuLv/nkU51X10Bf75Bsf6+gZdx1X+QP5QM6lIJFHHqbHLG0uJn/hWthzo13UAc2umiUorqZy3axoZg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.4.tgz", + "integrity": "sha512-4QzE9E81OohJ/HKzHhsqU+zcYYojVOXlFMs1DdyMT6qXl/niOH7AVElmmEdUNHHS/oRkc++d5k6Vy85zFs0DEw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.4.tgz", + "integrity": "sha512-zTPgT1YuHHcd+Tmx7h8aml0FWFVelV5N54oHow9SLj+GfoDy/huQ+UV396N/C7KpMDMiPspRktzM1/0r1usYEA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.4.tgz", + "integrity": "sha512-DRS4G7mi9lJxqEDezIkKCaUIKCrLUUDCUaCsTPCi/rtqaC6D/jjwslMQyiDU50Ka0JKpeXeRBFBAXwArY52vBw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.4.tgz", + "integrity": "sha512-QVTUovf40zgTqlFVrKA1uXMVvU2QWEFWfAH8Wdc48IxLvrJMQVMBRjuQyUpzZCDkakImib9eVazbWlC6ksWtJw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@tokenizer/inflate": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@tokenizer/inflate/-/inflate-0.4.1.tgz", + "integrity": "sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "token-types": "^6.1.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/@tokenizer/token": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@tokenizer/token/-/token-0.3.0.tgz", + "integrity": "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==", + "license": "MIT" + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.19.19", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.19.tgz", + "integrity": "sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/sharp": { + "version": "0.31.1", + "resolved": "https://registry.npmjs.org/@types/sharp/-/sharp-0.31.1.tgz", + "integrity": "sha512-5nWwamN9ZFHXaYEincMSuza8nNfOof8nmO+mcI+Agx1uMUk4/pQnNIcix+9rLPXzKrm1pS34+6WRDbDV0Jn7ag==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@vitest/expect": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz", + "integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz", + "integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "3.2.4", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz", + "integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.4.tgz", + "integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "3.2.4", + "pathe": "^2.0.3", + "strip-literal": "^3.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.4.tgz", + "integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.4", + "magic-string": "^0.30.17", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.4.tgz", + "integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^4.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.4.tgz", + "integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.4", + "loupe": "^3.1.4", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/abstract-logging": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/abstract-logging/-/abstract-logging-2.0.1.tgz", + "integrity": "sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==", + "license": "MIT" + }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/argon2": { + "version": "0.43.1", + "resolved": "https://registry.npmjs.org/argon2/-/argon2-0.43.1.tgz", + "integrity": "sha512-TfOzvDWUaQPurCT1hOwIeFNkgrAJDpbBGBGWDgzDsm11nNhImc13WhdGdCU6K7brkp8VpeY07oGtSex0Wmhg8w==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "@phc/format": "^1.0.0", + "node-addon-api": "^8.4.0", + "node-gyp-build": "^4.8.4" + }, + "engines": { + "node": ">=16.17.0" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/atomic-sleep": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", + "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/avvio": { + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/avvio/-/avvio-9.2.0.tgz", + "integrity": "sha512-2t/sy01ArdHHE0vRH5Hsay+RtCZt3dLPji7W7/MMOCEgze5b7SNDC4j5H6FnVgPkI1MTNFGzHdHrVXDDl7QSSQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/error": "^4.0.0", + "fastq": "^1.17.1" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/colorette": { + "version": "2.0.20", + "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", + "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==", + "license": "MIT" + }, + "node_modules/commander": { + "version": "13.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-13.1.0.tgz", + "integrity": "sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/dateformat": { + "version": "4.6.3", + "resolved": "https://registry.npmjs.org/dateformat/-/dateformat-4.6.3.tgz", + "integrity": "sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA==", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.0.tgz", + "integrity": "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.0", + "@esbuild/android-arm": "0.28.0", + "@esbuild/android-arm64": "0.28.0", + "@esbuild/android-x64": "0.28.0", + "@esbuild/darwin-arm64": "0.28.0", + "@esbuild/darwin-x64": "0.28.0", + "@esbuild/freebsd-arm64": "0.28.0", + "@esbuild/freebsd-x64": "0.28.0", + "@esbuild/linux-arm": "0.28.0", + "@esbuild/linux-arm64": "0.28.0", + "@esbuild/linux-ia32": "0.28.0", + "@esbuild/linux-loong64": "0.28.0", + "@esbuild/linux-mips64el": "0.28.0", + "@esbuild/linux-ppc64": "0.28.0", + "@esbuild/linux-riscv64": "0.28.0", + "@esbuild/linux-s390x": "0.28.0", + "@esbuild/linux-x64": "0.28.0", + "@esbuild/netbsd-arm64": "0.28.0", + "@esbuild/netbsd-x64": "0.28.0", + "@esbuild/openbsd-arm64": "0.28.0", + "@esbuild/openbsd-x64": "0.28.0", + "@esbuild/openharmony-arm64": "0.28.0", + "@esbuild/sunos-x64": "0.28.0", + "@esbuild/win32-arm64": "0.28.0", + "@esbuild/win32-ia32": "0.28.0", + "@esbuild/win32-x64": "0.28.0" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fast-copy": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/fast-copy/-/fast-copy-4.0.3.tgz", + "integrity": "sha512-58apWr0GUiDFM8+3afrO6eYwJBn9ZAhDOzG3L+/9llab/haCARS2UIfffmOurYLwbgDRs8n0rfr6qAAPEAuAQw==", + "license": "MIT" + }, + "node_modules/fast-decode-uri-component": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/fast-decode-uri-component/-/fast-decode-uri-component-1.0.1.tgz", + "integrity": "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==", + "license": "MIT" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-json-stringify": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/fast-json-stringify/-/fast-json-stringify-6.4.0.tgz", + "integrity": "sha512-ibRCQ0GZKJIQ+P3Et1h0LhPgp3PMTYk0MH8O+kW3lNYsvmaQww5Nn3f1jf73Q0jR1Yz3a1CDP4/NZD3vOajWJQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/merge-json-schemas": "^0.2.0", + "ajv": "^8.12.0", + "ajv-formats": "^3.0.1", + "fast-uri": "^3.0.0", + "json-schema-ref-resolver": "^3.0.0", + "rfdc": "^1.2.0" + } + }, + "node_modules/fast-querystring": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/fast-querystring/-/fast-querystring-1.1.2.tgz", + "integrity": "sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==", + "license": "MIT", + "dependencies": { + "fast-decode-uri-component": "^1.0.1" + } + }, + "node_modules/fast-safe-stringify": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", + "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==", + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", + "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fastify": { + "version": "5.8.5", + "resolved": "https://registry.npmjs.org/fastify/-/fastify-5.8.5.tgz", + "integrity": "sha512-Yqptv59pQzPgQUSIm87hMqHJmdkb1+GPxdE6vW6FRyVE9G86mt7rOghitiU4JHRaTyDUk9pfeKmDeu70lAwM4Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/ajv-compiler": "^4.0.5", + "@fastify/error": "^4.0.0", + "@fastify/fast-json-stringify-compiler": "^5.0.0", + "@fastify/proxy-addr": "^5.0.0", + "abstract-logging": "^2.0.1", + "avvio": "^9.0.0", + "fast-json-stringify": "^6.0.0", + "find-my-way": "^9.0.0", + "light-my-request": "^6.0.0", + "pino": "^9.14.0 || ^10.1.0", + "process-warning": "^5.0.0", + "rfdc": "^1.3.1", + "secure-json-parse": "^4.0.0", + "semver": "^7.6.0", + "toad-cache": "^3.7.0" + } + }, + "node_modules/fastify-plugin": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/fastify-plugin/-/fastify-plugin-5.1.0.tgz", + "integrity": "sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-type": { + "version": "21.3.4", + "resolved": "https://registry.npmjs.org/file-type/-/file-type-21.3.4.tgz", + "integrity": "sha512-Ievi/yy8DS3ygGvT47PjSfdFoX+2isQueoYP1cntFW1JLYAuS4GD7NUPGg4zv2iZfV52uDyk5w5Z0TdpRS6Q1g==", + "license": "MIT", + "dependencies": { + "@tokenizer/inflate": "^0.4.1", + "strtok3": "^10.3.4", + "token-types": "^6.1.1", + "uint8array-extras": "^1.4.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sindresorhus/file-type?sponsor=1" + } + }, + "node_modules/find-my-way": { + "version": "9.6.0", + "resolved": "https://registry.npmjs.org/find-my-way/-/find-my-way-9.6.0.tgz", + "integrity": "sha512-Zf4Xve4RymLl7NgaavNebZ01joJ8MfVerOG43wy7SHLO+r+K0C6d/SE0BiR7AV5V1VOCFlOP7ecdo+I4qmiHrQ==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-querystring": "^1.0.0", + "safe-regex2": "^5.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/help-me": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/help-me/-/help-me-5.0.0.tgz", + "integrity": "sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==", + "license": "MIT" + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.4.0.tgz", + "integrity": "sha512-9VGk3HGanVE6JoZXHiCpnGy5X0jYDnN4EA4lntFPj+1vIWlFhIylq2CrrCOJH9EAhc5CYhq18F2Av2tgoAPsYQ==", + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, + "node_modules/joycon": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz", + "integrity": "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-ref-resolver": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/json-schema-ref-resolver/-/json-schema-ref-resolver-3.0.0.tgz", + "integrity": "sha512-hOrZIVL5jyYFjzk7+y7n5JDzGlU8rfWDuYyHwGa2WA8/pcmMHezp2xsVwxrebD/Q9t8Nc5DboieySDpCp4WG4A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/light-my-request": { + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/light-my-request/-/light-my-request-6.6.0.tgz", + "integrity": "sha512-CHYbu8RtboSIoVsHZ6Ye4cj4Aw/yg2oAFimlF7mNvfDV192LR7nDiKtSIfCuLT7KokPSTn/9kfVLm5OGN0A28A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause", + "dependencies": { + "cookie": "^1.0.1", + "process-warning": "^4.0.0", + "set-cookie-parser": "^2.6.0" + } + }, + "node_modules/light-my-request/node_modules/process-warning": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-4.0.1.tgz", + "integrity": "sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-addon-api": { + "version": "8.7.0", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.7.0.tgz", + "integrity": "sha512-9MdFxmkKaOYVTV+XVRG8ArDwwQ77XIgIPyKASB1k3JPq3M8fGQQQE3YpMOrKm6g//Ktx8ivZr8xo1Qmtqub+GA==", + "license": "MIT", + "engines": { + "node": "^18 || ^20 || >= 21" + } + }, + "node_modules/node-gyp-build": { + "version": "4.8.4", + "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", + "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==", + "license": "MIT", + "bin": { + "node-gyp-build": "bin.js", + "node-gyp-build-optional": "optional.js", + "node-gyp-build-test": "build-test.js" + } + }, + "node_modules/on-exit-leak-free": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", + "integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pino": { + "version": "9.14.0", + "resolved": "https://registry.npmjs.org/pino/-/pino-9.14.0.tgz", + "integrity": "sha512-8OEwKp5juEvb/MjpIc4hjqfgCNysrS94RIOMXYvpYCdm/jglrKEiAYmiumbmGhCvs+IcInsphYDFwqrjr7398w==", + "license": "MIT", + "dependencies": { + "@pinojs/redact": "^0.4.0", + "atomic-sleep": "^1.0.0", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^2.0.0", + "pino-std-serializers": "^7.0.0", + "process-warning": "^5.0.0", + "quick-format-unescaped": "^4.0.3", + "real-require": "^0.2.0", + "safe-stable-stringify": "^2.3.1", + "sonic-boom": "^4.0.1", + "thread-stream": "^3.0.0" + }, + "bin": { + "pino": "bin.js" + } + }, + "node_modules/pino-abstract-transport": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-2.0.0.tgz", + "integrity": "sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw==", + "license": "MIT", + "dependencies": { + "split2": "^4.0.0" + } + }, + "node_modules/pino-pretty": { + "version": "13.1.3", + "resolved": "https://registry.npmjs.org/pino-pretty/-/pino-pretty-13.1.3.tgz", + "integrity": "sha512-ttXRkkOz6WWC95KeY9+xxWL6AtImwbyMHrL1mSwqwW9u+vLp/WIElvHvCSDg0xO/Dzrggz1zv3rN5ovTRVowKg==", + "license": "MIT", + "dependencies": { + "colorette": "^2.0.7", + "dateformat": "^4.6.3", + "fast-copy": "^4.0.0", + "fast-safe-stringify": "^2.1.1", + "help-me": "^5.0.0", + "joycon": "^3.1.1", + "minimist": "^1.2.6", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^3.0.0", + "pump": "^3.0.0", + "secure-json-parse": "^4.0.0", + "sonic-boom": "^4.0.1", + "strip-json-comments": "^5.0.2" + }, + "bin": { + "pino-pretty": "bin.js" + } + }, + "node_modules/pino-pretty/node_modules/pino-abstract-transport": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz", + "integrity": "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==", + "license": "MIT", + "dependencies": { + "split2": "^4.0.0" + } + }, + "node_modules/pino-std-serializers": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz", + "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==", + "license": "MIT" + }, + "node_modules/postcss": { + "version": "8.5.14", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz", + "integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/process-warning": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz", + "integrity": "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/quick-format-unescaped": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", + "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==", + "license": "MIT" + }, + "node_modules/real-require": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz", + "integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==", + "license": "MIT", + "engines": { + "node": ">= 12.13.0" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ret": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/ret/-/ret-0.5.0.tgz", + "integrity": "sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rfdc": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", + "license": "MIT" + }, + "node_modules/rollup": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.4.tgz", + "integrity": "sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.4", + "@rollup/rollup-android-arm64": "4.60.4", + "@rollup/rollup-darwin-arm64": "4.60.4", + "@rollup/rollup-darwin-x64": "4.60.4", + "@rollup/rollup-freebsd-arm64": "4.60.4", + "@rollup/rollup-freebsd-x64": "4.60.4", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.4", + "@rollup/rollup-linux-arm-musleabihf": "4.60.4", + "@rollup/rollup-linux-arm64-gnu": "4.60.4", + "@rollup/rollup-linux-arm64-musl": "4.60.4", + "@rollup/rollup-linux-loong64-gnu": "4.60.4", + "@rollup/rollup-linux-loong64-musl": "4.60.4", + "@rollup/rollup-linux-ppc64-gnu": "4.60.4", + "@rollup/rollup-linux-ppc64-musl": "4.60.4", + "@rollup/rollup-linux-riscv64-gnu": "4.60.4", + "@rollup/rollup-linux-riscv64-musl": "4.60.4", + "@rollup/rollup-linux-s390x-gnu": "4.60.4", + "@rollup/rollup-linux-x64-gnu": "4.60.4", + "@rollup/rollup-linux-x64-musl": "4.60.4", + "@rollup/rollup-openbsd-x64": "4.60.4", + "@rollup/rollup-openharmony-arm64": "4.60.4", + "@rollup/rollup-win32-arm64-msvc": "4.60.4", + "@rollup/rollup-win32-ia32-msvc": "4.60.4", + "@rollup/rollup-win32-x64-gnu": "4.60.4", + "@rollup/rollup-win32-x64-msvc": "4.60.4", + "fsevents": "~2.3.2" + } + }, + "node_modules/rollup/node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/safe-regex2": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/safe-regex2/-/safe-regex2-5.1.1.tgz", + "integrity": "sha512-mOSBvHGDZMuIEZMdOz/aCEYDCv0E7nfcNsIhUF+/P+xC7Hyf3FkvymqgPbg9D1EdSGu+uKbJgy09K/RKKc7kJA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "ret": "~0.5.0" + }, + "bin": { + "safe-regex2": "bin/safe-regex2.js" + } + }, + "node_modules/safe-stable-stringify": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", + "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/secure-json-parse": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-4.1.0.tgz", + "integrity": "sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/semver": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz", + "integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/sharp": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", + "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@img/colour": "^1.0.0", + "detect-libc": "^2.1.2", + "semver": "^7.7.3" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.34.5", + "@img/sharp-darwin-x64": "0.34.5", + "@img/sharp-libvips-darwin-arm64": "1.2.4", + "@img/sharp-libvips-darwin-x64": "1.2.4", + "@img/sharp-libvips-linux-arm": "1.2.4", + "@img/sharp-libvips-linux-arm64": "1.2.4", + "@img/sharp-libvips-linux-ppc64": "1.2.4", + "@img/sharp-libvips-linux-riscv64": "1.2.4", + "@img/sharp-libvips-linux-s390x": "1.2.4", + "@img/sharp-libvips-linux-x64": "1.2.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", + "@img/sharp-libvips-linuxmusl-x64": "1.2.4", + "@img/sharp-linux-arm": "0.34.5", + "@img/sharp-linux-arm64": "0.34.5", + "@img/sharp-linux-ppc64": "0.34.5", + "@img/sharp-linux-riscv64": "0.34.5", + "@img/sharp-linux-s390x": "0.34.5", + "@img/sharp-linux-x64": "0.34.5", + "@img/sharp-linuxmusl-arm64": "0.34.5", + "@img/sharp-linuxmusl-x64": "0.34.5", + "@img/sharp-wasm32": "0.34.5", + "@img/sharp-win32-arm64": "0.34.5", + "@img/sharp-win32-ia32": "0.34.5", + "@img/sharp-win32-x64": "0.34.5" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/sonic-boom": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz", + "integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==", + "license": "MIT", + "dependencies": { + "atomic-sleep": "^1.0.0" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/strip-json-comments": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-5.0.3.tgz", + "integrity": "sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==", + "license": "MIT", + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strip-literal": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", + "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/strtok3": { + "version": "10.3.5", + "resolved": "https://registry.npmjs.org/strtok3/-/strtok3-10.3.5.tgz", + "integrity": "sha512-ki4hZQfh5rX0QDLLkOCj+h+CVNkqmp/CMf8v8kZpkNVK6jGQooMytqzLZYUVYIZcFZ6yDB70EfD8POcFXiF5oA==", + "license": "MIT", + "dependencies": { + "@tokenizer/token": "^0.3.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/thread-stream": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-3.1.0.tgz", + "integrity": "sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A==", + "license": "MIT", + "dependencies": { + "real-require": "^0.2.0" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz", + "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/toad-cache": { + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/toad-cache/-/toad-cache-3.7.0.tgz", + "integrity": "sha512-/m8M+2BJUpoJdgAHoG+baCwBT+tf2VraSfkBgl0Y00qIWt41DJ8R5B8nsEw0I58YwF5IZH6z24/2TobDKnqSWw==", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/token-types": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/token-types/-/token-types-6.1.2.tgz", + "integrity": "sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww==", + "license": "MIT", + "dependencies": { + "@borewit/text-codec": "^0.2.1", + "@tokenizer/token": "^0.3.0", + "ieee754": "^1.2.1" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD", + "optional": true + }, + "node_modules/tsx": { + "version": "4.22.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.0.tgz", + "integrity": "sha512-8ccZMPD69s1AbKXx0C5ddTNZfNjwV04iIKgjZmKfKxMynEtSYcK0Lh7iQFh53fI5Yu4pb9usgAiqyPmEONaALg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/type-is": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "license": "MIT", + "dependencies": { + "content-type": "^2.0.0", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/uint8array-extras": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/uint8array-extras/-/uint8array-extras-1.5.0.tgz", + "integrity": "sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vite": { + "version": "7.3.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.3.tgz", + "integrity": "sha512-/4XH147Ui7OGTjg3HbdWe5arnZQSbfuRzdr9Ec7TQi5I7R+ir0Rlc9GIvD4v0XZurELqA035KVXJXpR61xhiTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", + "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.4.1", + "es-module-lexer": "^1.7.0", + "pathe": "^2.0.3", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vite/node_modules/@esbuild/aix-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", + "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/android-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", + "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/android-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", + "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/android-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", + "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/darwin-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", + "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/darwin-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", + "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", + "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/freebsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", + "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", + "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", + "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", + "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-loong64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", + "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-mips64el": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", + "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", + "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-riscv64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", + "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-s390x": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", + "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", + "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", + "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/netbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", + "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", + "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/openbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", + "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", + "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/sunos-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", + "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", + "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", + "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", + "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/esbuild": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", + "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.7", + "@esbuild/android-arm": "0.27.7", + "@esbuild/android-arm64": "0.27.7", + "@esbuild/android-x64": "0.27.7", + "@esbuild/darwin-arm64": "0.27.7", + "@esbuild/darwin-x64": "0.27.7", + "@esbuild/freebsd-arm64": "0.27.7", + "@esbuild/freebsd-x64": "0.27.7", + "@esbuild/linux-arm": "0.27.7", + "@esbuild/linux-arm64": "0.27.7", + "@esbuild/linux-ia32": "0.27.7", + "@esbuild/linux-loong64": "0.27.7", + "@esbuild/linux-mips64el": "0.27.7", + "@esbuild/linux-ppc64": "0.27.7", + "@esbuild/linux-riscv64": "0.27.7", + "@esbuild/linux-s390x": "0.27.7", + "@esbuild/linux-x64": "0.27.7", + "@esbuild/netbsd-arm64": "0.27.7", + "@esbuild/netbsd-x64": "0.27.7", + "@esbuild/openbsd-arm64": "0.27.7", + "@esbuild/openbsd-x64": "0.27.7", + "@esbuild/openharmony-arm64": "0.27.7", + "@esbuild/sunos-x64": "0.27.7", + "@esbuild/win32-arm64": "0.27.7", + "@esbuild/win32-ia32": "0.27.7", + "@esbuild/win32-x64": "0.27.7" + } + }, + "node_modules/vitest": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz", + "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/expect": "3.2.4", + "@vitest/mocker": "3.2.4", + "@vitest/pretty-format": "^3.2.4", + "@vitest/runner": "3.2.4", + "@vitest/snapshot": "3.2.4", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", + "chai": "^5.2.0", + "debug": "^4.4.1", + "expect-type": "^1.2.1", + "magic-string": "^0.30.17", + "pathe": "^2.0.3", + "picomatch": "^4.0.2", + "std-env": "^3.9.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.14", + "tinypool": "^1.1.1", + "tinyrainbow": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", + "vite-node": "3.2.4", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@vitest/browser": "3.2.4", + "@vitest/ui": "3.2.4", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/debug": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/extensions/entity-files/packages/simple-file-server/simple-file-server-full-plan.md b/extensions/entity-files/packages/simple-file-server/simple-file-server-full-plan.md new file mode 100644 index 00000000..52a73528 --- /dev/null +++ b/extensions/entity-files/packages/simple-file-server/simple-file-server-full-plan.md @@ -0,0 +1,842 @@ +# simple-file-server (SFS) + +`simple-file-server` (SFS) is a standalone filesystem-native file server with a minimal S3-style API designed for secure server-to-server file operations. + +The system is intentionally simple: + +- Private by default +- No anonymous access +- No public buckets +- No JWT +- No object metadata database +- No distributed storage features +- No S3 compatibility goals beyond basic object semantics + +SFS is designed as the backend for `RemoteSimpleEntityFileStorage` in EntityFiles and optimized for: + +- ERP systems +- Multi-tenant applications +- Internal services +- Hybrid infrastructure +- Large file streaming +- Low operational complexity + +--- + +# Core Principles + +1. Filesystem-native storage +2. All access requires authentication +3. Streaming-first architecture +4. No persistent object metadata +5. Predictable and portable deployment +6. Minimal operational complexity +7. Standalone runtime and CLI +8. JSON-only operational API + +--- + +# Main Goals + +Implement a standalone service that supports: + +- Upload +- Download +- Listing +- Deletion +- Thumbnail generation +- Permission-based access control +- Persistent operational logging + +using a simple JSON API and direct filesystem storage. + +--- + +# Security Model + +SFS is strictly private. + +Every request requires authentication using: + +- `identity` +- `secret` + +Without valid credentials: +- no access +- no listing +- no downloads +- no uploads +- no metadata access + +The server is intended exclusively for trusted backend/server-to-server communication. + +--- + +# Authentication + +Authentication uses: + +- HTTP Basic Auth +or +- custom headers + +Example: + +```http +X-SFS-Identity: erp-prod +X-SFS-Secret: xxxxxxxxx +``` + +Secrets are never stored in plain text. + +Requirements: + +- Argon2id password hashing +- Timing-safe secret comparison +- Identity-based permission resolution + +--- + +# Authorization Model + +Each identity may have access to one or more buckets. + +Permissions are granted per bucket and restricted by path prefixes. + +Supported permissions: + +- `read` +- `write` +- `delete` + +Example grant: + +```json +{ + "bucket": "documents", + "prefixes": [ + "/tenant-a/", + "/tenant-a/public/" + ], + "permissions": ["read", "write"] +} +``` + +Rules: + +- Access outside allowed prefixes is denied +- Prefix validation is mandatory for every operation +- Authorization is evaluated after path normalization + +--- + +# Bucket Model + +A bucket is a logical alias mapped to an absolute filesystem path. + +Example: + +```bash +sfs create bucket documents /mnt/storage/documents +``` + +Internal example: + +```text +documents -> /mnt/storage/documents +``` + +Buckets may point to any valid filesystem location. + +Requirements: + +- Absolute paths only +- Canonical path normalization +- Path traversal protection +- Writable validation during startup + +--- + +# Local Configuration Persistence + +All runtime configuration and metadata are stored in the server working directory. + +Default structure: + +```text +./sfs/ + ├── config/ + ├── identities/ + ├── buckets/ + ├── logs/ + ├── runtime/ + └── cache/ +``` + +Requirements: + +- Self-contained deployment +- Portable runtime +- No external database +- No external configuration dependency by default + +Persisted data includes: + +- identities +- grants +- bucket definitions +- runtime metadata +- operational logs + +Object/file metadata is never persisted. + +--- + +# Filesystem Layout + +Each bucket internally contains a reserved `.sfs` directory. + +Example: + +```text +/mnt/storage/documents/ + └── .sfs/ + ├── staging/ + ├── thumbs/ + └── runtime/ +``` + +Purpose: + +- staging uploads +- thumbnail cache +- temporary runtime artifacts + +Rules: + +- `.sfs` is internal and inaccessible through the API +- users cannot upload or access `.sfs` paths + +--- + +# API Design + +## Object Download + +```http +GET /:bucket/*key +``` + +Behavior: + +- Streams file directly +- Supports large files +- Requires `read` permission + +Responses: + +- file stream +- JSON error on failure + +--- + +## Folder Listing + +```http +GET /:bucket/*key-folder/ +``` + +Behavior: + +- Lists directory contents +- Returns JSON response +- Requires `read` permission + +If target: +- does not exist → `404` +- is not a directory → `404` + +--- + +## Bucket Listing + +```http +GET /:bucket +``` + +Returns: + +- paginated object listing +- JSON response only + +Supported query parameters: + +```http +?limit=100 +&cursor=... +``` + +Purpose: + +- prevent excessive memory usage +- support large directories + +--- + +## File Upload + +```http +PUT /:bucket/*key +``` + +Requirements: + +- streaming upload +- overwrite support +- mandatory staging +- requires `write` permission + +Upload flow: + +1. Stream upload into bucket-local staging directory +2. Validate successful write completion +3. fsync temporary file +4. Atomically move file into final destination when possible +5. Cleanup staging artifact + +Rules: + +- Never write directly into final destination +- Upload must not leave partial target files +- Upload failures must be logged +- Existing files may be overwritten by default + +Optional future header: + +```http +If-Not-Exists: true +``` + +--- + +## File Delete + +```http +DELETE /:bucket/*key +``` + +Behavior: + +- Deletes exact file only +- Non-recursive +- Requires `delete` permission + +Rules: + +- Directories cannot be recursively removed +- Missing targets return JSON error + +--- + +# Path Safety + +Path traversal protection is mandatory. + +The server must: + +- normalize paths +- resolve canonical paths +- validate bucket boundaries + +Invalid examples: + +```text +../../../etc/passwd +..%2F..%2F +``` + +After resolution: + +- final path must remain inside bucket root +- otherwise request is denied + +--- + +# Streaming Requirements + +All file operations must support streaming. + +Requirements: + +- constant memory usage +- support for large files +- no full-file buffering + +Applies to: + +- uploads +- downloads +- thumbnail generation + +--- + +# Thumbnail System + +Thumbnail generation is lazy and cache-based. + +Supported request: + +```http +GET /bucket/image.jpg?w=300&h=300 +``` + +Behavior: + +1. Detect image file +2. Generate thumbnail on first request +3. Persist thumbnail into local cache +4. Reuse cached thumbnail on subsequent requests + +Requirements: + +- generation only on demand +- cached thumbnails stored in `.sfs/thumbs` +- thumbnail cache keys based on: + - original file + - width + - height + - resize mode + - output format + +--- + +# MIME Detection + +MIME type detection must not rely exclusively on file extension. + +Preferred detection: + +- magic bytes +- content sniffing + +Purpose: + +- correct content type responses +- safer file handling + +--- + +# Logging + +SFS persists operational logs only. + +No persistent object catalog exists. + +Recommended format: + +- JSON Lines (`.jsonl`) + +Supported events: + +- `upload_started` +- `upload_staged` +- `upload_committed` +- `upload_failed` +- `download` +- `list` +- `delete` +- `auth_failed` +- `permission_denied` + +Example: + +```json +{ + "ts": "2026-05-16T12:00:00Z", + "event": "upload_committed", + "identity": "erp-prod", + "bucket": "documents", + "key": "tenant-a/invoice.pdf", + "size": 204812, + "elapsedMs": 42 +} +``` + +Recommended files: + +```text +logs/access.log.jsonl +logs/error.log.jsonl +``` + +--- + +# JSON Response Format + +## Success + +```json +{ + "ok": true, + "data": {} +} +``` + +## Error + +```json +{ + "ok": false, + "error": { + "code": "SFS_ERROR_CODE", + "message": "Human readable message", + "details": {} + } +} +``` + +--- + +# CLI (`sfs`) + +The server includes a standalone CLI. + +All CLI output must use JSON. + +--- + +# Bucket Commands + +Create bucket: + +```bash +sfs create bucket +``` + +List buckets: + +```bash +sfs list buckets +``` + +Remove bucket (optional policy): + +```bash +sfs remove bucket +``` + +--- + +# Identity Commands + +Create identity: + +```bash +sfs create identity +``` + +Grant permissions: + +```bash +sfs grant \ + --read \ + --write \ + --delete \ + --prefix +``` + +List identities: + +```bash +sfs list identities +``` + +Revoke permissions: + +```bash +sfs revoke +``` + +--- + +# Runtime Commands + +Start server: + +```bash +sfs serve --host 0.0.0.0 --port 8080 +``` + +Optional future flags: + +```bash +--data-dir +--config-dir +--log-level +``` + +--- + +# Startup Validation + +During startup the server validates: + +- bucket existence +- writable staging directories +- writable cache directories +- configuration integrity +- orphan staging cleanup + +Old temporary staging files may be cleaned automatically. + +--- + +# Recommended Node.js Technology Stack + +SFS is designed to be implemented as an ultra-fast streaming-first backend optimized for filesystem IO and server-to-server communication. + +## Runtime + +- Node.js 22+ +- TypeScript (strict mode) +- ESM modules + +## HTTP Framework + +Recommended framework: + +- Fastify + +Reasons: + +- Very high throughput +- Low overhead +- Excellent streaming support +- Native schema integration +- Lightweight plugin system +- Efficient request lifecycle + +## Validation + +Recommended options: + +- TypeBox + AJV (maximum performance) +or +- Zod (better developer experience) + +## Logging + +Recommended logger: + +- Pino + +Reasons: + +- Extremely fast JSON logging +- Native Fastify integration +- Perfect for `.jsonl` operational logs + +## Password Hashing + +Recommended: + +- Argon2id + +Requirements: + +- No plain text secret storage +- Timing-safe comparisons + +## Thumbnail Processing + +Recommended: + +- Sharp + +Reasons: + +- Very fast image processing +- Low memory usage +- Based on libvips +- Efficient thumbnail generation + +## MIME Detection + +Recommended: + +- file-type + +Purpose: + +- Detect MIME types using magic bytes +- Avoid extension-only detection + +## CLI + +Recommended: + +- CAC +or +- Commander.js + +## Development + +Recommended tools: + +- TSX +- Vitest + +## Streaming Requirements + +The implementation must use native streaming APIs: + +- stream.pipeline +- fs.createReadStream +- fs.createWriteStream + +The server must avoid: + +- full-file buffering +- readFile/writeFile for large files +- heavy multipart parsers + +## Upload Strategy + +Uploads should use raw streaming requests. + +Recommended request style: + +```http +PUT /bucket/key +Content-Type: application/octet-stream +``` + +This avoids multipart overhead and improves performance. + +## Recommended Internal Structure + +```text +src/ + ├── app/ + ├── auth/ + ├── bucket/ + ├── cli/ + ├── config/ + ├── errors/ + ├── http/ + ├── logging/ + ├── storage/ + ├── thumbnail/ + ├── utils/ + └── types/ +``` + +## Non-Recommended Frameworks + +SFS intentionally avoids large abstraction-heavy backend frameworks such as: + +- NestJS + +Reason: + +- unnecessary overhead +- excessive abstraction +- lower streaming control +- less predictable IO behavior + +## Final Recommended Stack + +```text +Node.js 24 +TypeScript +Fastify +Pino +Sharp +TypeBox +AJV +Argon2 +Vitest +TSX +``` + +--- + +# Proposed Internal Structure + +```text +src/ + ├── auth/ + ├── cli/ + ├── config/ + ├── http/ + ├── logging/ + ├── storage/ + ├── thumbnail/ + └── runtime/ +``` + +--- + +# Non-Goals + +SFS intentionally does NOT implement: + +- distributed storage +- clustering +- replication +- eventual consistency +- object versioning +- multipart upload +- public URLs +- anonymous access +- JWT authentication +- full S3 compatibility +- metadata indexing database + +The goal is simplicity, predictability, and operational efficiency. + +--- + +# Acceptance Criteria + +1. Buckets map to arbitrary absolute filesystem paths +2. Identities authenticate using `identity + secret` +3. Permissions are bucket and prefix based +4. PUT operations use mandatory staging +5. Uploads and downloads support streaming +6. DELETE removes exact files only +7. Bucket and folder listing return paginated JSON +8. Thumbnail generation is lazy and cached +9. No persistent object metadata exists +10. Operational logs are persisted +11. Everything runs standalone through `sfs` +12. All runtime configuration persists locally in the working directory +13. All access requires authentication +14. Path traversal attacks are prevented + +--- + +# Next Phase: EntityFiles Integration + +Implement: + +```text +RemoteSimpleEntityFileStorage +``` + +Responsibilities: + +- upload +- download +- delete +- list +- thumbnail access + +using SFS endpoints. + +Additional goals: + +- remote thumbnail strategy +- public URL abstraction layer +- transparent storage backend switching +- multi-storage support in EntityFiles From 1ecc6e19a31c94dff897465788329716de2c25f5 Mon Sep 17 00:00:00 2001 From: Mario Serrano Date: Sun, 17 May 2026 22:45:23 -0500 Subject: [PATCH 21/22] feat: enhance RemoteEntityFileStorage to include SFS authentication in resource fetching --- .../remote/RemoteEntityFileStorage.java | 47 +++++++++++++------ .../remote/RemoteEntityFileStorageTest.java | 29 ++++++++---- 2 files changed, 54 insertions(+), 22 deletions(-) diff --git a/extensions/entity-files/sources/core/src/main/java/tools/dynamia/modules/entityfile/remote/RemoteEntityFileStorage.java b/extensions/entity-files/sources/core/src/main/java/tools/dynamia/modules/entityfile/remote/RemoteEntityFileStorage.java index 24053af3..c583a7cf 100644 --- a/extensions/entity-files/sources/core/src/main/java/tools/dynamia/modules/entityfile/remote/RemoteEntityFileStorage.java +++ b/extensions/entity-files/sources/core/src/main/java/tools/dynamia/modules/entityfile/remote/RemoteEntityFileStorage.java @@ -17,11 +17,9 @@ package tools.dynamia.modules.entityfile.remote; -import org.springframework.core.env.Environment; -import org.springframework.core.io.Resource; import org.springframework.core.env.Environment; import org.springframework.core.io.InputStreamResource; -import org.springframework.core.io.UrlResource; +import org.springframework.core.io.Resource; import org.springframework.web.client.RestClient; import tools.dynamia.commons.logger.LoggingService; import tools.dynamia.commons.logger.SLF4JLoggingService; @@ -154,10 +152,8 @@ public String getFilename() { @Override public StoredEntityFile download(EntityFile entityFile) { - // Return a URL that goes through the local proxy handler — SFS credentials - // are never sent to the browser. String url = buildRemoteUrl(entityFile); - return new RemoteStoredEntityFile(entityFile, url); + return new RemoteStoredEntityFile(entityFile, url, client(), getIdentity(), getSecret()); } @Override @@ -298,14 +294,24 @@ private static String contentType(UploadedFileInfo fileInfo) { * {@link StoredEntityFile} for files hosted on a remote SFS instance. * There is no local {@link File} — {@link #getRealFile()} returns {@code null}. * Thumbnails are generated server-side by SFS and requested through the proxy. + * SFS credentials are included in every download request so the server can + * authorise the caller. */ public static class RemoteStoredEntityFile extends StoredEntityFile { @Serial private static final long serialVersionUID = 1L; - public RemoteStoredEntityFile(EntityFile entityFile, String removeUrl) { - super(entityFile, removeUrl, null); + private final transient RestClient restClient; + private final String identity; + private final String secret; + + public RemoteStoredEntityFile(EntityFile entityFile, String remoteUrl, + RestClient restClient, String identity, String secret) { + super(entityFile, remoteUrl, null); + this.restClient = restClient; + this.identity = identity; + this.secret = secret; } @Override @@ -323,17 +329,30 @@ public File getThumbnailFile(int width, int height) { @Override public Resource toResource() { - try { - return new UrlResource(getUrl()); - } catch (Exception e) { - return null; - } + return fetchResource(getUrl()); } @Override public Resource toThumbnailResource(int width, int height) { + return fetchResource(getThumbnailUrl(width, height)); + } + + /** + * GETs the given URL through the {@link RestClient} with SFS authentication headers + * and wraps the response body in an {@link InputStreamResource}. + */ + private Resource fetchResource(String url) { try { - return new UrlResource(getThumbnailUrl(width, height)); + var inputStream = restClient.get() + .uri(url) + .header(HEADER_IDENTITY, identity) + .header(HEADER_SECRET, secret) + .retrieve() + .body(java.io.InputStream.class); + if (inputStream == null) { + return null; + } + return new InputStreamResource(inputStream); } catch (Exception e) { return null; } diff --git a/extensions/entity-files/sources/core/src/test/java/tools/dynamia/modules/entityfile/remote/RemoteEntityFileStorageTest.java b/extensions/entity-files/sources/core/src/test/java/tools/dynamia/modules/entityfile/remote/RemoteEntityFileStorageTest.java index 6240c2a7..da2cc9fc 100644 --- a/extensions/entity-files/sources/core/src/test/java/tools/dynamia/modules/entityfile/remote/RemoteEntityFileStorageTest.java +++ b/extensions/entity-files/sources/core/src/test/java/tools/dynamia/modules/entityfile/remote/RemoteEntityFileStorageTest.java @@ -216,20 +216,33 @@ public void testReloadParams_resetsAndRebuildsClient() { } @Test - public void testToResource_returnsUrlResource() { - EntityFile ef = buildEntityFile("file.pdf", null, 1L); - StoredEntityFile stored = storage.download(ef); + public void testToResource_returnsInputStreamResource() { + Assume.assumeTrue("SFS server not available at " + sfsUrl, isServerReachable()); + + // Upload a file first so the URL is actually retrievable + EntityFile ef = buildEntityFile("to-resource-" + System.currentTimeMillis() + ".txt", null, 1L); + byte[] bytes = "toResource content".getBytes(StandardCharsets.UTF_8); + UploadedFileInfo info = new UploadedFileInfo(ef.getName(), "text/plain", new ByteArrayInputStream(bytes)); + info.setLength(bytes.length); + storage.upload(ef, info); - // toResource() must not throw even if the URL is not reachable - // (UrlResource accepts any syntactically valid URL without connecting) + StoredEntityFile stored = storage.download(ef); + // toResource() must authenticate with SFS and return an InputStreamResource assertNotNull("toResource() must not throw or return null", stored.toResource()); } @Test - public void testToThumbnailResource_returnsUrlResource() { - EntityFile ef = buildEntityFile("image.png", null, 1L); - StoredEntityFile stored = storage.download(ef); + public void testToThumbnailResource_returnsInputStreamResource() { + Assume.assumeTrue("SFS server not available at " + sfsUrl, isServerReachable()); + + EntityFile ef = buildEntityFile("to-thumb-" + System.currentTimeMillis() + ".png", null, 1L); + byte[] bytes = new byte[]{(byte) 0xFF, (byte) 0xD8}; // minimal JPEG-like stub + UploadedFileInfo info = new UploadedFileInfo(ef.getName(), "image/png", new ByteArrayInputStream(bytes)); + info.setLength(bytes.length); + storage.upload(ef, info); + StoredEntityFile stored = storage.download(ef); + // toThumbnailResource() must authenticate with SFS and return an InputStreamResource assertNotNull("toThumbnailResource() must not throw or return null", stored.toThumbnailResource(200, 200)); } From 1899160ea0fe11d54b905a9c3115ff51bae4271a Mon Sep 17 00:00:00 2001 From: Mario Serrano Date: Sun, 17 May 2026 22:45:28 -0500 Subject: [PATCH 22/22] fix: update thumbnail service to use 'contain' fit option for resizing --- .../simple-file-server/src/thumbnail/thumbnail.service.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/extensions/entity-files/packages/simple-file-server/src/thumbnail/thumbnail.service.ts b/extensions/entity-files/packages/simple-file-server/src/thumbnail/thumbnail.service.ts index 2c87eeeb..67faf6af 100644 --- a/extensions/entity-files/packages/simple-file-server/src/thumbnail/thumbnail.service.ts +++ b/extensions/entity-files/packages/simple-file-server/src/thumbnail/thumbnail.service.ts @@ -72,7 +72,7 @@ export class ThumbnailService { pipeline = pipeline.resize({ width: options.width, height: options.height, - fit: options.fit ?? 'cover', + fit: options.fit ?? 'contain', withoutEnlargement: true, }) } @@ -102,7 +102,7 @@ export class ThumbnailService { private buildCacheKey(key: string, options: ThumbnailOptions): string { const normalized = key.replace(/\//g, '_').replace(/\s/g, '_') - const optStr = `${options.width ?? 0}x${options.height ?? 0}-${options.fit ?? 'cover'}-${options.format ?? 'webp'}` + const optStr = `${options.width ?? 0}x${options.height ?? 0}-${options.fit ?? 'contain'}-${options.format ?? 'webp'}` const hash = crypto.createHash('sha1').update(`${normalized}:${optStr}`).digest('hex').slice(0, 12) const ext = options.format ?? 'webp' return `${hash}.${ext}`