From a4d689182418c76f4e4418a30d160f4746d09ba8 Mon Sep 17 00:00:00 2001 From: Chris Heywood <31988069+cheywood@users.noreply.github.com> Date: Mon, 8 Sep 2025 10:03:57 +1000 Subject: [PATCH] feat: improve attachment storage for API - Attachments are stored in note-specific folder - Attachment filenames are retained This mirrors the behaviour for attachments created in the web UI. --- appinfo/routes.php | 4 +- docs/api/v1.md | 12 ++--- lib/Service/NotesService.php | 90 +++++++++++++++++++++++++++++------- 3 files changed, 82 insertions(+), 24 deletions(-) diff --git a/appinfo/routes.php b/appinfo/routes.php index 769ace2af..7fd2c9c5e 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -200,7 +200,7 @@ 'url' => '/api/{apiVersion}/attachment/{noteid}', 'verb' => 'GET', 'requirements' => [ - 'apiVersion' => '(v1.4)', + 'apiVersion' => '(v1|v1.4)', 'noteid' => '\d+' ], ], @@ -209,7 +209,7 @@ 'url' => '/api/{apiVersion}/attachment/{noteid}', 'verb' => 'POST', 'requirements' => [ - 'apiVersion' => '(v1.4)', + 'apiVersion' => '(v1|v1.4)', 'noteid' => '\d+' ], ], diff --git a/docs/api/v1.md b/docs/api/v1.md index ab43d5ff1..a38b45f62 100644 --- a/docs/api/v1.md +++ b/docs/api/v1.md @@ -15,7 +15,7 @@ In this document, the Notes API major version 1 and all its minor versions are d | **1.1** | Notes 3.4 (May 2020) | Filter "Get all notes" by category | | **1.2** | Notes 4.1 (June 2021) | Preventing lost updates, read-only notes, settings | | **1.3** | Notes 4.5 (August 2022) | Allow custom file suffixes | -| **1.4** | Notes 4.9 (August 2025) | Add external image api | +| **1.4** | Notes 4.9 (August 2025) | Add external image API | @@ -288,7 +288,7 @@ No valid authentication credentials supplied. | Parameter | Type | Description | |:----------|:-----------------------------|:-------------------------------------------| | `id` | integer, required (path) | ID of the note to load the attachment from | -| `path` | string, required (request) | Path or name of the attachment to load. | +| `path` | string, required (request) | Path of the attachment to load | Example: @@ -327,16 +327,16 @@ curl -u "user:password" \ -F "file=@/path/to/image.png" \ "https://yournextcloud.com/index.php/apps/notes/api/v1.4/attachment/" -# The post request will return the filename that was generated: -{"filename":"d8aef2005b4f815fec8ade5388240f2c.png"} +# The post request will return the path where the image is stored: +{"filename":".attachments./image.png"} ``` #### Response ##### 200 OK -- **Body**: Filename in json encoded: +- **Body**: Path in JSON encoded, example: ```js { - "filename": "image.jpg" + "filaname": ".attachments.1234/image.png" } ``` diff --git a/lib/Service/NotesService.php b/lib/Service/NotesService.php index dca19508f..26f42392e 100644 --- a/lib/Service/NotesService.php +++ b/lib/Service/NotesService.php @@ -13,21 +13,26 @@ use OCP\Files\File; use OCP\Files\FileInfo; use OCP\Files\Folder; +use OCP\Files\IFilenameValidator; +use OCP\Files\NotFoundException; use OCP\Files\NotPermittedException; class NotesService { private MetaService $metaService; private SettingsService $settings; private NoteUtil $noteUtil; + private IFilenameValidator $filenameValidator; public function __construct( MetaService $metaService, SettingsService $settings, NoteUtil $noteUtil, + IFilenameValidator $filenameValidator, ) { $this->metaService = $metaService; $this->settings = $settings; $this->noteUtil = $noteUtil; + $this->filenameValidator = $filenameValidator; } public function getAll(string $userId, bool $autoCreateNotesFolder = false) : array { @@ -223,8 +228,8 @@ private static function getFileById(string $customExtension, Folder $folder, int * @NoCSRFRequired * @return \OCP\Files\File */ - public function getAttachment(string $userId, int $noteid, string $path) : File { - $note = $this->get($userId, $noteid); + public function getAttachment(string $userId, int $noteId, string $path) : File { + $note = $this->get($userId, $noteId); $notesFolder = $this->getNotesFolder($userId); $path = str_replace('\\', '/', $path); // change windows style path $p = explode('/', $note->getCategory()); @@ -243,24 +248,24 @@ public function getAttachment(string $userId, int $noteid, string $path) : File /** * @param $userId - * @param $noteid + * @param $noteId * @param $fileDataArray + * + * @return array * @throws NotPermittedException * @throws ImageNotWritableException - * https://github.com/nextcloud/deck/blob/master/lib/Service/AttachmentService.php + * @throws NotFoundException + * @throws InvalidPathException + * https://github.com/nextcloud/text/blob/main/lib/Service/AttachmentService.php */ - public function createImage(string $userId, int $noteid, $fileDataArray) { - $note = $this->get($userId, $noteid); + public function createImage(string $userId, int $noteId, $fileDataArray) : array { + $note = $this->get($userId, $noteId); $notesFolder = $this->getNotesFolder($userId); - $parent = $this->noteUtil->getCategoryFolder($notesFolder, $note->getCategory()); + $parentFolder = $this->noteUtil->getCategoryFolder($notesFolder, $note->getCategory()); - // try to generate long id, if not available on system fall back to a shorter one - try { - $filename = bin2hex(random_bytes(16)); - } catch (\Exception $e) { - $filename = uniqid(); - } - $filename = $filename . '.' . explode('.', $fileDataArray['name'])[1]; + $saveDir = $this->getAttachmentDirectoryForNote($note, $userId); + $fileName = self::getUniqueFileName($saveDir, $fileDataArray['name']); + $this->filenameValidator->validateFilename($fileName); if ($fileDataArray['tmp_name'] === '') { throw new ImageNotWritableException(); @@ -272,8 +277,61 @@ public function createImage(string $userId, int $noteid, $fileDataArray) { fclose($fp); $result = []; - $result['filename'] = $filename; - $this->noteUtil->getRoot()->newFile($parent->getPath() . '/' . $filename, $content); + $result['filename'] = '.attachments.' . $note->getId() . '/' . $fileName; + $saveDir->newFile($fileName, $content); return $result; } + + /** + * Get unique file name in a directory. Add '(n)' suffix. + * + * @param Folder $dir + * @param string $fileName + * + * @return string + */ + public static function getUniqueFileName(Folder $dir, string $fileName) : string { + $extension = pathinfo($fileName, PATHINFO_EXTENSION); + $counter = 1; + $uniqueFileName = $fileName; + if ($extension !== '') { + while ($dir->nodeExists($uniqueFileName)) { + $counter++; + $uniqueFileName = (string)preg_replace('/\.' . $extension . '$/', ' (' . $counter . ').' . $extension, $fileName); + } + } else { + while ($dir->nodeExists($uniqueFileName)) { + $counter++; + $uniqueFileName = (string)preg_replace('/$/', ' (' . $counter . ')', $fileName); + } + } + return $uniqueFileName; + } + + /** + * Get or create file--specific attachment folder + * + * @param Note $note + * @param string $userid + * + * @return Folder + * @throws NotFoundException + * @throws NotPermittedException + * @throws InvalidPathException + */ + private function getAttachmentDirectoryForNote(Note $note, string $userId) : Folder { + $notesFolder = $this->getNotesFolder($userId); + $parentFolder = $this->noteUtil->getCategoryFolder($notesFolder, $note->getCategory()); + + $attachmentFolderName = '.attachments.' . $note->getId(); + if ($parentFolder->nodeExists($attachmentFolderName)) { + $attachmentFolder = $parentFolder->get($attachmentFolderName); + if ($attachmentFolder instanceof Folder) { + return $attachmentFolder; + } + } else { + return $parentFolder->newFolder($attachmentFolderName); + } + throw new NotFoundException('Attachment dir for note ' . $note->getId() . ' was not found or could not be created.'); + } }