fix: webdav connectivity fixes for macos (#3597)

This commit is contained in:
Daniel Salazar
2026-08-17 19:02:55 -07:00
committed by GitHub
parent 9604cc3f23
commit f81b76c1d1
6 changed files with 147 additions and 34 deletions
@@ -1123,6 +1123,54 @@ describe('WebDAVController verbs', () => {
);
});
it('reports timestamps in the present, not the epoch', async () => {
// Entry timestamps are stored as Unix seconds. Handing those to
// `Date` unscaled reads them as milliseconds and dates every file
// to January 1970, which clients sort and sync against.
const { actor, username } = await makeUser();
const path = `/${username}/Documents/dated.txt`;
await putFile(actor, path, 'x');
const captured = await dispatch({
method: 'PROPFIND',
path,
actor,
headers: { depth: '0' },
});
const xml = captured.body as string;
const modified = xml.match(
/<D:getlastmodified>(.*?)<\/D:getlastmodified>/,
)?.[1];
expect(modified).toBeDefined();
const skew = Math.abs(
new Date(modified as string).getTime() - Date.now(),
);
expect(skew).toBeLessThan(5 * 60 * 1000);
});
it('emits the same ETag for an entry over PROPFIND and GET', async () => {
// A client that sees the validator change between the two treats
// the file as modified and re-downloads it on every pass.
const { actor, username } = await makeUser();
const path = `/${username}/Documents/etag-match.txt`;
await putFile(actor, path, 'x');
const propfind = await dispatch({
method: 'PROPFIND',
path,
actor,
headers: { depth: '0' },
});
const propfindEtag = (propfind.body as string).match(
/<D:getetag>(.*?)<\/D:getetag>/,
)?.[1];
const get = await dispatch({ method: 'GET', path, actor });
expect(propfindEtag).toBeDefined();
expect(get.headers['etag']).toBe(propfindEtag);
});
it('omits children at depth 0', async () => {
const { actor, username } = await makeUser();
await putFile(actor, `/${username}/Documents/hidden.txt`, 'x');
@@ -376,15 +376,14 @@ export class WebDAVController extends PuterController {
await this.#assertRead(actor, davPath);
const etag = `"${entry.uuid}-${Math.floor(entry.modified ?? entry.created ?? 0)}"`;
const modified = entry.modified ?? entry.created ?? 0;
const etag = entryEtag(entry.uuid, modified);
const size = entry.size ?? 0;
res.set({
'Accept-Ranges': 'bytes',
'Content-Length': String(size),
'Last-Modified': new Date(
entry.modified ?? entry.created ?? 0,
).toUTCString(),
'Last-Modified': entryDate(modified).toUTCString(),
ETag: etag,
});
@@ -622,13 +621,11 @@ export class WebDAVController extends PuterController {
);
const fe = writeResult.fsEntry;
const etag = `"${fe.uuid}-${Math.floor(fe.modified ?? fe.created ?? 0)}"`;
const modified = fe.modified ?? fe.created ?? 0;
res.status(existing ? 204 : 201)
.set({
ETag: etag,
'Last-Modified': new Date(
fe.modified ?? fe.created ?? 0,
).toUTCString(),
ETag: entryEtag(fe.uuid, modified),
'Last-Modified': entryDate(modified).toUTCString(),
})
.end();
}
@@ -1007,6 +1004,30 @@ function wrapMultistatus(inner: string): string {
return `<?xml version="1.0" encoding="utf-8"?>\n<D:multistatus xmlns:D="DAV:">\n${inner}\n</D:multistatus>`;
}
/**
* FSEntry timestamps are Unix seconds. Entries with nothing stored fall back to
* an ISO string literal, so both forms have to be accepted here.
*/
function toEpochSeconds(ts: number | string): number {
return typeof ts === 'number'
? Math.floor(ts)
: Math.floor(new Date(ts).getTime() / 1000);
}
/** `Date` takes milliseconds, so entry seconds must be scaled to format them. */
function entryDate(ts: number | string): Date {
return new Date(toEpochSeconds(ts) * 1000);
}
/**
* Opaque validator. Built from seconds so every verb emits the same ETag for an
* entry — a client that gets one value from PROPFIND and another from GET
* treats the resource as changed and re-fetches it on every pass.
*/
function entryEtag(uid: string, ts: number | string): string {
return `"${uid}-${toEpochSeconds(ts)}"`;
}
function propfindEntry(
href: string,
entry: FSEntry | null,
@@ -1019,14 +1040,13 @@ function propfindEntry(
const created = entry?.created ?? '2025-01-01T00:00:00Z';
const name = entry?.name ?? (pathPosix.basename(href) || '/');
const uid = entry?.uuid ?? 'root';
const modTs = Math.floor(new Date(modified as string).getTime());
let props = `
<D:displayname>${escapeXml(String(name))}</D:displayname>
<D:getlastmodified>${new Date(modified as string).toUTCString()}</D:getlastmodified>
<D:creationdate>${new Date(created as string).toISOString()}</D:creationdate>
<D:getlastmodified>${entryDate(modified).toUTCString()}</D:getlastmodified>
<D:creationdate>${entryDate(created).toISOString()}</D:creationdate>
<D:resourcetype>${isDir ? '<D:collection/>' : ''}</D:resourcetype>
<D:getetag>"${uid}-${modTs}"</D:getetag>
<D:getetag>${entryEtag(uid, modified)}</D:getetag>
<D:supportedlock>
<D:lockentry><D:lockscope><D:exclusive/></D:lockscope><D:locktype><D:write/></D:locktype></D:lockentry>
<D:lockentry><D:lockscope><D:shared/></D:lockscope><D:locktype><D:write/></D:locktype></D:lockentry>
+42 -12
View File
@@ -3,18 +3,19 @@
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
* Puter is free software: you can redistribute it and/or modify it under the
* terms of the GNU Affero General Public License as published by the Free
* Software Foundation, either version 3 of the License, or (at your option) any
* later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
* This program is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
* FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
* along with this program. If not, see
* [https://www.gnu.org/licenses/](https://www.gnu.org/licenses/).
*/
import http from 'node:http';
@@ -40,10 +41,11 @@ const rawRequest = (
port: number,
path: string,
headers: Record<string, string> = {},
method = 'GET',
): Promise<RawResponse> =>
new Promise((resolve, reject) => {
const req = http.request(
{ host: '127.0.0.1', port, path, method: 'GET', headers },
{ host: '127.0.0.1', port, path, method, headers },
(res) => {
let body = '';
res.setEncoding('utf8');
@@ -93,8 +95,11 @@ describe('PuterServer host header validation', () => {
await server?.shutdown();
});
const request = (path: string, headers: Record<string, string> = {}) =>
rawRequest(port, path, headers);
const request = (
path: string,
headers: Record<string, string> = {},
method = 'GET',
) => rawRequest(port, path, headers, method);
it('accepts the configured main domain and its subdomains', async () => {
for (const host of [
@@ -185,6 +190,31 @@ describe('PuterServer host header validation', () => {
);
});
it('lets the dav subdomain answer its own OPTIONS', async () => {
// A DAV client opens a mount with OPTIONS and reads `DAV:` to decide
// the host speaks WebDAV at all. The blanket preflight reply is a bare
// 200 with no such header, which makes macOS abandon the mount before
// it ever sends credentials — so this request has to reach the
// controller instead.
const res = await request(
'/some-user',
{ host: `dav.puter.localhost:${port}` },
'OPTIONS',
);
expect(res.headers['dav']).toContain('1');
expect(res.headers['dav']).toContain('2');
});
it('still short-circuits OPTIONS preflight off the dav subdomain', async () => {
const res = await request(
'/some-path',
{ host: `api.puter.localhost:${port}` },
'OPTIONS',
);
expect(res.status).toBe(200);
expect(res.headers['dav']).toBeUndefined();
});
it('pins X-Frame-Options on the main domain only', async () => {
const main = await request('/healthcheck', {
host: 'puter.localhost',
+12 -3
View File
@@ -56,7 +56,7 @@ import { validateSubscriptionRequirement } from './services/metering/enforcement
import { createStepUpGate } from './core/http/middleware/stepUpSession';
import { createNotFoundHandler } from './core/http/middleware/notFoundHandler';
import { installProcessGuards } from './util/processGuards';
import { subdomainOffsetForDomain } from './util/subdomains';
import { activeSubdomain, subdomainOffsetForDomain } from './util/subdomains';
import {
requireAntiCsrf,
setAntiCsrfRedis,
@@ -482,7 +482,16 @@ export class PuterServer {
}
// -- OPTIONS preflight ---------------------------------------
this.#app.options('/*splat', (_req, res) => {
// WebDAV is exempt: OPTIONS is how a DAV client discovers the server,
// and the reply has to carry `DAV:` and `Allow:` for the mount to
// proceed. Answering it here with a bare 200 tells the client this
// isn't a WebDAV server at all, so let it fall through to the
// controller, which builds the real response.
this.#app.options('/*splat', (req, res, next) => {
if (activeSubdomain(req) === 'dav') {
next();
return;
}
res.sendStatus(200);
});
@@ -678,7 +687,7 @@ export class PuterServer {
this.#app.use((req, res, next) => {
const origin = req.headers.origin;
const subdomain = req.subdomains?.[req.subdomains.length - 1];
const subdomain = activeSubdomain(req);
// Allow any origin. puter.js is meant to be consumed from
// arbitrary third-party sites, so reflect the caller's origin
+2 -6
View File
@@ -22,16 +22,14 @@ import { posix as pathPosix } from 'node:path';
import { userRelatedActor, type Actor } from '../../core/actor';
import { HttpError } from '../../core/http/HttpError.js';
import type { FSEntry } from '../../stores/fs/FSEntry';
import type { LayerInstances } from '../../types';
import type { AclMode } from '../acl/ACLService';
import type { puterServices } from '../index';
import { MANAGE_PERM_PREFIX } from '../permission/consts';
import { PuterService } from '../types';
import {
learnShareRoots,
maskEntryPath,
resolveSharePath,
} from '../fs/sharePathMask';
import { MANAGE_PERM_PREFIX } from '../permission/consts';
import { PuterService } from '../types';
// -- Types ------------------------------------------------------------
@@ -196,8 +194,6 @@ const maskedSelfPath = (entry: FSEntry, realPath: string): string => {
* explicit `manage:fs:<uid>` grant.
*/
export class ShareService extends PuterService {
declare protected services: LayerInstances<typeof puterServices>;
/** Entries awaiting the next retire flush, deduped by uuid. */
#pendingRetire = new Map<string, FSEntry>();
/** The in-flight flush, shared by everything buffered for it. */
+10
View File
@@ -39,3 +39,13 @@ export function subdomainOffsetForDomain(
.filter(Boolean);
return labels.length > 0 ? labels.length : DEFAULT_SUBDOMAIN_OFFSET;
}
/**
* The subdomain a request is addressed to, or `''` for the root domain.
* `req.subdomains` is reverse-of-URL order, so the active one is last.
*/
export function activeSubdomain(req: { subdomains?: string[] }): string {
const subdomains = req.subdomains;
if (!subdomains?.length) return '';
return subdomains[subdomains.length - 1] ?? '';
}