Move filename and URL decisions to the server. Resolves #153.
authorAdam Dullage <redacted>
Tue, 6 Feb 2024 13:51:34 +0000 (13:51 +0000)
committerAdam Dullage <redacted>
Tue, 6 Feb 2024 13:51:34 +0000 (13:51 +0000)
client/components/NoteViewerEditor.vue
server/attachments/base.py
server/attachments/file_system/file_system.py
server/attachments/models.py [new file with mode: 0644]
server/main.py

index d1a855017ffc3276163d51e76e6a25ec9dab8981..1e1b03139abced6a6fbf7e6d6f80f00b8cb8b997 100644 (file)
@@ -570,34 +570,24 @@ export default {
     },
 
     uploadImageHook(file, callback) {
-      // image.png is the default name given to images copied from the clipboard. To avoid conflicts we'll append a timestamp.
-      if (file.name == "image.png") {
-        const currentDateString = new Date().toISOString().replace(/:/g, "-");
-        file = new File([file], `image-${currentDateString}.png`, {
-          type: file.type,
-        });
-      }
-
-      // If the user has entered an alt text, use it. Otherwise, use the filename.
       const altTextInputValue = document.getElementById(
         "toastuiAltTextInput"
       )?.value;
-      const altText = altTextInputValue ? altTextInputValue : file.name;
 
       // Upload the image then use the callback to insert the URL into the editor
-      this.postAttachment(file).then(function (success) {
-        if (success === true) {
-          callback(`/attachments/${encodeURIComponent(file.name)}`, altText);
+      this.postAttachment(file).then(function (data) {
+        if (data) {
+          // If the user has entered an alt text, use it. Otherwise, use the filename returned by the API.
+          const altText = altTextInputValue ? altTextInputValue : data.filename;
+          callback(data.url, altText);
         }
       });
     },
 
     postAttachment(file) {
-      let parent = this;
-
       if (reservedFilenameCharacters.test(file.name)) {
         this.badFilenameToast("filename");
-        return false;
+        return;
       }
 
       EventBus.$emit("showToast", "success", "Uploading attachment...");
@@ -610,9 +600,9 @@ export default {
             "Content-Type": "multipart/form-data",
           },
         })
-        .then(function () {
+        .then(function (response) {
           EventBus.$emit("showToast", "success", "Attachment uploaded ✓");
-          return true;
+          return response.data;
         })
         .catch(function (error) {
           if (error.response?.status == 409) {
@@ -630,7 +620,7 @@ export default {
               "Failed to upload attachment ✘"
             );
           }
-          return false;
+          return;
         });
     },
 
index fd4864f60cd4739421d7ac340da75349e8f0e55f..8d370ded8d5c1f1b6f022a561af652a834096b6e 100644 (file)
@@ -3,10 +3,12 @@ from abc import ABC, abstractmethod
 from fastapi import UploadFile
 from fastapi.responses import FileResponse
 
+from .models import AttachmentCreateResponse
+
 
 class BaseAttachments(ABC):
     @abstractmethod
-    def create(self, file: UploadFile) -> None:
+    def create(self, file: UploadFile) -> AttachmentCreateResponse:
         """Create a new attachment."""
         pass
 
index c57c99c91cf7a026e1cb3dcbc57f3a6302f94a64..a1af2ce4f10c0e8d161fe92c94cdd3d6eb817055 100644 (file)
@@ -1,5 +1,7 @@
 import os
 import shutil
+import urllib.parse
+from datetime import datetime
 
 from fastapi import UploadFile
 from fastapi.responses import FileResponse
@@ -7,6 +9,7 @@ from fastapi.responses import FileResponse
 from helpers import get_env, is_valid_filename
 
 from ..base import BaseAttachments
+from ..models import AttachmentCreateResponse
 
 
 class FileSystemAttachments(BaseAttachments):
@@ -19,14 +22,17 @@ class FileSystemAttachments(BaseAttachments):
         self.storage_path = os.path.join(self.base_path, "attachments")
         os.makedirs(self.storage_path, exist_ok=True)
 
-    def create(self, file: UploadFile) -> None:
+    def create(self, file: UploadFile) -> AttachmentCreateResponse:
         """Create a new attachment."""
         is_valid_filename(file.filename)
-        filepath = os.path.join(self.storage_path, file.filename)
-        if os.path.exists(filepath):
-            raise FileExistsError(f"'{file.filename}' already exists.")
-        with open(filepath, "wb") as f:
-            shutil.copyfileobj(file.file, f)
+        try:
+            self._save_file(file)
+        except FileExistsError:
+            file.filename = self._datetime_suffix_filename(file.filename)
+            self._save_file(file)
+        return AttachmentCreateResponse(
+            filename=file.filename, url=self._url_for_filename(file.filename)
+        )
 
     def get(self, filename: str) -> FileResponse:
         """Get a specific attachment."""
@@ -35,3 +41,18 @@ class FileSystemAttachments(BaseAttachments):
         if not os.path.isfile(filepath):
             raise FileNotFoundError(f"'{filename}' not found.")
         return FileResponse(filepath)
+
+    def _save_file(self, file: UploadFile):
+        filepath = os.path.join(self.storage_path, file.filename)
+        with open(filepath, "xb") as f:
+            shutil.copyfileobj(file.file, f)
+
+    def _datetime_suffix_filename(self, filename: str) -> str:
+        """Add a timestamp suffix to the filename."""
+        timestamp = datetime.utcnow().strftime("%Y-%m-%dT%H-%M-%SZ")
+        name, ext = os.path.splitext(filename)
+        return f"{name}_{timestamp}{ext}"
+
+    def _url_for_filename(self, filename: str) -> str:
+        """Return the URL for the given filename."""
+        return f"/attachments/{urllib.parse.quote(filename)}"
diff --git a/server/attachments/models.py b/server/attachments/models.py
new file mode 100644 (file)
index 0000000..2b65ca9
--- /dev/null
@@ -0,0 +1,6 @@
+from helpers import CustomBaseModel
+
+
+class AttachmentCreateResponse(CustomBaseModel):
+    filename: str
+    url: str
index 99a8eff8c701824e1c6d45446ecb0db247120fb2..f751a29ec60faad3015bb033a4524674d10fcea3 100644 (file)
@@ -6,6 +6,7 @@ from fastapi.staticfiles import StaticFiles
 
 import api_messages
 from attachments.base import BaseAttachments
+from attachments.models import AttachmentCreateResponse
 from auth.base import BaseAuth
 from auth.models import Login, Token
 from global_config import AuthType, GlobalConfig, GlobalConfigResponseModel
@@ -203,7 +204,7 @@ if global_config.auth_type != AuthType.READ_ONLY:
     @app.post(
         "/api/attachments",
         dependencies=auth_deps,
-        response_model=None,
+        response_model=AttachmentCreateResponse,
     )
     def post_attachment(file: UploadFile):
         """Upload an attachment."""
git clone https://git.99rst.org/PROJECT