diff --git a/package.json b/package.json index 932e3ee2..315f719d 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,7 @@ "release:major": "standard-version --release-as major" }, "dependencies": { - "@gip-recia/tldraw-webcomponent": "^1.3.0", + "@gip-recia/tldraw-webcomponent": "^1.5.1", "@vueuse/core": "^10.7.2", "pinia": "^2.1.7", "vue": "^3.4.15", diff --git a/src/main/java/fr/recia/collabsoft/configuration/CollabsoftProperties.java b/src/main/java/fr/recia/collabsoft/configuration/CollabsoftProperties.java index 9d0481ee..763360b0 100644 --- a/src/main/java/fr/recia/collabsoft/configuration/CollabsoftProperties.java +++ b/src/main/java/fr/recia/collabsoft/configuration/CollabsoftProperties.java @@ -19,6 +19,7 @@ import fr.recia.collabsoft.configuration.beans.CorsProperties; import fr.recia.collabsoft.configuration.beans.SecurityProperties; import fr.recia.collabsoft.configuration.beans.SoffitProperties; +import fr.recia.collabsoft.configuration.beans.StorageProperties; import lombok.Data; import lombok.extern.slf4j.Slf4j; import org.springframework.boot.context.properties.ConfigurationProperties; @@ -40,6 +41,7 @@ public class CollabsoftProperties { private CorsProperties cors = new CorsProperties(); private SecurityProperties security = new SecurityProperties(); private SoffitProperties soffit = new SoffitProperties(); + private StorageProperties storage = new StorageProperties(); @PostConstruct private void init() throws JsonProcessingException { @@ -52,6 +54,7 @@ public String toString() { + cors + ",\n" + security + ",\n" + soffit + "\n" + + storage + "\n}"; } diff --git a/src/main/java/fr/recia/collabsoft/configuration/beans/StorageProperties.java b/src/main/java/fr/recia/collabsoft/configuration/beans/StorageProperties.java new file mode 100644 index 00000000..9c3c9325 --- /dev/null +++ b/src/main/java/fr/recia/collabsoft/configuration/beans/StorageProperties.java @@ -0,0 +1,32 @@ +/* + * Copyright (C) 2023 GIP-RECIA, Inc. + * + * 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 fr.recia.collabsoft.configuration.beans; + +import lombok.Data; + +@Data +public class StorageProperties { + + private String location; + + @Override + public String toString() { + return "\"StorageProperties\": {" + + "\n\t\"location\": \"" + location + "\"" + + "\n}"; + } + +} diff --git a/src/main/java/fr/recia/collabsoft/interceptors/SoffitInterceptor.java b/src/main/java/fr/recia/collabsoft/interceptors/SoffitInterceptor.java index 603f9a8b..d991d88e 100644 --- a/src/main/java/fr/recia/collabsoft/interceptors/SoffitInterceptor.java +++ b/src/main/java/fr/recia/collabsoft/interceptors/SoffitInterceptor.java @@ -40,7 +40,7 @@ public SoffitInterceptor(SoffitHolder soffitHolder) { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String path = request.getRequestURI().substring(request.getContextPath().length()); - if (!path.startsWith("/api/file")) return true; + if (!path.startsWith("/api/file") || path.matches("^/api/file/\\d+/resource/.+$")) return true; String token = request.getHeader("Authorization"); if (token == null) { log.debug("No Authorization header found"); diff --git a/src/main/java/fr/recia/collabsoft/services/storage/ResourceService.java b/src/main/java/fr/recia/collabsoft/services/storage/ResourceService.java new file mode 100644 index 00000000..c02e3a71 --- /dev/null +++ b/src/main/java/fr/recia/collabsoft/services/storage/ResourceService.java @@ -0,0 +1,87 @@ +/* + * Copyright (C) 2023 GIP-RECIA, Inc. + * + * 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 fr.recia.collabsoft.services.storage; + +import com.google.common.io.Files; +import fr.recia.collabsoft.configuration.CollabsoftProperties; +import fr.recia.collabsoft.configuration.beans.StorageProperties; +import lombok.extern.slf4j.Slf4j; +import org.springframework.core.io.PathResource; +import org.springframework.core.io.Resource; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; + +import java.io.File; +import java.io.IOException; +import java.text.SimpleDateFormat; +import java.util.Date; + +@Slf4j +@Service +public class ResourceService { + + private final StorageProperties storageProperties; + + public ResourceService(CollabsoftProperties collabsoftProperties) { + this.storageProperties = collabsoftProperties.getStorage(); + } + + private String getFilePath(Long fileId, String resourceName) { + return storageProperties.getLocation() + File.separator + fileId + File.separator + resourceName; + } + + public Resource getResource(Long fileId, String resourceName) { + return new PathResource(getFilePath(fileId, resourceName)); + } + + public String saveResource(Long fileId, MultipartFile file, String name) { + if (file.isEmpty()) throw new RuntimeException("File can not be empty"); + try { + final String fileExt = Files.getFileExtension(file.getOriginalFilename()).toLowerCase(); + String fileName = name != null && !name.trim().isEmpty() ? name : new SimpleDateFormat("yyyyMMddHHmmss'." + fileExt + "'").format(new Date()); + File inFile = new File(getFilePath(fileId, fileName)); + if (!inFile.getParentFile().exists()) { + boolean error = !inFile.getParentFile().mkdirs(); + if (error) { + log.error("Can't create directory {} to upload file, track error!", inFile.getParentFile().getPath()); + return null; + } + } + + file.transferTo(inFile); + + return fileName; + } catch (IOException e) { + return null; + } + } + + public boolean deleteResource(Long fileId, String resourceName) { + final String path = getFilePath(fileId, resourceName); + File file = new File(path); + + if (file.exists()) { + final boolean isDeleted = file.delete(); + if (!isDeleted) { + log.error("Tried to delete the file {} failed, track errors!", path); + return false; + } else return true; + } + log.error("Tried to delete the file {} but it doesn't exist, track errors!", path); + return false; + } + +} diff --git a/src/main/java/fr/recia/collabsoft/web/rest/FileController.java b/src/main/java/fr/recia/collabsoft/web/rest/FileController.java index da69d779..8bc23fde 100644 --- a/src/main/java/fr/recia/collabsoft/web/rest/FileController.java +++ b/src/main/java/fr/recia/collabsoft/web/rest/FileController.java @@ -26,10 +26,14 @@ import fr.recia.collabsoft.services.db.FileHistoryService; import fr.recia.collabsoft.services.db.FileService; import fr.recia.collabsoft.services.db.MetadataService; +import fr.recia.collabsoft.services.storage.ResourceService; import lombok.NonNull; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.core.io.InputStreamResource; +import org.springframework.core.io.Resource; import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; @@ -38,9 +42,16 @@ import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; +import javax.annotation.Nullable; +import java.io.IOException; +import java.io.InputStream; +import java.util.HashMap; import java.util.List; +import java.util.Map; @Slf4j @RestController @@ -55,6 +66,8 @@ public class FileController { private FileHistoryService fileHistoryService; @Autowired private MetadataService metadataService; + @Autowired + private ResourceService resourceService; /* * File @@ -156,6 +169,62 @@ public ResponseEntity deleteFile(@PathVariable Long id) { return new ResponseEntity<>(HttpStatus.OK); } + /* + * Resource + */ + + /** + * Save resource + * + * @param id File id + * @param file Resource + */ + @PostMapping(value = "/{id}/resource") + public ResponseEntity postResource(@PathVariable Long id, @RequestParam("file") MultipartFile file, @Nullable @RequestParam("name") String name) { + final String fileName = resourceService.saveResource(id, file, name); + if (fileName == null) return new ResponseEntity<>(HttpStatus.INTERNAL_SERVER_ERROR); + Map data = new HashMap<>(); + data.put("uri", fileName); + + return new ResponseEntity<>(data, HttpStatus.CREATED); + } + + /** + * Get resource + * + * @param id File id + * @param resourceName Resource name + * @return + */ + @GetMapping(value = "/{id}/resource/{resourceName:.+}") + public ResponseEntity getResource(@PathVariable Long id, @PathVariable String resourceName) { + Resource resource = resourceService.getResource(id, resourceName); + try { + MediaType contentType = MediaType.parseMediaType(resource.getFile().toURL().openConnection().getContentType()); + InputStream in = resource.getInputStream(); + + return ResponseEntity.ok() + .contentType(contentType) + .body(new InputStreamResource(in)); + } catch (IOException e) { + return new ResponseEntity<>(HttpStatus.NOT_FOUND); + } + } + + /** + * Delete resource + * + * @param id File id + * @param resourceName Resource name + */ + @DeleteMapping(value = "/{id}/resource/{resourceName:.+}") + public ResponseEntity deleteResource(@PathVariable Long id, @PathVariable String resourceName) { + final boolean isResource = resourceService.deleteResource(id, resourceName); + if (!isResource) return new ResponseEntity<>(HttpStatus.NOT_FOUND); + + return new ResponseEntity<>(HttpStatus.OK); + } + /* * Metadata */ diff --git a/src/main/webapp/src/views/AppView.vue b/src/main/webapp/src/views/AppView.vue index fc904939..45077f5b 100644 --- a/src/main/webapp/src/views/AppView.vue +++ b/src/main/webapp/src/views/AppView.vue @@ -9,6 +9,7 @@ import { storeToRefs } from 'pinia'; import { onMounted, onUnmounted, ref } from 'vue'; import { useI18n } from 'vue-i18n'; import { useRoute, useRouter } from 'vue-router'; +import { useTheme } from 'vuetify'; const configurationStore = useConfigurationStore(); const { loadFile } = configurationStore; @@ -20,6 +21,7 @@ const isDev = import.meta.env.DEV; const route = useRoute(); const router = useRouter(); const { t } = useI18n(); +const theme = useTheme(); if (!currentFile.value || currentFile.value.id != (route.params.fileId as unknown as number)) loadFile(parseInt(route.params.fileId as string)); @@ -89,7 +91,9 @@ onUnmounted(() => { diff --git a/yarn.lock b/yarn.lock index e608e2a2..3b26dd24 100644 --- a/yarn.lock +++ b/yarn.lock @@ -806,14 +806,49 @@ __metadata: languageName: node linkType: hard -"@gip-recia/tldraw-webcomponent@npm:^1.3.0": - version: 1.3.0 - resolution: "@gip-recia/tldraw-webcomponent@npm:1.3.0" +"@gip-recia/tldraw-v1@npm:^1.29.2-recia-1.0.0": + version: 1.29.2-recia-1.0.0 + resolution: "@gip-recia/tldraw-v1@npm:1.29.2-recia-1.0.0" + dependencies: + "@fontsource/caveat-brush": "npm:^4.5.9" + "@fontsource/crimson-pro": "npm:^4.5.10" + "@fontsource/recursive": "npm:^4.5.11" + "@fontsource/source-code-pro": "npm:^4.5.12" + "@fontsource/source-sans-pro": "npm:^4.5.11" + "@radix-ui/react-alert-dialog": "npm:^1.0.0" + "@radix-ui/react-context-menu": "npm:^1.0.0" + "@radix-ui/react-dialog": "npm:^1.0.0" + "@radix-ui/react-dropdown-menu": "npm:^1.0.0" + "@radix-ui/react-icons": "npm:^1.1.1" + "@radix-ui/react-popover": "npm:^1.0.0" + "@radix-ui/react-tooltip": "npm:^1.0.0" + "@stitches/react": "npm:^1.2.8" + "@tldraw/core": "npm:^1.23.2" + "@tldraw/intersect": "npm:^1.9.2" + "@tldraw/vec": "npm:^1.9.2" + browser-fs-access: "npm:^0.31.0" + idb-keyval: "npm:^6.2.0" + perfect-freehand: "npm:^1.2.0" + react-error-boundary: "npm:^3.1.4" + react-hotkeys-hook: "npm:^3.4.7" + react-intl: "npm:^6.1.1" + tslib: "npm:^2.4.0" + zustand: "npm:^4.1.1" + peerDependencies: + react: ">=16.8" + react-dom: ">=16.8" + checksum: dd104b6e1f086d1cac9d00ac4bd0180d162ad4681f7857d040379776997425d6078c0fedda53766873df350af1cfbad2e308a0d93c17cab42853a561564f8058 + languageName: node + linkType: hard + +"@gip-recia/tldraw-webcomponent@npm:^1.5.1": + version: 1.5.1 + resolution: "@gip-recia/tldraw-webcomponent@npm:1.5.1" dependencies: - "@tldraw/tldraw": "npm:^1.29.2" + "@gip-recia/tldraw-v1": "npm:^1.29.2-recia-1.0.0" react: "npm:^18.2.0" react-dom: "npm:^18.2.0" - checksum: ac381bacf11d3c107c9faafd4637f34a27d2824d4c827899c971d6dc7c396f436c06cb0c9ceeb146fd4cd61a8a5da56d10f5a0f19d222c77f4fb96e0462e8801 + checksum: 9d84b34ca9bd7604cc9de751711861f376f59957a31efd9f1bdfdbc31ffdc1418987360e857345a4376b71d3e8267128b3a0e0978aef40961358ba8be2797a3f languageName: node linkType: hard @@ -2093,41 +2128,6 @@ __metadata: languageName: node linkType: hard -"@tldraw/tldraw@npm:^1.29.2": - version: 1.29.2 - resolution: "@tldraw/tldraw@npm:1.29.2" - dependencies: - "@fontsource/caveat-brush": "npm:^4.5.9" - "@fontsource/crimson-pro": "npm:^4.5.10" - "@fontsource/recursive": "npm:^4.5.11" - "@fontsource/source-code-pro": "npm:^4.5.12" - "@fontsource/source-sans-pro": "npm:^4.5.11" - "@radix-ui/react-alert-dialog": "npm:^1.0.0" - "@radix-ui/react-context-menu": "npm:^1.0.0" - "@radix-ui/react-dialog": "npm:^1.0.0" - "@radix-ui/react-dropdown-menu": "npm:^1.0.0" - "@radix-ui/react-icons": "npm:^1.1.1" - "@radix-ui/react-popover": "npm:^1.0.0" - "@radix-ui/react-tooltip": "npm:^1.0.0" - "@stitches/react": "npm:^1.2.8" - "@tldraw/core": "npm:^1.23.2" - "@tldraw/intersect": "npm:^1.9.2" - "@tldraw/vec": "npm:^1.9.2" - browser-fs-access: "npm:^0.31.0" - idb-keyval: "npm:^6.2.0" - perfect-freehand: "npm:^1.2.0" - react-error-boundary: "npm:^3.1.4" - react-hotkeys-hook: "npm:^3.4.7" - react-intl: "npm:^6.1.1" - tslib: "npm:^2.4.0" - zustand: "npm:^4.1.1" - peerDependencies: - react: ">=16.8" - react-dom: ">=16.8" - checksum: 521128651d6adda779749cbeff0c01246a27b5d779a00b596fd2b48da9b23eb5d430e41964605d377a08818525d0c5cf89fb51244aa3e94241143a5a8ae403ff - languageName: node - linkType: hard - "@tldraw/vec@npm:^1.9.2": version: 1.9.2 resolution: "@tldraw/vec@npm:1.9.2" @@ -3374,7 +3374,7 @@ __metadata: "@fortawesome/free-regular-svg-icons": "npm:^6.5.1" "@fortawesome/free-solid-svg-icons": "npm:^6.5.1" "@fortawesome/vue-fontawesome": "npm:^3.0.5" - "@gip-recia/tldraw-webcomponent": "npm:^1.3.0" + "@gip-recia/tldraw-webcomponent": "npm:^1.5.1" "@intlify/unplugin-vue-i18n": "npm:^2.0.0" "@playwright/test": "npm:^1.41.1" "@rushstack/eslint-patch": "npm:^1.7.0"