diff --git a/src/backend/controllers/webdav/WebDAVController.test.ts b/src/backend/controllers/webdav/WebDAVController.test.ts index dc1306062..1c8fd3016 100644 --- a/src/backend/controllers/webdav/WebDAVController.test.ts +++ b/src/backend/controllers/webdav/WebDAVController.test.ts @@ -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>/, + )?.[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>/, + )?.[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'); diff --git a/src/backend/controllers/webdav/WebDAVController.ts b/src/backend/controllers/webdav/WebDAVController.ts index 34af18171..fa8a822e9 100644 --- a/src/backend/controllers/webdav/WebDAVController.ts +++ b/src/backend/controllers/webdav/WebDAVController.ts @@ -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 `\n\n${inner}\n`; } +/** + * 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 = ` ${escapeXml(String(name))} - ${new Date(modified as string).toUTCString()} - ${new Date(created as string).toISOString()} + ${entryDate(modified).toUTCString()} + ${entryDate(created).toISOString()} ${isDir ? '' : ''} - "${uid}-${modTs}" + ${entryEtag(uid, modified)} diff --git a/src/backend/server.test.ts b/src/backend/server.test.ts index 75d4a662b..37f32f386 100644 --- a/src/backend/server.test.ts +++ b/src/backend/server.test.ts @@ -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 . + * 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 = {}, + method = 'GET', ): Promise => 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 = {}) => - rawRequest(port, path, headers); + const request = ( + path: string, + headers: Record = {}, + 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', diff --git a/src/backend/server.ts b/src/backend/server.ts index cd04d9956..bf9883b08 100644 --- a/src/backend/server.ts +++ b/src/backend/server.ts @@ -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 diff --git a/src/backend/services/share/ShareService.ts b/src/backend/services/share/ShareService.ts index 2fcda9f25..64c22d022 100644 --- a/src/backend/services/share/ShareService.ts +++ b/src/backend/services/share/ShareService.ts @@ -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:` grant. */ export class ShareService extends PuterService { - declare protected services: LayerInstances; - /** Entries awaiting the next retire flush, deduped by uuid. */ #pendingRetire = new Map(); /** The in-flight flush, shared by everything buffered for it. */ diff --git a/src/backend/util/subdomains.ts b/src/backend/util/subdomains.ts index 2a0683d06..419d39538 100644 --- a/src/backend/util/subdomains.ts +++ b/src/backend/util/subdomains.ts @@ -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] ?? ''; +}