feat(assets): add a read-only WebDAV share as a media source - #355
obelix58143 wants to merge 7 commits into
Conversation
Teams that keep their photos and videos on a WebDAV share - Nextcloud, ownCloud or anything else speaking the protocol - currently have to download a file and upload it again to post it. The service browses one directory level at a time and streams a single file onto disk. It is read-only: there is no PUT, MKCOL or DELETE anywhere in it. Paths coming back from the client are normalised rather than rejected, so `..`, a leading slash or a backslash land on the root of the share instead of somewhere else on the server. Size is settled before the body is fetched: the share is asked over HEAD, and `WEBDAV_MAX_IMPORT_MB` decides. What actually landed on disk is checked afterwards as well, because a HEAD answer is the share's word rather than a guarantee - and because the body is streamed, neither path asks the process to hold a file whole. The endpoint is configured by the operator, the way S3 or R2 are, which is why it may point at a host on the local network that the SSRF guard would refuse for a user-supplied URL. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Both endpoints sit behind the same `createPost` check the rest of the asset routes use, and both 404 while no share is configured, so an instance without one behaves as if the feature did not exist. Importing copies each file into the workspace's own library instead of linking to it, so a post keeps the media it was scheduled with even after the share changes. A share holds spreadsheets and archives too: a file the editor cannot use is reported as a refusal, and one bad pick does not discard the rest of a selection - what came through is returned alongside the names that did not. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The tab carries the operator's label, lists folders before files, walks down into a folder and back up to the configured root, and imports a multi-file selection in one request. It only appears once a share is configured. When part of a selection is refused, the tab names the files that did not make it rather than reporting the whole import as failed. Every string is translated into all sixteen locales, so an instance running in one language does not fall back to English mid-picker. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Twenty cases: listing and its ordering, the way back up, both endpoints while no share is configured, signing in, a successful import, selection limits, a refused file type, partial and total failure of a selection, an oversized file refused on the announced size before its body is fetched, a share that understates that size and is caught by what landed on disk, and five paths that try to climb out of the share - each asserted against the URL that actually left the server, not just the response. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
# Conflicts: # resources/js/components/assets/GalleryBrowser.vue
Building the URL by concatenation dropped the slash after the configured root, so `WEBDAV_ROOT=Marketing` plus `plakat.jpg` asked the share for `.../Marketingplakat.jpg`. Browsing the root still worked, which is what made it look fine: only paths with a segment after the root were wrong, so listing succeeded and every download 404'd. Found by pointing the feature at a real Nextcloud rather than a fake. The test that should have caught it asserted `str_contains($url, '... /Marketing')`, which a URL missing the separator still satisfies. It now compares the whole URL, and seven cases cover a file, a folder, a nested path, a nested root, no root at all and a name that needs encoding - each verified by restoring the bug and watching them turn red. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Pushed a fix for a bug I found after opening this. Building the URL by concatenation dropped the slash after a configured 27 tests on the feature now, full suite matching |
The first entry of a PROPFIND is the collection itself, recognised by comparing its href with the URL we asked for. The server sends hrefs decoded while the URL we built is percent-encoded, so the two only matched for names that need no encoding. A folder called "Bilder 2026" therefore appeared inside itself - and an empty one looked like it held a single item. Found by pointing the feature at a real Nextcloud with the names a club actually uses. Also pins down the characters that end a path early if they reach the URL unencoded: an ampersand, a hash, a question mark and a plus. Those were already handled - the cases are there so they stay that way. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Out of draft — field-tested against a real Nextcloud on a live instance, not just the fake. Two bugs found that way, both fixed in this PR:
Both were verified by restoring the bug and watching the new tests turn red. Verified end-to-end through the HTTP endpoints against a real share: browsing and folder navigation, a folder with a space not containing itself, an empty folder coming back empty, importing real files into the library, a spreadsheet refused while the photos in the same selection went through, and a traversal attempt staying inside the share. Filenames exercised included QA made by humen in the browser: multi-select imported instantly, and a mixed selection reported "2 files could not be imported: budget.csv, grosse-datei.bin" while keeping the rest. 33 feature tests. Full suite matches |
Adds a read-only WebDAV share as a media source in the asset picker, so a team
whose photos and videos already live in Nextcloud, ownCloud or any other WebDAV
server can pick them straight from the editor instead of downloading a file and
uploading it again.
Off by default: without
WEBDAV_URLthe tab does not render and both endpointsreturn 404, so nothing changes for an instance that does not configure it.
How it works
The tab lists one directory level at a time, folders before files, and walks
down into a folder and back up to the configured root. A selection is imported
in one request and copied into the workspace's own library rather than
linked, so a post keeps the media it was scheduled with even if the share
changes afterwards.
Security
PUT,MKCOL,MOVEorDELETEexists in theservice, only
PROPFIND,HEADandGET.., a leading slash andbackslashes are dropped, so a mangled path lands on the root of the share
instead of somewhere else on the server
createPostauthorization the rest of theasset routes use
guard is not in the way of a share on the local network
HEADbefore the body is fetched, and the download isstreamed onto disk, so neither the refusal nor the import asks the process to
hold a file whole. What actually landed is checked too: a
HEADanswer is theshare's word, not a guarantee
Five path-traversal attempts are asserted against the URL that actually left
the server, not just the response code, so a future refactor cannot make them
pass while the request escapes.
Partial failures
A share holds spreadsheets and archives next to the photos. Picking one by
accident would otherwise cost the whole selection; instead, what imported is
returned alongside the names that did not, and the picker says which ones
failed. A selection in which nothing survives is still an error.
Worth knowing before merging
The credentials are configured per instance, not per workspace — everyone
who can open the media picker browses the same share. That fits a self-hosted
instance run by one team, which is what this is for, and it does not affect the
hosted product, where the tab stays hidden while no share is configured. Making
it per-workspace would mean a credentials UI and encrypted per-workspace
storage; happy to follow up with that if you'd rather have it that way.
20 tests, Pint clean, full suite green. Documentation: trypostit/trypost-docs#25
🤖Docs and Tests and some code Generated with Claude Code