Skip to content

Commit 3234f4b

Browse files
feat: file upload with tldraw
1 parent 8ce05a1 commit 3234f4b

File tree

8 files changed

+238
-43
lines changed

8 files changed

+238
-43
lines changed

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@
2323
"release:major": "standard-version --release-as major"
2424
},
2525
"dependencies": {
26-
"@gip-recia/tldraw-webcomponent": "^1.3.0",
26+
"@gip-recia/tldraw-webcomponent": "^1.5.1",
2727
"@vueuse/core": "^10.7.2",
2828
"pinia": "^2.1.7",
2929
"vue": "^3.4.15",

src/main/java/fr/recia/collabsoft/configuration/CollabsoftProperties.java

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
import fr.recia.collabsoft.configuration.beans.CorsProperties;
2020
import fr.recia.collabsoft.configuration.beans.SecurityProperties;
2121
import fr.recia.collabsoft.configuration.beans.SoffitProperties;
22+
import fr.recia.collabsoft.configuration.beans.StorageProperties;
2223
import lombok.Data;
2324
import lombok.extern.slf4j.Slf4j;
2425
import org.springframework.boot.context.properties.ConfigurationProperties;
@@ -40,6 +41,7 @@ public class CollabsoftProperties {
4041
private CorsProperties cors = new CorsProperties();
4142
private SecurityProperties security = new SecurityProperties();
4243
private SoffitProperties soffit = new SoffitProperties();
44+
private StorageProperties storage = new StorageProperties();
4345

4446
@PostConstruct
4547
private void init() throws JsonProcessingException {
@@ -52,6 +54,7 @@ public String toString() {
5254
+ cors + ",\n"
5355
+ security + ",\n"
5456
+ soffit + "\n"
57+
+ storage
5558
+ "\n}";
5659
}
5760

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
/*
2+
* Copyright (C) 2023 GIP-RECIA, Inc.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package fr.recia.collabsoft.configuration.beans;
17+
18+
import lombok.Data;
19+
20+
@Data
21+
public class StorageProperties {
22+
23+
private String location;
24+
25+
@Override
26+
public String toString() {
27+
return "\"StorageProperties\": {" +
28+
"\n\t\"location\": \"" + location + "\"" +
29+
"\n}";
30+
}
31+
32+
}

src/main/java/fr/recia/collabsoft/interceptors/SoffitInterceptor.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ public SoffitInterceptor(SoffitHolder soffitHolder) {
4040
@Override
4141
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
4242
String path = request.getRequestURI().substring(request.getContextPath().length());
43-
if (!path.startsWith("/api/file")) return true;
43+
if (!path.startsWith("/api/file") || path.matches("^/api/file/\\d+/resource/.+$")) return true;
4444
String token = request.getHeader("Authorization");
4545
if (token == null) {
4646
log.debug("No Authorization header found");
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
/*
2+
* Copyright (C) 2023 GIP-RECIA, Inc.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package fr.recia.collabsoft.services.storage;
17+
18+
import com.google.common.io.Files;
19+
import fr.recia.collabsoft.configuration.CollabsoftProperties;
20+
import fr.recia.collabsoft.configuration.beans.StorageProperties;
21+
import lombok.extern.slf4j.Slf4j;
22+
import org.springframework.core.io.PathResource;
23+
import org.springframework.core.io.Resource;
24+
import org.springframework.stereotype.Service;
25+
import org.springframework.web.multipart.MultipartFile;
26+
27+
import java.io.File;
28+
import java.io.IOException;
29+
import java.text.SimpleDateFormat;
30+
import java.util.Date;
31+
32+
@Slf4j
33+
@Service
34+
public class ResourceService {
35+
36+
private final StorageProperties storageProperties;
37+
38+
public ResourceService(CollabsoftProperties collabsoftProperties) {
39+
this.storageProperties = collabsoftProperties.getStorage();
40+
}
41+
42+
private String getFilePath(Long fileId, String resourceName) {
43+
return storageProperties.getLocation() + File.separator + fileId + File.separator + resourceName;
44+
}
45+
46+
public Resource getResource(Long fileId, String resourceName) {
47+
return new PathResource(getFilePath(fileId, resourceName));
48+
}
49+
50+
public String saveResource(Long fileId, MultipartFile file, String name) {
51+
if (file.isEmpty()) throw new RuntimeException("File can not be empty");
52+
try {
53+
final String fileExt = Files.getFileExtension(file.getOriginalFilename()).toLowerCase();
54+
String fileName = name != null && !name.trim().isEmpty() ? name : new SimpleDateFormat("yyyyMMddHHmmss'." + fileExt + "'").format(new Date());
55+
File inFile = new File(getFilePath(fileId, fileName));
56+
if (!inFile.getParentFile().exists()) {
57+
boolean error = !inFile.getParentFile().mkdirs();
58+
if (error) {
59+
log.error("Can't create directory {} to upload file, track error!", inFile.getParentFile().getPath());
60+
return null;
61+
}
62+
}
63+
64+
file.transferTo(inFile);
65+
66+
return fileName;
67+
} catch (IOException e) {
68+
return null;
69+
}
70+
}
71+
72+
public boolean deleteResource(Long fileId, String resourceName) {
73+
final String path = getFilePath(fileId, resourceName);
74+
File file = new File(path);
75+
76+
if (file.exists()) {
77+
final boolean isDeleted = file.delete();
78+
if (!isDeleted) {
79+
log.error("Tried to delete the file {} failed, track errors!", path);
80+
return false;
81+
} else return true;
82+
}
83+
log.error("Tried to delete the file {} but it doesn't exist, track errors!", path);
84+
return false;
85+
}
86+
87+
}

src/main/java/fr/recia/collabsoft/web/rest/FileController.java

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,10 +26,14 @@
2626
import fr.recia.collabsoft.services.db.FileHistoryService;
2727
import fr.recia.collabsoft.services.db.FileService;
2828
import fr.recia.collabsoft.services.db.MetadataService;
29+
import fr.recia.collabsoft.services.storage.ResourceService;
2930
import lombok.NonNull;
3031
import lombok.extern.slf4j.Slf4j;
3132
import org.springframework.beans.factory.annotation.Autowired;
33+
import org.springframework.core.io.InputStreamResource;
34+
import org.springframework.core.io.Resource;
3235
import org.springframework.http.HttpStatus;
36+
import org.springframework.http.MediaType;
3337
import org.springframework.http.ResponseEntity;
3438
import org.springframework.web.bind.annotation.DeleteMapping;
3539
import org.springframework.web.bind.annotation.GetMapping;
@@ -38,9 +42,16 @@
3842
import org.springframework.web.bind.annotation.PutMapping;
3943
import org.springframework.web.bind.annotation.RequestBody;
4044
import org.springframework.web.bind.annotation.RequestMapping;
45+
import org.springframework.web.bind.annotation.RequestParam;
4146
import org.springframework.web.bind.annotation.RestController;
47+
import org.springframework.web.multipart.MultipartFile;
4248

49+
import javax.annotation.Nullable;
50+
import java.io.IOException;
51+
import java.io.InputStream;
52+
import java.util.HashMap;
4353
import java.util.List;
54+
import java.util.Map;
4455

4556
@Slf4j
4657
@RestController
@@ -55,6 +66,8 @@ public class FileController {
5566
private FileHistoryService fileHistoryService;
5667
@Autowired
5768
private MetadataService metadataService;
69+
@Autowired
70+
private ResourceService resourceService;
5871

5972
/*
6073
* File
@@ -156,6 +169,62 @@ public ResponseEntity<Object> deleteFile(@PathVariable Long id) {
156169
return new ResponseEntity<>(HttpStatus.OK);
157170
}
158171

172+
/*
173+
* Resource
174+
*/
175+
176+
/**
177+
* Save resource
178+
*
179+
* @param id File id
180+
* @param file Resource
181+
*/
182+
@PostMapping(value = "/{id}/resource")
183+
public ResponseEntity<Object> postResource(@PathVariable Long id, @RequestParam("file") MultipartFile file, @Nullable @RequestParam("name") String name) {
184+
final String fileName = resourceService.saveResource(id, file, name);
185+
if (fileName == null) return new ResponseEntity<>(HttpStatus.INTERNAL_SERVER_ERROR);
186+
Map<String, Object> data = new HashMap<>();
187+
data.put("uri", fileName);
188+
189+
return new ResponseEntity<>(data, HttpStatus.CREATED);
190+
}
191+
192+
/**
193+
* Get resource
194+
*
195+
* @param id File id
196+
* @param resourceName Resource name
197+
* @return
198+
*/
199+
@GetMapping(value = "/{id}/resource/{resourceName:.+}")
200+
public ResponseEntity<InputStreamResource> getResource(@PathVariable Long id, @PathVariable String resourceName) {
201+
Resource resource = resourceService.getResource(id, resourceName);
202+
try {
203+
MediaType contentType = MediaType.parseMediaType(resource.getFile().toURL().openConnection().getContentType());
204+
InputStream in = resource.getInputStream();
205+
206+
return ResponseEntity.ok()
207+
.contentType(contentType)
208+
.body(new InputStreamResource(in));
209+
} catch (IOException e) {
210+
return new ResponseEntity<>(HttpStatus.NOT_FOUND);
211+
}
212+
}
213+
214+
/**
215+
* Delete resource
216+
*
217+
* @param id File id
218+
* @param resourceName Resource name
219+
*/
220+
@DeleteMapping(value = "/{id}/resource/{resourceName:.+}")
221+
public ResponseEntity<Object> deleteResource(@PathVariable Long id, @PathVariable String resourceName) {
222+
final boolean isResource = resourceService.deleteResource(id, resourceName);
223+
if (!isResource) return new ResponseEntity<>(HttpStatus.NOT_FOUND);
224+
225+
return new ResponseEntity<>(HttpStatus.OK);
226+
}
227+
159228
/*
160229
* Metadata
161230
*/

src/main/webapp/src/views/AppView.vue

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { storeToRefs } from 'pinia';
99
import { onMounted, onUnmounted, ref } from 'vue';
1010
import { useI18n } from 'vue-i18n';
1111
import { useRoute, useRouter } from 'vue-router';
12+
import { useTheme } from 'vuetify';
1213
1314
const configurationStore = useConfigurationStore();
1415
const { loadFile } = configurationStore;
@@ -20,6 +21,7 @@ const isDev = import.meta.env.DEV;
2021
const route = useRoute();
2122
const router = useRouter();
2223
const { t } = useI18n();
24+
const theme = useTheme();
2325
2426
if (!currentFile.value || currentFile.value.id != (route.params.fileId as unknown as number))
2527
loadFile(parseInt(route.params.fileId as string));
@@ -89,7 +91,9 @@ onUnmounted(() => {
8991
<tldraw-editor
9092
v-if="currentFile.associatedApp.slug == AppSlug.tldraw"
9193
:persistance-api-url="`${VITE_API_URI}/api/file/${currentFile.id}`"
94+
:assets-api-url="`${VITE_API_URI}/api/file/${currentFile.id}/resource`"
9295
:user-info-api-url="VITE_USER_INFO_API_URI"
96+
:dark-mode="theme.global.name.value == 'dark'"
9397
/>
9498
</div>
9599
</v-main>

yarn.lock

Lines changed: 41 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -806,14 +806,49 @@ __metadata:
806806
languageName: node
807807
linkType: hard
808808

809-
"@gip-recia/tldraw-webcomponent@npm:^1.3.0":
810-
version: 1.3.0
811-
resolution: "@gip-recia/tldraw-webcomponent@npm:1.3.0"
809+
"@gip-recia/tldraw-v1@npm:^1.29.2-recia-1.0.0":
810+
version: 1.29.2-recia-1.0.0
811+
resolution: "@gip-recia/tldraw-v1@npm:1.29.2-recia-1.0.0"
812+
dependencies:
813+
"@fontsource/caveat-brush": "npm:^4.5.9"
814+
"@fontsource/crimson-pro": "npm:^4.5.10"
815+
"@fontsource/recursive": "npm:^4.5.11"
816+
"@fontsource/source-code-pro": "npm:^4.5.12"
817+
"@fontsource/source-sans-pro": "npm:^4.5.11"
818+
"@radix-ui/react-alert-dialog": "npm:^1.0.0"
819+
"@radix-ui/react-context-menu": "npm:^1.0.0"
820+
"@radix-ui/react-dialog": "npm:^1.0.0"
821+
"@radix-ui/react-dropdown-menu": "npm:^1.0.0"
822+
"@radix-ui/react-icons": "npm:^1.1.1"
823+
"@radix-ui/react-popover": "npm:^1.0.0"
824+
"@radix-ui/react-tooltip": "npm:^1.0.0"
825+
"@stitches/react": "npm:^1.2.8"
826+
"@tldraw/core": "npm:^1.23.2"
827+
"@tldraw/intersect": "npm:^1.9.2"
828+
"@tldraw/vec": "npm:^1.9.2"
829+
browser-fs-access: "npm:^0.31.0"
830+
idb-keyval: "npm:^6.2.0"
831+
perfect-freehand: "npm:^1.2.0"
832+
react-error-boundary: "npm:^3.1.4"
833+
react-hotkeys-hook: "npm:^3.4.7"
834+
react-intl: "npm:^6.1.1"
835+
tslib: "npm:^2.4.0"
836+
zustand: "npm:^4.1.1"
837+
peerDependencies:
838+
react: ">=16.8"
839+
react-dom: ">=16.8"
840+
checksum: dd104b6e1f086d1cac9d00ac4bd0180d162ad4681f7857d040379776997425d6078c0fedda53766873df350af1cfbad2e308a0d93c17cab42853a561564f8058
841+
languageName: node
842+
linkType: hard
843+
844+
"@gip-recia/tldraw-webcomponent@npm:^1.5.1":
845+
version: 1.5.1
846+
resolution: "@gip-recia/tldraw-webcomponent@npm:1.5.1"
812847
dependencies:
813-
"@tldraw/tldraw": "npm:^1.29.2"
848+
"@gip-recia/tldraw-v1": "npm:^1.29.2-recia-1.0.0"
814849
react: "npm:^18.2.0"
815850
react-dom: "npm:^18.2.0"
816-
checksum: ac381bacf11d3c107c9faafd4637f34a27d2824d4c827899c971d6dc7c396f436c06cb0c9ceeb146fd4cd61a8a5da56d10f5a0f19d222c77f4fb96e0462e8801
851+
checksum: 9d84b34ca9bd7604cc9de751711861f376f59957a31efd9f1bdfdbc31ffdc1418987360e857345a4376b71d3e8267128b3a0e0978aef40961358ba8be2797a3f
817852
languageName: node
818853
linkType: hard
819854

@@ -2093,41 +2128,6 @@ __metadata:
20932128
languageName: node
20942129
linkType: hard
20952130

2096-
"@tldraw/tldraw@npm:^1.29.2":
2097-
version: 1.29.2
2098-
resolution: "@tldraw/tldraw@npm:1.29.2"
2099-
dependencies:
2100-
"@fontsource/caveat-brush": "npm:^4.5.9"
2101-
"@fontsource/crimson-pro": "npm:^4.5.10"
2102-
"@fontsource/recursive": "npm:^4.5.11"
2103-
"@fontsource/source-code-pro": "npm:^4.5.12"
2104-
"@fontsource/source-sans-pro": "npm:^4.5.11"
2105-
"@radix-ui/react-alert-dialog": "npm:^1.0.0"
2106-
"@radix-ui/react-context-menu": "npm:^1.0.0"
2107-
"@radix-ui/react-dialog": "npm:^1.0.0"
2108-
"@radix-ui/react-dropdown-menu": "npm:^1.0.0"
2109-
"@radix-ui/react-icons": "npm:^1.1.1"
2110-
"@radix-ui/react-popover": "npm:^1.0.0"
2111-
"@radix-ui/react-tooltip": "npm:^1.0.0"
2112-
"@stitches/react": "npm:^1.2.8"
2113-
"@tldraw/core": "npm:^1.23.2"
2114-
"@tldraw/intersect": "npm:^1.9.2"
2115-
"@tldraw/vec": "npm:^1.9.2"
2116-
browser-fs-access: "npm:^0.31.0"
2117-
idb-keyval: "npm:^6.2.0"
2118-
perfect-freehand: "npm:^1.2.0"
2119-
react-error-boundary: "npm:^3.1.4"
2120-
react-hotkeys-hook: "npm:^3.4.7"
2121-
react-intl: "npm:^6.1.1"
2122-
tslib: "npm:^2.4.0"
2123-
zustand: "npm:^4.1.1"
2124-
peerDependencies:
2125-
react: ">=16.8"
2126-
react-dom: ">=16.8"
2127-
checksum: 521128651d6adda779749cbeff0c01246a27b5d779a00b596fd2b48da9b23eb5d430e41964605d377a08818525d0c5cf89fb51244aa3e94241143a5a8ae403ff
2128-
languageName: node
2129-
linkType: hard
2130-
21312131
"@tldraw/vec@npm:^1.9.2":
21322132
version: 1.9.2
21332133
resolution: "@tldraw/vec@npm:1.9.2"
@@ -3374,7 +3374,7 @@ __metadata:
33743374
"@fortawesome/free-regular-svg-icons": "npm:^6.5.1"
33753375
"@fortawesome/free-solid-svg-icons": "npm:^6.5.1"
33763376
"@fortawesome/vue-fontawesome": "npm:^3.0.5"
3377-
"@gip-recia/tldraw-webcomponent": "npm:^1.3.0"
3377+
"@gip-recia/tldraw-webcomponent": "npm:^1.5.1"
33783378
"@intlify/unplugin-vue-i18n": "npm:^2.0.0"
33793379
"@playwright/test": "npm:^1.41.1"
33803380
"@rushstack/eslint-patch": "npm:^1.7.0"

0 commit comments

Comments
 (0)