Skip to content

Commit 1a42914

Browse files
WebDAV: honour X-Forwarded-Prefix so hrefs work behind a proxy
PROPFIND emitted /dav/… hrefs, but behind nginx (which strips /api) the client requests /api/dav/… — so it could list the root but navigating into folders hit /dav/… (unproxied) and 404'd, breaking the mount. hrefs now include the public prefix from X-Forwarded-Prefix (defaulting to /dav when absent). Docs add the nginx 'proxy_set_header X-Forwarded-Prefix /api'.
1 parent 000611f commit 1a42914

3 files changed

Lines changed: 36 additions & 16 deletions

File tree

apps/api/src/routes/webdav.ts

Lines changed: 22 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -27,11 +27,12 @@ export function escapeXml(s: string): string {
2727
return s.replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&apos;' })[c]!);
2828
}
2929

30-
/** Absolute href for a path, with a trailing slash for collections. */
31-
export function hrefFor(segments: string[], isCollection: boolean): string {
30+
/** Absolute href for a path, with a trailing slash for collections. `base` is the public path to
31+
* the WebDAV root (e.g. "/dav", or "/api/dav" behind a proxy), so links work through nginx. */
32+
export function hrefFor(base: string, segments: string[], isCollection: boolean): string {
3233
const encoded = segments.map((s) => encodeURIComponent(s)).join('/');
33-
const base = encoded ? `${PREFIX}/${encoded}` : `${PREFIX}`;
34-
return isCollection ? `${base}/` : base;
34+
const full = encoded ? `${base}/${encoded}` : base;
35+
return isCollection ? `${full}/` : full;
3536
}
3637

3738
/** Split a WebDAV request path (the part after /dav) into decoded, non-empty segments. */
@@ -48,6 +49,13 @@ export function pathSegments(star: string | undefined): string[] {
4849
});
4950
}
5051

52+
/** Public path to the WebDAV root, honouring a reverse-proxy prefix (X-Forwarded-Prefix) so the
53+
* hrefs we emit match the URL the client actually used (e.g. /api/dav behind nginx). */
54+
function davBase(req: FastifyRequest): string {
55+
const fwd = String(req.headers['x-forwarded-prefix'] ?? '').trim().replace(/\/+$/, '');
56+
return `${fwd}${PREFIX}`;
57+
}
58+
5159
type Resolved =
5260
| { type: 'root' }
5361
| { type: 'folder'; folder: Folder; parentId: string | null }
@@ -76,12 +84,12 @@ async function resolvePath(ownerId: string, segments: string[]): Promise<Resolve
7684
return { type: 'missing', parentId, name: last };
7785
}
7886

79-
function propXml(segments: string[], isCollection: boolean, name: string, size: number, mtime: Date, mime: string): string {
87+
function propXml(base: string, segments: string[], isCollection: boolean, name: string, size: number, mtime: Date, mime: string): string {
8088
const props = isCollection
8189
? `<D:resourcetype><D:collection/></D:resourcetype>`
8290
: `<D:resourcetype/><D:getcontentlength>${size}</D:getcontentlength><D:getcontenttype>${escapeXml(mime)}</D:getcontenttype>`;
8391
return (
84-
`<D:response><D:href>${escapeXml(hrefFor(segments, isCollection))}</D:href>` +
92+
`<D:response><D:href>${escapeXml(hrefFor(base, segments, isCollection))}</D:href>` +
8593
`<D:propstat><D:prop>` +
8694
`<D:displayname>${escapeXml(name)}</D:displayname>` +
8795
`<D:getlastmodified>${mtime.toUTCString()}</D:getlastmodified>` +
@@ -153,16 +161,18 @@ export const webdavRoutes: FastifyPluginAsync = async (app) => {
153161

154162
const node = await resolvePath(ownerId, segments);
155163

164+
const base = davBase(req);
165+
156166
if (method === 'PROPFIND') {
157167
if (node.type === 'invalid' || node.type === 'missing') return reply.code(404).send();
158168
const depth = String(req.headers.depth ?? '1');
159169
const parts: string[] = [];
160170
if (node.type === 'root') {
161-
parts.push(propXml([], true, 'OpenCoperLock', 0, new Date(), ''));
171+
parts.push(propXml(base, [], true, 'OpenCoperLock', 0, new Date(), ''));
162172
} else if (node.type === 'folder') {
163-
parts.push(propXml(segments, true, node.folder.name, 0, node.folder.createdAt, ''));
173+
parts.push(propXml(base, segments, true, node.folder.name, 0, node.folder.createdAt, ''));
164174
} else {
165-
parts.push(propXml(segments, false, node.file.name, Number(node.file.sizeBytes), node.file.createdAt, node.file.mimeType));
175+
parts.push(propXml(base, segments, false, node.file.name, Number(node.file.sizeBytes), node.file.createdAt, node.file.mimeType));
166176
}
167177
if (depth !== '0' && node.type !== 'file') {
168178
const parentId = node.type === 'root' ? null : node.folder.id;
@@ -173,8 +183,8 @@ export const webdavRoutes: FastifyPluginAsync = async (app) => {
173183
orderBy: { name: 'asc' },
174184
}),
175185
]);
176-
for (const f of folders) parts.push(propXml([...segments, f.name], true, f.name, 0, f.createdAt, ''));
177-
for (const f of files) parts.push(propXml([...segments, f.name], false, f.name, Number(f.sizeBytes), f.createdAt, f.mimeType));
186+
for (const f of folders) parts.push(propXml(base, [...segments, f.name], true, f.name, 0, f.createdAt, ''));
187+
for (const f of files) parts.push(propXml(base, [...segments, f.name], false, f.name, Number(f.sizeBytes), f.createdAt, f.mimeType));
178188
}
179189
return reply
180190
.header('Content-Type', 'application/xml; charset=utf-8')
@@ -267,7 +277,7 @@ export const webdavRoutes: FastifyPluginAsync = async (app) => {
267277
.code(207)
268278
.send(
269279
`<?xml version="1.0" encoding="utf-8"?>\n<D:multistatus xmlns:D="DAV:"><D:response>` +
270-
`<D:href>${escapeXml(hrefFor(segments, node.type === 'folder' || node.type === 'root'))}</D:href>` +
280+
`<D:href>${escapeXml(hrefFor(base, segments, node.type === 'folder' || node.type === 'root'))}</D:href>` +
271281
`<D:propstat><D:status>HTTP/1.1 200 OK</D:status></D:propstat></D:response></D:multistatus>`,
272282
);
273283
}

apps/api/test/webdav.test.ts

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,15 @@ describe('webdav helpers', () => {
99
});
1010

1111
it('builds hrefs with a trailing slash for collections and encoding', () => {
12-
expect(hrefFor([], true)).toBe('/dav/');
13-
expect(hrefFor(['Photos'], true)).toBe('/dav/Photos/');
14-
expect(hrefFor(['a', 'b.txt'], false)).toBe('/dav/a/b.txt');
15-
expect(hrefFor(['my folder'], true)).toBe('/dav/my%20folder/');
12+
expect(hrefFor('/dav', [], true)).toBe('/dav/');
13+
expect(hrefFor('/dav', ['Photos'], true)).toBe('/dav/Photos/');
14+
expect(hrefFor('/dav', ['a', 'b.txt'], false)).toBe('/dav/a/b.txt');
15+
expect(hrefFor('/dav', ['my folder'], true)).toBe('/dav/my%20folder/');
16+
});
17+
18+
it('honours a reverse-proxy prefix so links work through nginx', () => {
19+
expect(hrefFor('/api/dav', ['Photos'], true)).toBe('/api/dav/Photos/');
20+
expect(hrefFor('/api/dav', [], true)).toBe('/api/dav/');
1621
});
1722

1823
it('splits and decodes path segments', () => {

docs/API.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,7 @@ location /api/ {
102102
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
103103
proxy_set_header X-Forwarded-Proto $scheme;
104104
proxy_set_header Authorization $http_authorization; # forward Basic creds
105+
proxy_set_header X-Forwarded-Prefix /api; # so WebDAV hrefs keep the /api prefix
105106
proxy_pass_request_headers on;
106107
client_max_body_size 0; # no upload size cap at the proxy
107108
proxy_request_buffering off; # stream PUT bodies straight through
@@ -113,6 +114,10 @@ location /api/ {
113114
The WebDAV URL is then `https://<host>/api/dav/` (exactly what the account page shows — copy it
114115
from there rather than typing it).
115116

117+
> The `X-Forwarded-Prefix` header is important: without it the server emits `/dav/…` hrefs, and
118+
> clients that strip `/api` can list the root but fail to open folders. With it, hrefs become
119+
> `/api/dav/…` and navigation works.
120+
116121
### Diagnosing a mount that won't connect
117122

118123
First prove the server side works, independent of any OS client:

0 commit comments

Comments
 (0)