mirror of
https://github.com/HeyPuter/puter.git
synced 2026-08-24 06:58:21 +00:00
fix: webdav connectivity fixes for macos (#3597)
This commit is contained in:
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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] ?? '';
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user