Skip to content

feat(assets): add a read-only WebDAV share as a media source - #355

Open
obelix58143 wants to merge 7 commits into
trypostit:mainfrom
obelix58143:feat/webdav-media-source
Open

obelix58143 wants to merge 7 commits into
trypostit:mainfrom
obelix58143:feat/webdav-media-source

Conversation

@obelix58143

@obelix58143 obelix58143 commented Sep 14, 2026

Copy link
Copy Markdown

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_URL the tab does not render and both endpoints
return 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.

WEBDAV_URL=https://cloud.example.com/remote.php/dav/files/trypost
WEBDAV_USERNAME=trypost
WEBDAV_PASSWORD=
WEBDAV_ROOT=Marketing/Social   # optional: confine browsing to one folder
WEBDAV_LABEL="Files"           # optional: name of the tab
WEBDAV_MAX_IMPORT_MB=512       # optional: largest single file

Security

  • read-only by construction: no PUT, MKCOL, MOVE or DELETE exists in the
    service, only PROPFIND, HEAD and GET
  • client paths are normalised, not trusted: .., a leading slash and
    backslashes are dropped, so a mangled path lands on the root of the share
    instead of somewhere else on the server
  • both endpoints sit behind the same createPost authorization the rest of the
    asset routes use
  • the host is the operator's, never the client's — which is also why the SSRF
    guard is not in the way of a share on the local network
  • size is settled over HEAD before the body is fetched, and the download is
    streamed onto disk, so neither the refusal nor the import asks the process to
    hold a file whole. What actually landed is checked too: a HEAD answer is the
    share's word, not a guarantee
  • at most 20 files per import
  • a file the editor cannot use comes back as a refusal, not a 500

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

obelix58143 and others added 4 commits September 14, 2026 21:24
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>
obelix58143 and others added 2 commits September 14, 2026 23:35
# 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>
@obelix58143

obelix58143 commented Sep 14, 2026

Copy link
Copy Markdown
Author

Pushed a fix for a bug I found after opening this.

Building the URL by concatenation dropped the slash after a configured WEBDAV_ROOT, so root Marketing plus plakat.jpg asked the share for .../Marketingplakat.jpg. Browsing the root still worked (nothing is appended there), which is what made it look fine — so listing succeeded and every download 404'd. Only instances setting WEBDAV_ROOT were affected.

27 tests on the feature now, full suite matching main (the one failing instagram verify case fails on a clean main too).

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>
@obelix58143
obelix58143 marked this pull request as ready for review September 15, 2026 00:45
@obelix58143

obelix58143 commented Sep 15, 2026

Copy link
Copy Markdown
Author

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:

  1. The URL dropped the separator after a configured WEBDAV_ROOT, so listing worked and every download 404'd.
  2. A folder whose name needs percent-encoding listed itself as a child — the href comparison used the encoded form while the server sends hrefs decoded. "Bilder 2026" appeared inside itself; an empty folder looked like it held one item.

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 Bühne 2026.png, Rock & Roll.png, Set #3.png, Was solls?.png and Süß+Sauer.png — the characters that end a path early if they reach the URL unencoded.

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 main (the one failing instagram verify case fails on a clean main too).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant