diff --git a/apps/server/src/app-imports.test.ts b/apps/server/src/app-imports.test.ts new file mode 100644 index 0000000..8451942 --- /dev/null +++ b/apps/server/src/app-imports.test.ts @@ -0,0 +1,123 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { FileContentResponse, PaperProject, UploadSession } from "@fastwrite/shared"; +import { createApplication } from "./app"; + +const temporaryDirectories: string[] = []; + +afterEach(async () => { + await Promise.all(temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true, force: true }))); +}); + +async function importApplication(serverAuth = true) { + const directory = await mkdtemp(join(tmpdir(), "fastwrite-import-auth-")); + temporaryDirectories.push(directory); + const app = await createApplication(directory, { features: { serverAuth } }); + return { directory, request: (path: string, init?: RequestInit) => app(new Request(`http://fastwrite.test${path}`, init)) }; +} + +type RequestApplication = Awaited>["request"]; + +async function register(request: RequestApplication, email: string) { + const response = await request("/api/auth/register", { + method: "POST", headers: { "content-type": "application/json" }, + body: JSON.stringify({ email, password: "correct-horse-battery-staple" }) + }); + expect(response.status).toBe(201); + return await response.json() as { token: string; user: { id: string } }; +} + +const sources = [ + ["main.tex", "\\documentclass{article}\n\\begin{document}\\input{sections/intro}\\bibliography{refs}\\end{document}"], + ["sections/intro.tex", "\\section{Introduction}\nImported paper \\cite{example}."], + ["refs.bib", "@article{example, title={Imported Reference}, year={2026}}"] +] as const; + +function manifest(ownerUserId?: string) { + return JSON.stringify({ + projectName: "Private imported paper", mainDocument: "main.tex", venue: "network-information-security", sourceName: "paper-folder", + entries: sources.map(([path, content]) => ({ path, kind: "file", size: new TextEncoder().encode(content).byteLength })), + ...(ownerUserId ? { ownerUserId } : {}) + }); +} + +describe("authenticated project imports", () => { + test("keeps upload sessions private and registers the importer as the project owner", async () => { + const { directory, request } = await importApplication(); + await register(request, "admin@example.test"); + const owner = await register(request, "importer@example.test"); + const stranger = await register(request, "stranger@example.test"); + const ownerHeaders = { authorization: `Bearer ${owner.token}` }; + const strangerHeaders = { authorization: `Bearer ${stranger.token}` }; + const created = await request("/api/upload-sessions", { + method: "POST", headers: { ...ownerHeaders, "content-type": "application/json" }, body: manifest(stranger.user.id) + }); + expect(created.status).toBe(201); + const session = await created.json() as UploadSession; + expect(session.ownerUserId).toBe(owner.user.id); + + for (const [path, method, body] of [ + [`/api/upload-sessions/${session.id}`, "GET", undefined], + [`/api/upload-sessions/${session.id}/files?path=main.tex`, "PUT", new TextEncoder().encode(sources[0][1])], + [`/api/upload-sessions/${session.id}/complete`, "POST", undefined], + [`/api/upload-sessions/${session.id}`, "DELETE", undefined] + ] as const) { + expect((await request(path, { method, headers: strangerHeaders, ...(body ? { body } : {}) })).status).toBe(403); + expect((await request(path, { method, ...(body ? { body } : {}) })).status).toBe(401); + } + expect((await request(`/api/upload-sessions/${session.id}`, { headers: ownerHeaders })).status).toBe(200); + expect((await request(`/api/upload-sessions/${session.id}/complete`, { method: "POST", headers: ownerHeaders })).status).toBe(409); + for (const [path, content] of sources) { + expect((await request(`/api/upload-sessions/${session.id}/files?path=${encodeURIComponent(path)}`, { + method: "PUT", headers: ownerHeaders, body: new TextEncoder().encode(content) + })).status).toBe(200); + } + const completed = await request(`/api/upload-sessions/${session.id}/complete`, { method: "POST", headers: ownerHeaders }); + expect(completed.status).toBe(201); + const project = await completed.json() as PaperProject; + expect(project).toMatchObject({ personalOwnerUserId: owner.user.id, visibility: "private" }); + const retried = await request(`/api/upload-sessions/${session.id}/complete`, { method: "POST", headers: ownerHeaders }); + expect(retried.status).toBe(201); + expect(await retried.json()).toMatchObject({ id: project.id, personalOwnerUserId: owner.user.id }); + expect((await request(`/api/upload-sessions/${session.id}/complete`, { method: "POST", headers: strangerHeaders })).status).toBe(403); + expect(await (await request("/api/projects", { headers: ownerHeaders })).json()).toEqual(expect.arrayContaining([expect.objectContaining({ id: project.id })])); + expect(await (await request("/api/projects", { headers: strangerHeaders })).json()).toEqual([]); + expect((await request(`/api/projects/${project.id}`, { headers: strangerHeaders })).status).toBe(403); + expect(await (await request(`/api/projects/${project.id}/members`, { headers: ownerHeaders })).json()).toMatchObject({ members: expect.arrayContaining([expect.objectContaining({ userId: owner.user.id, role: "owner" })]) }); + for (const [path, content] of sources) { + const opened = await request(`/api/projects/${project.id}/file?path=${encodeURIComponent(path)}`, { headers: ownerHeaders }); + expect(opened.status).toBe(200); + expect((await opened.json() as FileContentResponse).content).toBe(content); + } + const persisted = await createApplication(directory, { features: { serverAuth: true } }); + expect((await persisted(new Request(`http://fastwrite.test/api/projects/${project.id}`, { headers: ownerHeaders }))).status).toBe(200); + expect((await persisted(new Request(`http://fastwrite.test/api/upload-sessions/${session.id}`, { headers: strangerHeaders }))).status).toBe(403); + }); + + test("requires authentication before creating directory or GitHub imports", async () => { + const { request } = await importApplication(); + expect((await request("/api/upload-sessions", { method: "POST", headers: { "content-type": "application/json" }, body: manifest() })).status).toBe(401); + expect((await request("/api/project-imports/github", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ repository: "https://github.com/example/paper" }) })).status).toBe(401); + expect((await request("/api/projects", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ name: "Anonymous template", initializeFromTemplate: true }) })).status).toBe(401); + }); + + test("does not let an authenticated user claim an ownerless legacy upload", async () => { + const { directory, request } = await importApplication(false); + const response = await request("/api/upload-sessions", { method: "POST", headers: { "content-type": "application/json" }, body: manifest() }); + expect(response.status).toBe(201); + const session = await response.json() as UploadSession; + const authenticated = await createApplication(directory, { features: { serverAuth: true } }); + const authenticatedRequest: RequestApplication = (path, init) => authenticated(new Request(`http://fastwrite.test${path}`, init)); + const user = await register(authenticatedRequest, "new-user@example.test"); + for (const [path, method] of [ + [`/api/upload-sessions/${session.id}`, "GET"], + [`/api/upload-sessions/${session.id}/files?path=main.tex`, "PUT"], + [`/api/upload-sessions/${session.id}/complete`, "POST"], + [`/api/upload-sessions/${session.id}`, "DELETE"] + ] as const) { + expect((await authenticatedRequest(path, { method, headers: { authorization: `Bearer ${user.token}` } })).status).toBe(403); + } + }); +}); diff --git a/apps/server/src/app-web-static.test.ts b/apps/server/src/app-web-static.test.ts new file mode 100644 index 0000000..5ede114 --- /dev/null +++ b/apps/server/src/app-web-static.test.ts @@ -0,0 +1,52 @@ +import { expect, test } from "bun:test"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { gzipSync, gunzipSync } from "node:zlib"; +import { createApplication } from "./app"; +import { config } from "./config"; + +test("serves gzip build assets through the application and keeps SPA fallbacks distinct from missing assets", async () => { + const directory = await mkdtemp(join(tmpdir(), "fastwrite-static-")); + const originalWebDirectory = config.webDirectory; + try { + const webDirectory = join(directory, "web"); + await mkdir(join(webDirectory, "assets"), { recursive: true }); + const source = "console.log('FastWrite static delivery');\n".repeat(100); + const compressed = gzipSync(source); + await writeFile(join(webDirectory, "assets", "app-abcdefgh.js"), source); + await writeFile(join(webDirectory, "assets", "app-abcdefgh.js.gz"), compressed); + await writeFile(join(webDirectory, "index.html"), "FastWrite"); + config.webDirectory = webDirectory; + const app = await createApplication(join(directory, "data"), { features: { serverAuth: false } }); + const request = (path: string, init?: RequestInit) => app(new Request(`http://fastwrite.test${path}`, init)); + + const asset = await request("/assets/app-abcdefgh.js", { headers: { "accept-encoding": "gzip" } }); + expect(asset.status).toBe(200); + expect(asset.headers.get("content-encoding")).toBe("gzip"); + expect(asset.headers.get("content-type")).toContain("text/javascript"); + expect(asset.headers.get("cache-control")).toContain("immutable"); + expect(asset.headers.get("vary")).toBe("Accept-Encoding"); + expect(gunzipSync(await asset.arrayBuffer()).toString()).toBe(source); + + const uncompressed = await request("/assets/app-abcdefgh.js", { headers: { "accept-encoding": "gzip;q=0" } }); + expect(uncompressed.headers.get("content-encoding")).toBeNull(); + expect(await uncompressed.text()).toBe(source); + const head = await request("/assets/app-abcdefgh.js", { method: "HEAD", headers: { "accept-encoding": "gzip" } }); + expect(head.headers.get("content-length")).toBe(String(compressed.byteLength)); + expect(await head.text()).toBe(""); + + const clientRoute = await request("/projects?invite=example"); + expect(clientRoute.status).toBe(200); + expect(clientRoute.headers.get("content-type")).toContain("text/html"); + expect(clientRoute.headers.get("cache-control")).toBe("no-cache"); + for (const path of ["/assets/missing.js", "/worker-missing.js", "/bundles/missing.data.gz"]) { + const missing = await request(path); + expect(missing.status).toBe(404); + expect(missing.headers.get("content-type")).toContain("application/json"); + } + } finally { + config.webDirectory = originalWebDirectory; + await rm(directory, { recursive: true, force: true }); + } +}); diff --git a/apps/server/src/app.test.ts b/apps/server/src/app.test.ts index d9cb246..050681c 100644 --- a/apps/server/src/app.test.ts +++ b/apps/server/src/app.test.ts @@ -30,6 +30,7 @@ describe("workspace API", () => { expect(response.headers.get("content-security-policy")).toContain("frame-ancestors 'none'"); expect(response.headers.get("content-security-policy")).toContain("style-src 'self' 'unsafe-inline'"); expect(response.headers.get("content-security-policy")).toContain("default-src 'self';"); + expect(response.headers.get("content-security-policy")).toContain("img-src 'self' blob:"); expect(response.headers.get("x-request-id")).toMatch(/^req_/); }); diff --git a/apps/server/src/app.ts b/apps/server/src/app.ts index 7705aa2..d27b165 100644 --- a/apps/server/src/app.ts +++ b/apps/server/src/app.ts @@ -88,6 +88,7 @@ import { LocalObjectStore, createS3ObjectStore, type ObjectStore } from "./stora import { LocalModelProvider } from "./agent/local-model-provider"; import { createNotification } from "./notifications/notification-service"; import { MetricsRegistry } from "./metrics"; +import { webFileResponse, webMimeType } from "./web-static"; const PROJECT_ACTIONS: ProjectAction[] = ["project:read", "project:write", "project:invite", "project:manage", "comment:write", "compile:run", "review:run", "agent:propose", "changeset:approve", "harness:use", "github:sync"]; @@ -401,7 +402,7 @@ export async function createApplication(dataDirectory = config.dataDirectory, op void mailDelivery.dispatchPending(); return response; } - const response = withRuntimeHeaders(await serveWeb(url.pathname)); + const response = withRuntimeHeaders(await serveWeb(request, url.pathname)); metrics.observe(request.method, url.pathname, response.status); return response; } catch (error) { @@ -467,6 +468,14 @@ function buildRoutes({ diagrams, database, workspaces, projectSearch, uploads, g state.auditEvents.push({ id: `audit_${crypto.randomUUID()}`, actorUserId: current.user.id, action: "project.create", resourceType: "project", resourceId: project.id, createdAt: timestamp }); }); }; + const authorizeUpload = (request: Request, uploadId: string) => { + const current = principal(request, authEnabled); + const session = uploads.get(uploadId); + if ((session.ownerUserId && session.ownerUserId !== current?.user.id) || (authEnabled && !session.ownerUserId)) { + throw new ApiError(403, "upload_access_denied", "You do not have permission to access this upload session"); + } + return session; + }; return [ route("GET", "/api/health", async () => json({ status: "ok", version: "0.1.0" })), route("GET", "/api/metrics", async () => new Response(metrics.prometheus(), { headers: { "content-type": "text/plain; version=0.0.4; charset=utf-8" } })), @@ -848,17 +857,19 @@ function buildRoutes({ diagrams, database, workspaces, projectSearch, uploads, g return json(!authEnabled ? workspaces.listProjects() : workspaces.listProjects().filter((project) => current && authorization.projectRole(current.user.id, project.id))); }), route("POST", "/api/projects", async (request) => { + const current = principal(request, authEnabled); const body = await readJson(request); + if (body.teamId && current) authorization.requireTeamRole(current, body.teamId, ["owner", "admin"]); if (body.initializeFromTemplate) { if (!body.publicationTarget) throw new ApiError(400, "template_target_required", "Select a conference or journal before using its template"); const venue = body.venue ?? body.publicationTarget.domain; const template = await latexTemplates.materialize(body.name, venue, body.publicationTarget); const project = await workspaces.importStagingDirectory({ stagingDirectory: template.stagingDirectory, name: body.name, mainDocument: template.mainDocument, venue, publicationTarget: body.publicationTarget, source: { type: "local", displayName: template.displayName } }); - await assignProjectOwner(project, principal(request, false), body.teamId); + await assignProjectOwner(project, current, body.teamId); return json(project, 201); } const project = await workspaces.createEmpty(body.name, body.mainDocument, body.venue, body.publicationTarget); - await assignProjectOwner(project, principal(request, false), body.teamId); + await assignProjectOwner(project, current, body.teamId); return json(project, 201); }), route("GET", "/api/projects/:projectId", async (request, params) => { const projectId = required(params, "projectId"); const current = principal(request, authEnabled); if (current) authorization.requireProject(current, projectId, "project:read"); return json(workspaces.getProject(projectId)); }), @@ -1266,6 +1277,7 @@ function buildRoutes({ diagrams, database, workspaces, projectSearch, uploads, g return json(rolledBack); }), route("POST", "/api/upload-sessions", async (request) => { + const current = principal(request, authEnabled); const body = await readJson<{ projectName: string; mainDocument: string; @@ -1274,22 +1286,32 @@ function buildRoutes({ diagrams, database, workspaces, projectSearch, uploads, g sourceName: string; entries: UploadManifestEntry[]; }>(request); - return json(await uploads.create(body), 201); + return json(await uploads.create(body, current?.user.id), 201); }), - route("GET", "/api/upload-sessions/:uploadId", async (_request, params) => json(uploads.get(required(params, "uploadId")))), + route("GET", "/api/upload-sessions/:uploadId", async (request, params) => json(authorizeUpload(request, required(params, "uploadId")))), route("PUT", "/api/upload-sessions/:uploadId/files", async (request, params, url) => { - return json(await uploads.uploadFile(required(params, "uploadId"), requiredQuery(url, "path"), await request.arrayBuffer())); - }), - route("POST", "/api/upload-sessions/:uploadId/complete", async (_request, params) => { - return json(await uploads.complete(required(params, "uploadId")), 201); + const uploadId = required(params, "uploadId"); + authorizeUpload(request, uploadId); + return json(await uploads.uploadFile(uploadId, requiredQuery(url, "path"), await request.arrayBuffer())); + }), + route("POST", "/api/upload-sessions/:uploadId/complete", async (request, params) => { + const uploadId = required(params, "uploadId"); + authorizeUpload(request, uploadId); + const project = await uploads.complete(uploadId); + const current = principal(request, authEnabled); + if (current) authorization.requireProject(current, project.id, "project:read"); + return json(project, 201); }), - route("DELETE", "/api/upload-sessions/:uploadId", async (_request, params) => { - await uploads.cancel(required(params, "uploadId")); + route("DELETE", "/api/upload-sessions/:uploadId", async (request, params) => { + const uploadId = required(params, "uploadId"); + authorizeUpload(request, uploadId); + await uploads.cancel(uploadId); return new Response(null, { status: 204 }); }), route("POST", "/api/project-imports/github", async (request) => { + const current = principal(request, authEnabled); const body = await readJson(request); - return json(await github.import(body), 201); + return json(await github.import(body, current?.user.id), 201); }) ]; } @@ -1517,41 +1539,26 @@ async function authorizeMcpWorkspaceInput(projectId: string, principal: Principa function textPathsForAuthorization(nodes: WorkspaceTreeNode[]): string[] { return nodes.flatMap((node) => node.type === "directory" ? textPathsForAuthorization(node.children) : node.kind === "text" ? [node.path] : []); } function workspacePathsForAuthorization(nodes: WorkspaceTreeNode[]): string[] { return nodes.flatMap((node) => node.type === "directory" ? workspacePathsForAuthorization(node.children) : [node.path]); } -async function serveWeb(pathname: string): Promise { +async function serveWeb(request: Request, pathname: string): Promise { const requested = pathname === "/" ? "index.html" : pathname.replace(/^\//, ""); const candidate = join(config.webDirectory, requested); if (existsSync(candidate) && (await stat(candidate)).isFile()) { - const headers: Record = { "content-type": mimeType(extname(candidate)) }; - if (/^bundles\//.test(requested) || /^(?:busytex\.wasm|worker\.js)$/.test(requested)) headers["cache-control"] = "public, max-age=3600"; - return new Response(Bun.file(candidate), { headers }); + return webFileResponse(request, requested, Bun.file(candidate), existsSync(`${candidate}.gz`) ? Bun.file(`${candidate}.gz`) : undefined); } const embedded = embeddedWebFile(requested); - if (embedded) return new Response(embedded, { headers: webHeaders(requested) }); + if (embedded) return webFileResponse(request, requested, embedded, embeddedWebFile(`${requested}.gz`)); + if (/^(?:assets|bundles)\//.test(requested) || /\.(?:js|mjs|css|map|json|png|jpe?g|gif|webp|svg|wasm|woff2?|ttf|ico|pdf|gz|data)$/.test(requested)) { + return json({ error: { code: "web_asset_not_found", message: "Web asset not found" } }, 404); + } const index = join(config.webDirectory, "index.html"); - if (existsSync(index)) return new Response(Bun.file(index), { headers: { "content-type": "text/html; charset=utf-8" } }); + if (existsSync(index)) return webFileResponse(request, "index.html", Bun.file(index), existsSync(`${index}.gz`) ? Bun.file(`${index}.gz`) : undefined); const embeddedIndex = embeddedWebFile("index.html"); - if (embeddedIndex) return new Response(embeddedIndex, { headers: { "content-type": "text/html; charset=utf-8" } }); + if (embeddedIndex) return webFileResponse(request, "index.html", embeddedIndex, embeddedWebFile("index.html.gz")); return json({ error: { code: "web_not_built", message: "Web client is not built. Run the development server or build the project." } }, 404); } -function webHeaders(path: string): Record { - const headers: Record = { "content-type": mimeType(extname(path)) }; - if (/^bundles\//.test(path) || /^(?:busytex\.wasm|worker\.js)$/.test(path)) headers["cache-control"] = "public, max-age=3600"; - return headers; -} - export function mimeType(extension: string): string { - return ({ - ".html": "text/html; charset=utf-8", - ".js": "text/javascript; charset=utf-8", - ".mjs": "text/javascript; charset=utf-8", - ".css": "text/css; charset=utf-8", - ".svg": "image/svg+xml", - ".png": "image/png", - ".jpg": "image/jpeg", - ".woff2": "font/woff2", - ".wasm": "application/wasm" - } as Record)[extension] ?? "application/octet-stream"; + return webMimeType(extension); } function requestCookie(request: Request, name: string): string { return request.headers.get("cookie")?.split(";").map(item => item.trim()).find(item => item.startsWith(name + "="))?.slice(name.length + 1) ?? ""; } diff --git a/apps/server/src/http.ts b/apps/server/src/http.ts index a509829..ea224b0 100644 --- a/apps/server/src/http.ts +++ b/apps/server/src/http.ts @@ -62,7 +62,7 @@ export function withRuntimeHeaders(response: Response): Response { for (const [name, value] of Object.entries(CROSS_ORIGIN_HEADERS)) headers.set(name, value); // Monaco generates inline line-position styles and theme stylesheets at runtime. // Keep script execution restricted while allowing those rendering styles. - if (!headers.has("content-security-policy")) headers.set("Content-Security-Policy", "default-src 'self'; style-src 'self' 'unsafe-inline'; base-uri 'self'; frame-ancestors 'none'; object-src 'none'; worker-src 'self' blob:; connect-src 'self' blob: ws: wss:"); + if (!headers.has("content-security-policy")) headers.set("Content-Security-Policy", "default-src 'self'; img-src 'self' blob:; style-src 'self' 'unsafe-inline'; base-uri 'self'; frame-ancestors 'none'; object-src 'none'; worker-src 'self' blob:; connect-src 'self' blob: ws: wss:"); if (!headers.has("x-request-id")) headers.set("X-Request-ID", `req_${crypto.randomUUID()}`); return new Response(response.body, { status: response.status, statusText: response.statusText, headers }); } diff --git a/apps/server/src/imports/github-service.test.ts b/apps/server/src/imports/github-service.test.ts index d71e3b0..1514195 100644 --- a/apps/server/src/imports/github-service.test.ts +++ b/apps/server/src/imports/github-service.test.ts @@ -1,5 +1,11 @@ -import { describe, expect, test } from "bun:test"; -import { cleanGitError, parseGithubRepository } from "./github-service"; +import { describe, expect, spyOn, test } from "bun:test"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { JsonDatabase } from "../storage/database"; +import { WorkspaceService } from "../workspace/workspace-service"; +import { AuthorizationService } from "../auth/authorization-service"; +import { cleanGitError, GithubService, parseGithubRepository } from "./github-service"; describe("parseGithubRepository", () => { test("accepts canonical and SSH-shaped GitHub URLs", () => { @@ -16,3 +22,45 @@ describe("parseGithubRepository", () => { expect(cleanGitError("Cloning into '/private/tmp/github-123/repository'...\nremote: Repository not found.\nfatal: repository unavailable")).toBe("GitHub repository not found or access was denied"); }); }); + +describe("GitHub ownership", () => { + test("registers an imported repository with its authenticated owner", async () => { + const directory = await mkdtemp(join(tmpdir(), "fastwrite-github-owner-")); + const database = new JsonDatabase(directory); + await database.initialize(); + const workspaces = new WorkspaceService(directory, database); + await workspaces.initialize(); + const originalSpawn = Bun.spawn; + const spawn = spyOn(Bun, "spawn"); + const processResult = (stdout: string, exited = Promise.resolve(0)) => ({ + stdout: new Blob([stdout]).stream(), stderr: new Blob([]).stream(), exited + }); + spawn.mockImplementation(((command: string[], options: unknown) => { + if (command[0] === "git" && command[1] === "clone") { + const destination = command.at(-1)!; + const exited = mkdir(destination, { recursive: true }) + .then(() => writeFile(join(destination, "main.tex"), "\\documentclass{article}\n\\begin{document}GitHub paper\\end{document}")) + .then(() => 0); + return processResult("", exited); + } + if (command[0] === "git" && command[1] === "-C" && command[2]?.endsWith("repository")) { + if (command[3] === "rev-parse") return processResult("0123456789abcdef0123456789abcdef01234567\n"); + if (command[3] === "symbolic-ref") return processResult("main\n"); + } + return originalSpawn(command, options as Parameters[1]); + }) as typeof Bun.spawn); + try { + const ownerUserId = "user_github_importer"; + const project = await new GithubService(directory, workspaces).import({ repository: "https://github.com/example/paper" }, ownerUserId); + expect(project).toMatchObject({ personalOwnerUserId: ownerUserId, visibility: "private", source: { type: "github", ref: "main" } }); + const authorization = new AuthorizationService(database); + expect(authorization.projectRole(ownerUserId, project.id)).toBe("owner"); + expect(authorization.projectRole("user_other", project.id)).toBeUndefined(); + expect(database.snapshot().projectMembers).toContainEqual(expect.objectContaining({ projectId: project.id, userId: ownerUserId, role: "owner" })); + expect(database.snapshot().auditEvents).toContainEqual(expect.objectContaining({ resourceId: project.id, actorUserId: ownerUserId, action: "project.create" })); + } finally { + spawn.mockRestore(); + await rm(directory, { recursive: true, force: true }); + } + }); +}); diff --git a/apps/server/src/imports/github-service.ts b/apps/server/src/imports/github-service.ts index 56eed77..6795ef4 100644 --- a/apps/server/src/imports/github-service.ts +++ b/apps/server/src/imports/github-service.ts @@ -13,7 +13,7 @@ interface GithubLocation { export class GithubService { constructor(private readonly dataDirectory: string, private readonly workspaces: WorkspaceService) {} - async import(request: GithubImportRequest): Promise { + async import(request: GithubImportRequest, personalOwnerUserId?: string): Promise { const location = parseGithubRepository(request.repository); const requestedRef = request.ref?.trim() || "HEAD"; if (requestedRef !== "HEAD" && (!/^[\w./-]{1,200}$/.test(requestedRef) || requestedRef.startsWith("-") || requestedRef.includes("..") || requestedRef.includes("@{"))) { @@ -52,6 +52,7 @@ export class GithubService { ...(request.mainDocument ? { mainDocument: request.mainDocument } : {}), venue: request.venue ?? "network-information-security", ...(request.publicationTarget ? { publicationTarget: request.publicationTarget } : {}), + ...(personalOwnerUserId ? { personalOwnerUserId } : {}), source: { type: "github", repository: `https://github.com/${location.owner}/${location.repository}`, diff --git a/apps/server/src/imports/upload-service.test.ts b/apps/server/src/imports/upload-service.test.ts new file mode 100644 index 0000000..cd2901c --- /dev/null +++ b/apps/server/src/imports/upload-service.test.ts @@ -0,0 +1,103 @@ +import { afterEach, describe, expect, mock, test } from "bun:test"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { paperSkillForProfile, type PaperProject } from "@fastwrite/shared"; +import { JsonDatabase } from "../storage/database"; +import type { WorkspaceService } from "../workspace/workspace-service"; +import { UploadService } from "./upload-service"; + +const temporaryDirectories: string[] = []; +const encoded = new TextEncoder().encode("\\documentclass{article}\n\\begin{document}Imported paper\\end{document}"); +const content = new ArrayBuffer(encoded.byteLength); +new Uint8Array(content).set(encoded); +type ImportInput = Parameters[0]; + +afterEach(async () => { + await Promise.all(temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true, force: true }))); +}); + +async function testUploads(beforeImport?: () => Promise) { + const directory = await mkdtemp(join(tmpdir(), "fastwrite-upload-retry-")); + temporaryDirectories.push(directory); + const database = new JsonDatabase(directory); + await database.initialize(); + const projects = new Map(); + const importDirectory = mock(async (input: ImportInput): Promise => { + await beforeImport?.(); + const timestamp = new Date().toISOString(); + const project: PaperProject = { + id: `paper_${crypto.randomUUID()}`, name: input.name, mainDocument: input.mainDocument, + skill: paperSkillForProfile(input.venue), source: input.source, version: 1, + ...(input.personalOwnerUserId ? { personalOwnerUserId: input.personalOwnerUserId, visibility: "private" } : {}), + createdAt: timestamp, updatedAt: timestamp + }; + projects.set(project.id, project); + return project; + }); + const workspaces = { importStagingDirectory: importDirectory, getProject: (id: string) => projects.get(id)! } as unknown as WorkspaceService; + const uploads = new UploadService(directory, database, workspaces); + await uploads.initialize(); + const session = await uploads.create({ projectName: "Retry paper", mainDocument: "main.tex", venue: "network-information-security", sourceName: "source", entries: [{ path: "main.tex", kind: "file", size: content.byteLength }] }, "user_importer"); + return { uploads, database, session, importDirectory }; +} + +describe("upload completion retries", () => { + test("repeated completion returns the original private project without importing twice", async () => { + const { uploads, database, session, importDirectory } = await testUploads(); + await uploads.uploadFile(session.id, "main.tex", content); + const project = await uploads.complete(session.id); + // A completed receipt remains usable after the staging-upload TTL expires. + await database.mutate(state => { state.uploadSessions[0]!.expiresAt = new Date(0).toISOString(); }); + const retried = await uploads.complete(session.id); + expect(retried).toEqual(project); + expect(retried.personalOwnerUserId).toBe("user_importer"); + expect(importDirectory).toHaveBeenCalledTimes(1); + expect(uploads.get(session.id)).toMatchObject({ status: "completed", projectId: project.id, ownerUserId: "user_importer" }); + }); + + test("concurrent completion shares one import and blocks uploads or cancellation while completing", async () => { + let started!: () => void; + let release!: () => void; + const importing = new Promise(resolve => { started = resolve; }); + const finish = new Promise(resolve => { release = resolve; }); + const { uploads, session, importDirectory } = await testUploads(async () => { started(); await finish; }); + await uploads.uploadFile(session.id, "main.tex", content); + const first = uploads.complete(session.id); + const second = uploads.complete(session.id); + await importing; + try { + expect(uploads.get(session.id).status).toBe("completing"); + await expect(uploads.uploadFile(session.id, "main.tex", content)).rejects.toMatchObject({ status: 409, code: "upload_not_writable" }); + await expect(uploads.cancel(session.id)).rejects.toMatchObject({ status: 409, code: "upload_not_writable" }); + } finally { release(); } + const [project, retried] = await Promise.all([first, second]); + expect(retried).toEqual(project); + expect(project.personalOwnerUserId).toBe("user_importer"); + expect(importDirectory).toHaveBeenCalledTimes(1); + expect(uploads.get(session.id)).toMatchObject({ status: "completed", projectId: project.id, ownerUserId: "user_importer" }); + await expect(uploads.uploadFile(session.id, "main.tex", content)).rejects.toMatchObject({ status: 409, code: "upload_not_writable" }); + await expect(uploads.cancel(session.id)).rejects.toMatchObject({ status: 409, code: "upload_completed" }); + }); + + test("an incomplete completion releases the session so missing files can be uploaded and retried", async () => { + const { uploads, session, importDirectory } = await testUploads(); + await expect(uploads.complete(session.id)).rejects.toMatchObject({ status: 409, code: "upload_incomplete" }); + await uploads.uploadFile(session.id, "main.tex", content); + const project = await uploads.complete(session.id); + expect(project.personalOwnerUserId).toBe("user_importer"); + expect(importDirectory).toHaveBeenCalledTimes(1); + }); + + test("a failed import releases the queue and remains closed to further file writes", async () => { + const { uploads, session, importDirectory } = await testUploads(async () => { throw new Error("workspace import failed"); }); + await uploads.uploadFile(session.id, "main.tex", content); + await expect(uploads.complete(session.id)).rejects.toThrow("workspace import failed"); + expect(uploads.get(session.id)).toMatchObject({ status: "failed", ownerUserId: "user_importer" }); + await expect(uploads.complete(session.id)).rejects.toMatchObject({ status: 409, code: "upload_not_writable" }); + await expect(uploads.uploadFile(session.id, "main.tex", content)).rejects.toMatchObject({ status: 409, code: "upload_not_writable" }); + await uploads.cancel(session.id); + expect(uploads.get(session.id).status).toBe("cancelled"); + expect(importDirectory).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/server/src/imports/upload-service.ts b/apps/server/src/imports/upload-service.ts index e0504e7..a39493e 100644 --- a/apps/server/src/imports/upload-service.ts +++ b/apps/server/src/imports/upload-service.ts @@ -7,6 +7,7 @@ import { ApiError } from "../http"; import type { JsonDatabase } from "../storage/database"; import type { WorkspaceService } from "../workspace/workspace-service"; import { resolveWorkspacePath } from "../workspace/path"; +import { ProjectQueue } from "../workspace/project-queue"; interface CreateUploadInput { projectName: string; @@ -19,6 +20,7 @@ interface CreateUploadInput { export class UploadService { private readonly uploadsDirectory: string; + private readonly sessionQueue = new ProjectQueue(); constructor( dataDirectory: string, @@ -32,7 +34,7 @@ export class UploadService { await mkdir(this.uploadsDirectory, { recursive: true }); } - async create(input: CreateUploadInput): Promise { + async create(input: CreateUploadInput, ownerUserId?: string): Promise { if (!input.projectName.trim()) throw new ApiError(400, "project_name_required", "Project name is required"); if (input.entries.length === 0) throw new ApiError(400, "empty_upload", "The selected directory is empty"); if (input.entries.length > config.maxEntries) throw new ApiError(413, "too_many_files", `A project can contain at most ${config.maxEntries} entries`); @@ -58,6 +60,7 @@ export class UploadService { const createdAt = new Date(); const session: UploadSession = { id: `upload_${crypto.randomUUID()}`, + ...(ownerUserId ? { ownerUserId } : {}), projectName: input.projectName.trim(), mainDocument, venue: input.venue, @@ -87,77 +90,88 @@ export class UploadService { } async uploadFile(id: string, requestedPath: string, body: ArrayBuffer): Promise { - const session = this.getMutableSession(id); - this.assertWritable(session); - const path = normalizeWorkspacePath(requestedPath); - const manifest = session.entries.find((entry) => entry.kind === "file" && entry.path === path); - if (!manifest) throw new ApiError(404, "manifest_entry_not_found", "The file is not part of this upload session"); - if (body.byteLength !== manifest.size) { - throw new ApiError(400, "size_mismatch", `Expected ${manifest.size} bytes but received ${body.byteLength}`); - } - if (manifest.checksum) { - const actual = Buffer.from(await crypto.subtle.digest("SHA-256", body)).toString("hex"); - if (actual !== manifest.checksum.toLowerCase()) throw new ApiError(400, "checksum_mismatch", `Checksum mismatch for '${path}'`); - } - const absolutePath = resolveWorkspacePath(this.stagingDirectory(id), path).absolutePath; - await mkdir(dirname(absolutePath), { recursive: true }); - await writeFile(absolutePath, new Uint8Array(body)); - await this.database.mutate((state) => { - const stored = state.uploadSessions.find((candidate) => candidate.id === id)!; - if (!stored.receivedPaths.includes(path)) { - stored.receivedPaths.push(path); - stored.receivedBytes += body.byteLength; + this.assertWritable(this.getMutableSession(id)); + return this.sessionQueue.run(id, async () => { + const session = this.getMutableSession(id); + this.assertWritable(session); + const path = normalizeWorkspacePath(requestedPath); + const manifest = session.entries.find((entry) => entry.kind === "file" && entry.path === path); + if (!manifest) throw new ApiError(404, "manifest_entry_not_found", "The file is not part of this upload session"); + if (body.byteLength !== manifest.size) { + throw new ApiError(400, "size_mismatch", `Expected ${manifest.size} bytes but received ${body.byteLength}`); + } + if (manifest.checksum) { + const actual = Buffer.from(await crypto.subtle.digest("SHA-256", body)).toString("hex"); + if (actual !== manifest.checksum.toLowerCase()) throw new ApiError(400, "checksum_mismatch", `Checksum mismatch for '${path}'`); } - stored.status = "uploading"; + const absolutePath = resolveWorkspacePath(this.stagingDirectory(id), path).absolutePath; + await mkdir(dirname(absolutePath), { recursive: true }); + await writeFile(absolutePath, new Uint8Array(body)); + await this.database.mutate((state) => { + const stored = state.uploadSessions.find((candidate) => candidate.id === id)!; + if (!stored.receivedPaths.includes(path)) { + stored.receivedPaths.push(path); + stored.receivedBytes += body.byteLength; + } + stored.status = "uploading"; + }); + return this.get(id); }); - return this.get(id); } async complete(id: string) { - const session = this.getMutableSession(id); - this.assertWritable(session); - const missing = session.entries.filter((entry) => entry.kind === "file" && !session.receivedPaths.includes(entry.path)); - if (missing.length > 0) throw new ApiError(409, "upload_incomplete", `${missing.length} files have not been uploaded`, { paths: missing.slice(0, 20).map((entry) => entry.path) }); - for (const entry of session.entries.filter((candidate) => candidate.kind === "file")) { - const info = await stat(resolveWorkspacePath(this.stagingDirectory(id), entry.path).absolutePath); - if (info.size !== entry.size) throw new ApiError(409, "size_mismatch", `Stored size does not match for '${entry.path}'`); - } - await this.database.mutate((state) => { - const stored = state.uploadSessions.find((candidate) => candidate.id === id)!; - stored.status = "completing"; - }); - try { - const project = await this.workspaces.importStagingDirectory({ - stagingDirectory: this.stagingDirectory(id), - name: session.projectName, - mainDocument: session.mainDocument, - venue: session.venue, - ...(session.publicationTarget ? { publicationTarget: session.publicationTarget } : {}), - source: { type: "local", displayName: session.sourceName } - }); - await this.database.mutate((state) => { - const stored = state.uploadSessions.find((candidate) => candidate.id === id)!; - stored.status = "completed"; - stored.projectId = project.id; - }); - return project; - } catch (error) { + return this.sessionQueue.run(id, async () => { + const existing = this.get(id); + if (existing.status === "completed" && existing.projectId) return this.workspaces.getProject(existing.projectId); + const session = this.getMutableSession(id); + this.assertWritable(session); + const missing = session.entries.filter((entry) => entry.kind === "file" && !session.receivedPaths.includes(entry.path)); + if (missing.length > 0) throw new ApiError(409, "upload_incomplete", `${missing.length} files have not been uploaded`, { paths: missing.slice(0, 20).map((entry) => entry.path) }); + for (const entry of session.entries.filter((candidate) => candidate.kind === "file")) { + const info = await stat(resolveWorkspacePath(this.stagingDirectory(id), entry.path).absolutePath); + if (info.size !== entry.size) throw new ApiError(409, "size_mismatch", `Stored size does not match for '${entry.path}'`); + } await this.database.mutate((state) => { const stored = state.uploadSessions.find((candidate) => candidate.id === id)!; - stored.status = "failed"; - stored.error = error instanceof Error ? error.message : String(error); + stored.status = "completing"; }); - throw error; - } + try { + const project = await this.workspaces.importStagingDirectory({ + stagingDirectory: this.stagingDirectory(id), + name: session.projectName, + mainDocument: session.mainDocument, + venue: session.venue, + ...(session.publicationTarget ? { publicationTarget: session.publicationTarget } : {}), + ...(session.ownerUserId ? { personalOwnerUserId: session.ownerUserId } : {}), + source: { type: "local", displayName: session.sourceName } + }); + await this.database.mutate((state) => { + const stored = state.uploadSessions.find((candidate) => candidate.id === id)!; + stored.status = "completed"; + stored.projectId = project.id; + }); + return project; + } catch (error) { + await this.database.mutate((state) => { + const stored = state.uploadSessions.find((candidate) => candidate.id === id)!; + stored.status = "failed"; + stored.error = error instanceof Error ? error.message : String(error); + }); + throw error; + } + }); } async cancel(id: string): Promise { - const session = this.getMutableSession(id); - if (session.status === "completed") throw new ApiError(409, "upload_completed", "A completed upload cannot be cancelled"); - await rm(this.stagingDirectory(id), { recursive: true, force: true }); - await this.database.mutate((state) => { - const stored = state.uploadSessions.find((candidate) => candidate.id === id)!; - stored.status = "cancelled"; + this.assertCancellable(this.getMutableSession(id)); + return this.sessionQueue.run(id, async () => { + const session = this.getMutableSession(id); + this.assertCancellable(session); + await rm(this.stagingDirectory(id), { recursive: true, force: true }); + await this.database.mutate((state) => { + const stored = state.uploadSessions.find((candidate) => candidate.id === id)!; + stored.status = "cancelled"; + }); }); } @@ -177,4 +191,9 @@ export class UploadService { throw new ApiError(409, "upload_not_writable", `Upload session is ${session.status}`); } } + + private assertCancellable(session: UploadSession): void { + if (session.status === "completed") throw new ApiError(409, "upload_completed", "A completed upload cannot be cancelled"); + if (session.status === "completing") throw new ApiError(409, "upload_not_writable", "An upload being completed cannot be cancelled"); + } } diff --git a/apps/server/src/web-static.test.ts b/apps/server/src/web-static.test.ts new file mode 100644 index 0000000..ff3c6fc --- /dev/null +++ b/apps/server/src/web-static.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, test } from "bun:test"; +import { gzipSync, gunzipSync } from "node:zlib"; +import { webFileResponse } from "./web-static"; + +const text = "export const message = 'FastWrite';\n".repeat(100); +const plain = new Blob([text]); +const compressed = new Blob([gzipSync(text)]); +const asset = "assets/index-1234abcd.js"; +const request = (encoding?: string, extra: Record = {}, method = "GET") => new Request(`http://localhost/write/${asset}`, { method, headers: { ...(encoding === undefined ? {} : { "accept-encoding": encoding }), ...extra } }); + +describe("static web resources", () => { + test("gzip is lossless and versioned assets are cached without changing their MIME type", async () => { + const response = webFileResponse(request("br, gzip"), asset, plain, compressed); + expect(response.headers.get("content-encoding")).toBe("gzip"); + expect(response.headers.get("content-type")).toBe("text/javascript; charset=utf-8"); + expect(response.headers.get("cache-control")).toBe("public, max-age=31536000, immutable"); + expect(response.headers.get("vary")).toBe("Accept-Encoding"); + expect(gunzipSync(new Uint8Array(await response.arrayBuffer())).toString()).toBe(text); + }); + + test("encoding quality, missing gzip siblings and range requests keep the original representation", async () => { + for (const encoding of [undefined, "br", "gzip;q=0", "*;q=1, gzip;q=0", "gzip;q=invalid"]) { + const response = webFileResponse(request(encoding), asset, plain, compressed); + expect(response.headers.get("content-encoding")).toBeNull(); + expect(await response.text()).toBe(text); + } + expect(webFileResponse(request("gzip"), asset, plain).headers.get("content-encoding")).toBeNull(); + expect(webFileResponse(request("gzip", { range: "bytes=0-99" }), asset, plain, compressed).headers.get("content-encoding")).toBeNull(); + expect(webFileResponse(request("GZIP; Q=0.5"), asset, plain, compressed).headers.get("content-encoding")).toBe("gzip"); + }); + + test("HTML and service workers revalidate; TeX data archives stay application bytes", async () => { + expect(webFileResponse(request("gzip"), "index.html", plain, compressed).headers.get("cache-control")).toBe("no-cache"); + expect(webFileResponse(request("gzip"), "sw.js", plain, compressed).headers.get("cache-control")).toBe("no-cache"); + const archive = webFileResponse(request("gzip"), "bundles/default.data.gz", compressed, compressed); + expect(archive.headers.get("content-encoding")).toBeNull(); + expect(archive.headers.get("content-type")).toBe("application/octet-stream"); + expect(await archive.arrayBuffer()).toEqual(await compressed.arrayBuffer()); + }); + + test("HEAD returns negotiated metadata without a body", async () => { + const response = webFileResponse(request("gzip", {}, "HEAD"), asset, plain, compressed); + expect(response.headers.get("content-length")).toBe(String(compressed.size)); + expect(response.headers.get("content-encoding")).toBe("gzip"); + expect(await response.text()).toBe(""); + }); +}); diff --git a/apps/server/src/web-static.ts b/apps/server/src/web-static.ts new file mode 100644 index 0000000..7e12105 --- /dev/null +++ b/apps/server/src/web-static.ts @@ -0,0 +1,52 @@ +import { extname } from "node:path"; + +const COMPRESSIBLE = /\.(?:html|js|mjs|css|svg|json)$/; +const FINGERPRINTED_ASSET = /^assets\/[^/]+-[A-Za-z0-9_-]{8,}\.[^/]+$/; + +function acceptsGzip(header: string | null): boolean { + const encodings = (header ?? "").split(",").map((item) => { + const [name = "", ...parameters] = item.trim().toLowerCase().split(";"); + const quality = parameters.map((parameter) => parameter.trim()).find((parameter) => parameter.startsWith("q=")); + const q = quality === undefined ? 1 : Number(quality.slice(2)); + return { name: name.trim(), q: Number.isFinite(q) && q >= 0 && q <= 1 ? q : 0 }; + }); + const explicit = encodings.find((encoding) => encoding.name === "gzip"); + return explicit ? explicit.q > 0 : encodings.some((encoding) => encoding.name === "*" && encoding.q > 0); +} + +/** Respond with a build-produced gzip sibling only when the client accepts it. */ +export function webFileResponse(request: Request, path: string, file: Blob, gzipFile?: Blob | null): Response { + const compressible = COMPRESSIBLE.test(path); + const useGzip = compressible && !request.headers.has("range") && gzipFile && acceptsGzip(request.headers.get("accept-encoding")); + const body = useGzip ? gzipFile : file; + const headers: Record = { + "content-type": webMimeType(extname(path)), + "content-length": String(body.size), + "cache-control": path.endsWith(".html") || path === "sw.js" ? "no-cache" : FINGERPRINTED_ASSET.test(path) ? "public, max-age=31536000, immutable" : "public, max-age=3600" + }; + if (compressible) headers.vary = "Accept-Encoding"; + if (useGzip) headers["content-encoding"] = "gzip"; + return new Response(request.method === "HEAD" ? null : body, { headers }); +} + +export function webMimeType(extension: string): string { + return ({ + ".html": "text/html; charset=utf-8", + ".js": "text/javascript; charset=utf-8", + ".mjs": "text/javascript; charset=utf-8", + ".css": "text/css; charset=utf-8", + ".json": "application/json; charset=utf-8", + ".webmanifest": "application/manifest+json", + ".svg": "image/svg+xml", + ".png": "image/png", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".webp": "image/webp", + ".gif": "image/gif", + ".ico": "image/x-icon", + ".woff": "font/woff", + ".woff2": "font/woff2", + ".pdf": "application/pdf", + ".wasm": "application/wasm" + } as Record)[extension] ?? "application/octet-stream"; +} diff --git a/apps/server/src/workspace/workspace-service.ts b/apps/server/src/workspace/workspace-service.ts index d96705b..6dcccea 100644 --- a/apps/server/src/workspace/workspace-service.ts +++ b/apps/server/src/workspace/workspace-service.ts @@ -99,6 +99,7 @@ export class WorkspaceService { mainDocument: string; venue: TargetVenue; publicationTarget?: PublicationTarget; + personalOwnerUserId?: string; source: ImportSource; }): Promise { const id = projectId(); @@ -115,7 +116,7 @@ export class WorkspaceService { await rm(projectDirectory, { recursive: true, force: true }); throw new ApiError(400, "main_document_missing", `Main document '${normalizedMain}' was not uploaded`); } - return this.registerProject(id, input.name, normalizedMain, input.venue, input.source, files, input.publicationTarget); + return this.registerProject(id, input.name, normalizedMain, input.venue, input.source, files, input.publicationTarget, input.personalOwnerUserId); } async copyExternalDirectory(input: { @@ -124,6 +125,7 @@ export class WorkspaceService { mainDocument?: string; venue: TargetVenue; publicationTarget?: PublicationTarget; + personalOwnerUserId?: string; source: ImportSource; }): Promise { const temporary = join(this.dataDirectory, "imports", crypto.randomUUID()); @@ -139,6 +141,7 @@ export class WorkspaceService { mainDocument, venue: input.venue, ...(input.publicationTarget ? { publicationTarget: input.publicationTarget } : {}), + ...(input.personalOwnerUserId ? { personalOwnerUserId: input.personalOwnerUserId } : {}), source: input.source }); } @@ -533,7 +536,8 @@ export class WorkspaceService { venue: TargetVenue, source: ImportSource, files: string[], - publicationTarget?: PublicationTarget + publicationTarget?: PublicationTarget, + personalOwnerUserId?: string ): Promise { const timestamp = now(); const skill = paperSkillForProfile(venue); @@ -545,6 +549,7 @@ export class WorkspaceService { skill, ...(target ? { publicationTarget: target } : {}), source, + ...(personalOwnerUserId ? { personalOwnerUserId, visibility: "private" as const } : {}), createdAt: timestamp, updatedAt: timestamp, version: 1 @@ -552,6 +557,10 @@ export class WorkspaceService { await this.database.mutate((state) => { state.projects.push(project); state.fileVersions[id] = Object.fromEntries(files.map((path) => [path, { version: 1, updatedAt: timestamp }])); + if (personalOwnerUserId) { + state.projectMembers.push({ projectId: id, userId: personalOwnerUserId, role: "owner", createdAt: timestamp, updatedAt: timestamp }); + state.auditEvents.push({ id: `audit_${crypto.randomUUID()}`, actorUserId: personalOwnerUserId, action: "project.create", resourceType: "project", resourceId: id, createdAt: timestamp }); + } }); await this.snapshotHistory(id, "Import paper snapshot"); return project; diff --git a/apps/web/build/startupAssets.ts b/apps/web/build/startupAssets.ts new file mode 100644 index 0000000..c8a5b77 --- /dev/null +++ b/apps/web/build/startupAssets.ts @@ -0,0 +1,49 @@ +import { gzipSync } from "node:zlib"; +import type { Plugin } from "vite"; + +const HEAVY_MODULE = /\/node_modules\/(?:.*\/)?(?:monaco-editor|pdfjs-dist|react-pdf|@siglum\/[^/]+|xzwasm|blake3-wasm)(?:\/|@)/; + +/** Assert the actual emitted graph, so future shared imports cannot undo lazy routing. */ +export function startupAssets(): Plugin { + return { + name: "fastwrite-startup-assets", + enforce: "post", + generateBundle: { + order: "post", + handler(_options, bundle) { + const chunks = Object.values(bundle).filter((item) => item.type === "chunk"); + const initial = chunks.filter((chunk) => chunk.isEntry || chunk.facadeModuleId?.replaceAll("\\", "/").endsWith("/pages/ProjectsPage.tsx")); + const visited = new Set(); + const visit = (fileName: string): void => { + if (visited.has(fileName)) return; + visited.add(fileName); + const chunk = bundle[fileName]; + if (chunk?.type !== "chunk") return; + const heavyweight = Object.keys(chunk.modules).find((id) => HEAVY_MODULE.test(id.replaceAll("\\", "/"))); + if (heavyweight) this.error(`The home/project-list startup depends on an editor-only module: ${heavyweight} (in ${fileName})`); + chunk.imports.forEach(visit); + }; + initial.forEach((chunk) => visit(chunk.fileName)); + this.emitFile({ + type: "asset", + fileName: "startup-assets.json", + source: JSON.stringify({ + checked: ["entry", "project-list"], + chunks: [...visited].map((fileName) => { + const chunk = bundle[fileName]; + return { fileName, bytes: chunk?.type === "chunk" ? Buffer.byteLength(chunk.code) : 0 }; + }), + editorOnly: chunks.filter((chunk) => Object.keys(chunk.modules).some((id) => HEAVY_MODULE.test(id.replaceAll("\\", "/")))).map((chunk) => chunk.fileName) + }, null, 2) + }); + // Precompress text assets once at build time. TeX .data.gz bundles are + // application archives and must retain their original HTTP encoding. + for (const item of Object.values(bundle)) { + if (!/\.(?:html|js|mjs|css|svg|json)$/.test(item.fileName)) continue; + const source = item.type === "chunk" ? item.code : item.source; + this.emitFile({ type: "asset", fileName: `${item.fileName}.gz`, source: gzipSync(source, { level: 9 }) }); + } + } + } + }; +} diff --git a/apps/web/index.html b/apps/web/index.html index db84ba6..241ee10 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -10,7 +10,17 @@ FastWrite -
+
+
+
+

FastWrite

+

Loading your writing workspace…

+ + +
+
+
+ diff --git a/apps/web/public/boot.js b/apps/web/public/boot.js new file mode 100644 index 0000000..da8faaf --- /dev/null +++ b/apps/web/public/boot.js @@ -0,0 +1,15 @@ +// This tiny independent script can offer recovery even if the app bundle fails. +(() => { + const retry = document.getElementById("fastwrite-boot-retry"); + retry?.addEventListener("click", () => window.location.reload()); + const showRecovery = (failed) => { + if (!document.getElementById("fastwrite-boot")) return; + const message = document.getElementById("fastwrite-boot-message"); + if (message) message.textContent = failed ? "FastWrite could not load. Check your connection and reload." : "Loading is taking longer than usual. You can reload to try again."; + if (retry) retry.hidden = false; + }; + window.addEventListener("error", (event) => { + if (event.target instanceof HTMLScriptElement) showRecovery(true); + }, true); + window.setTimeout(() => showRecovery(false), 15_000); +})(); diff --git a/apps/web/public/sw.js b/apps/web/public/sw.js index 3ff905a..7570f39 100644 --- a/apps/web/public/sw.js +++ b/apps/web/public/sw.js @@ -1,20 +1,37 @@ -const CACHE = "fastwrite-shell-v2"; -const SHELL = ["/", "/index.html", "/manifest.webmanifest", "/busytex.js", "/busytex.wasm", "/worker.js"]; +const BASE = new URL("./", self.location.href); +const CACHE = `fastwrite-shell-v3:${BASE.pathname}`; +const INDEX = new URL("index.html", BASE).href; +const API_PATH = new URL("api/", BASE).pathname; +const SHELL = [INDEX, new URL("manifest.webmanifest", BASE).href]; self.addEventListener("install", (event) => { event.waitUntil(caches.open(CACHE).then((cache) => cache.addAll(SHELL)).then(() => self.skipWaiting())); }); self.addEventListener("activate", (event) => { - event.waitUntil(caches.keys().then((keys) => Promise.all(keys.filter((key) => key !== CACHE).map((key) => caches.delete(key)))).then(() => self.clients.claim())); + event.waitUntil(caches.keys().then((keys) => Promise.all(keys.filter((key) => key !== CACHE && (/^fastwrite-shell-v\d+$/.test(key) || (key.startsWith("fastwrite-shell-") && key.endsWith(`:${BASE.pathname}`)))).map((key) => caches.delete(key)))).then(() => self.clients.claim())); }); self.addEventListener("fetch", (event) => { const request = event.request; const url = new URL(request.url); - if (request.method !== "GET" || url.origin !== self.location.origin || url.pathname.startsWith("/api/")) return; - event.respondWith(caches.match(request).then((cached) => cached || fetch(request).then((response) => { - if (!response.ok || response.type === "opaque") return response; - const copy = response.clone(); - void caches.open(CACHE).then((cache) => cache.put(request, copy)); + if (request.method !== "GET" || url.origin !== self.location.origin || !url.pathname.startsWith(BASE.pathname) || url.pathname.startsWith(API_PATH) || request.headers.has("Authorization")) return; + const immutable = /^assets\/[^/]+-[A-Za-z0-9_-]{8,}\.[^/]+$/.test(url.pathname.slice(BASE.pathname.length)); + const fetchAndCache = async () => { + const response = await fetch(request); + if (response.ok && response.type !== "opaque") { + const copy = response.clone(); + const key = request.mode === "navigate" ? INDEX : request; + await caches.open(CACHE).then((cache) => cache.put(key, copy)).catch(() => undefined); + } return response; - }).catch(() => caches.match("/index.html"))); + }; + event.respondWith((async () => { + const cached = await caches.match(request).catch(() => undefined); + if (immutable && cached) return cached; + try { return await fetchAndCache(); } + catch { + // Only document navigations receive the offline HTML shell. Returning + // HTML for a failed JS/CSS fetch hides its failure and breaks recovery. + return cached || (request.mode === "navigate" ? await caches.match(INDEX).catch(() => undefined) : undefined) || Response.error(); + } + })()); }); diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index f4511f2..e01fce3 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -1,8 +1,9 @@ -import { lazy, Suspense, useEffect, useState } from "react"; +import { Component, lazy, Suspense, useEffect, useState, type ReactNode } from "react"; import { FASTWRITE_SAVE_EVENT, isSaveShortcut } from "./lib/keyboard"; import { applyTheme, initialTheme } from "./lib/theme"; import { api } from "./api/client"; -import { Link } from "./components/ui"; +import { Button, Link } from "./components/ui"; +import { appPath, relativeAppPath } from "./lib/appBase"; const ProjectsPage = lazy(() => import("./pages/ProjectsPage").then((module) => ({ default: module.ProjectsPage }))); const DiagramsPage = lazy(() => import("./pages/DiagramsPage").then((module) => ({ default: module.DiagramsPage }))); @@ -13,18 +14,40 @@ const AccessRequestPage = lazy(() => import("./pages/AccessRequestPage").then((m const AdminPage = lazy(() => import("./pages/AdminPage").then((module) => ({ default: module.AdminPage }))); function routeFromLocation(): { name: "admin" } | { name: "diagrams" } | { name: "projects" } | { name: "gallery" } | { name: "workspace"; projectId: string } | { name: "shared"; token: string } | { name: "access-request"; projectId: string } { - if (window.location.pathname === "/admin") return { name: "admin" }; - if (window.location.pathname === "/diagrams") return { name: "diagrams" }; - if (window.location.pathname === "/components") return { name: "gallery" }; - const shared = window.location.pathname.match(/^\/shared\/([^/]+)\/?$/); + const pathname = relativeAppPath(window.location.pathname); + if (pathname === "/admin") return { name: "admin" }; + if (pathname === "/diagrams") return { name: "diagrams" }; + if (pathname === "/components") return { name: "gallery" }; + const shared = pathname.match(/^\/shared\/([^/]+)\/?$/); if (shared?.[1]) return { name: "shared", token: decodeURIComponent(shared[1]) }; - const accessRequest = window.location.pathname.match(/^\/request-access\/([^/]+)\/?$/); + const accessRequest = pathname.match(/^\/request-access\/([^/]+)\/?$/); if (accessRequest?.[1]) return { name: "access-request", projectId: decodeURIComponent(accessRequest[1]) }; - const match = window.location.pathname.match(/^\/projects\/([^/]+)\/?$/); + const match = pathname.match(/^\/projects\/([^/]+)\/?$/); if (match?.[1]) return { name: "workspace", projectId: decodeURIComponent(match[1]) }; return { name: "projects" }; } +function AppLoading({ message = "Loading FastWrite…", failed = false }: { message?: string; failed?: boolean }) { + const [slow, setSlow] = useState(false); + useEffect(() => { + const timer = window.setTimeout(() => setSlow(true), 15_000); + return () => window.clearTimeout(timer); + }, []); + return
+

FastWrite

+

{message}

+ {(failed || slow) && <>

Check your connection, then reload to try again.

} +
; +} + +class PageLoadBoundary extends Component<{ children: ReactNode }, { failed: boolean }> { + state = { failed: false }; + static getDerivedStateFromError() { return { failed: true }; } + render() { + return this.state.failed ? : this.props.children; + } +} + export function App() { const [route, setRoute] = useState(routeFromLocation); const [authenticationReady, setAuthenticationReady] = useState(() => { @@ -63,7 +86,8 @@ export function App() { window.addEventListener("keydown", save, { capture: true }); return () => window.removeEventListener("keydown", save, { capture: true }); }, []); - if (!authenticationReady) return
Completing sign-in…
; + if (!authenticationReady) return ; const page = route.name === "admin" ? : route.name === "diagrams" ? : route.name === "workspace" ? : route.name === "shared" ? : route.name === "access-request" ? : route.name === "gallery" ? : ; - return Loading FastWrite…}>{route.name !== "diagrams" && 科研绘图}{page}; + const routeKey = "projectId" in route ? `${route.name}:${route.projectId}` : "token" in route ? `${route.name}:${route.token}` : route.name; + return }>{route.name !== "diagrams" && 科研绘图}{page}; } diff --git a/apps/web/src/api/client.ts b/apps/web/src/api/client.ts index 0e2639a..a6a1d03 100644 --- a/apps/web/src/api/client.ts +++ b/apps/web/src/api/client.ts @@ -48,6 +48,8 @@ import type { ,ResearchWork, ResearchRun, ProjectResearchWork, ProjectResearchWorkDetails, PaperClaim, SourceEvidence, FastReadBundleReceipt, ClaimEvidenceLink, EvidenceCockpit, CitationReviewer, PublishedSkillDescriptor ,AlignmentFinding, SupportAccessRequest } from "@fastwrite/shared"; +import { attachmentFilename, type DownloadFile } from "./download"; +import { appPath } from "../lib/appBase"; export class ApiClientError extends Error { constructor(public readonly status: number, public readonly code: string, message: string, public readonly details?: unknown) { @@ -55,18 +57,18 @@ export class ApiClientError extends Error { } } -async function request(path: string, init?: RequestInit): Promise { +async function requestResponse(path: string, init?: RequestInit): Promise { const headers = new Headers(init?.headers); const token = localStorage.getItem("fastwrite.session-token"); if (token && !headers.has("authorization")) headers.set("authorization", `Bearer ${token}`); - let response = await fetch(path, { ...init, headers }); + let response = await fetch(appPath(path), { ...init, headers }); if (response.status === 401 && token && !path.startsWith("/api/auth/")) { - const refreshed = await fetch("/api/auth/refresh", { method: "POST" }); + const refreshed = await fetch(appPath("/api/auth/refresh"), { method: "POST" }); if (refreshed.ok) { const session = await refreshed.json() as { token: string }; localStorage.setItem("fastwrite.session-token", session.token); headers.set("authorization", `Bearer ${session.token}`); - response = await fetch(path, { ...init, headers }); + response = await fetch(appPath(path), { ...init, headers }); } else localStorage.removeItem("fastwrite.session-token"); } if (!response.ok) { @@ -78,6 +80,11 @@ async function request(path: string, init?: RequestInit): Promise { } throw new ApiClientError(response.status, body.error?.code ?? "request_failed", body.error?.message ?? `Request failed (${response.status})`, body.error?.details); } + return response; +} + +async function request(path: string, init?: RequestInit): Promise { + const response = await requestResponse(path, init); if (response.status === 204) return undefined as T; return (await response.json()) as T; } @@ -190,7 +197,16 @@ export const api = { get: (id: string, signal?: AbortSignal) => request(`/api/projects/${id}`, signal ? { signal } : undefined), create: (body: { name: string; mainDocument?: string; venue?: TargetVenue; publicationTarget?: PublicationTarget; initializeFromTemplate?: boolean; teamId?: string }) => request("/api/projects", jsonInit("POST", body)), update: (id: string, body: Partial> & { venue?: TargetVenue; publicationTarget?: PublicationTarget | null }) => request(`/api/projects/${id}`, jsonInit("PATCH", body)), - exportUrl: (id: string) => `/api/projects/${id}/export`, + exportArchive: async (id: string, signal?: AbortSignal): Promise => { + const response = await requestResponse(`/api/projects/${encodeURIComponent(id)}/export`, signal ? { signal } : undefined); + const contentType = response.headers.get("content-type")?.split(";")[0]?.trim().toLowerCase(); + if (contentType !== "application/gzip" && contentType !== "application/x-gzip" && contentType !== "application/octet-stream") { + throw new ApiClientError(response.status, "invalid_export", "The server did not return a source archive. Please try exporting again."); + } + const blob = await response.blob(); + if (!blob.size) throw new ApiClientError(response.status, "empty_export", "The source archive is empty. Please try exporting again."); + return { blob, filename: attachmentFilename(response.headers.get("content-disposition"), "fastwrite-source.tar.gz") }; + }, checkpoint: (id: string) => request<{ createdAt: string }>(`/api/projects/${id}/history/checkpoint`, { method: "POST" }), history: (id: string, limit = 50, signal?: AbortSignal) => request(`/api/projects/${id}/history?limit=${limit}`, signal ? { signal } : undefined), historySummary: (id: string, oid: string, signal?: AbortSignal) => request(`/api/projects/${id}/history/${encodeURIComponent(oid)}`, signal ? { signal } : undefined), @@ -222,7 +238,7 @@ export const api = { historyFile: (id: string, oid: string, path: string, signal?: AbortSignal) => request<{ path: string; content: string }>(`/api/projects/${id}/history/${encodeURIComponent(oid)}/file?path=${encodeURIComponent(path)}`, signal ? { signal } : undefined), restoreHistory: (id: string, oid: string, paths: string[], expectedVersion?: number) => request<{ oid?: string; restored: string[] }>(`/api/projects/${id}/history/${encodeURIComponent(oid)}/restore`, jsonInit("POST", { paths, ...(expectedVersion !== undefined ? { expectedVersion } : {}) })), provenance: (id: string, signal?: AbortSignal) => request(`/api/projects/${id}/provenance`, signal ? { signal } : undefined), - provenanceExport: (id: string) => request(`/api/projects/${id}/provenance/export`), + provenanceExport: (id: string) => request(`/api/projects/${encodeURIComponent(id)}/provenance/export`), createShare: (id: string, permission: "read" | "comment") => request<{ id: string; token: string; permission: "read" | "comment"; createdAt: string }>(`/api/projects/${id}/shares`, jsonInit("POST", { permission })), shares: (id: string) => request>(`/api/projects/${id}/shares`), revokeShare: (id: string, shareId: string) => request(`/api/projects/${id}/shares/${shareId}`, { method: "DELETE" }), @@ -255,6 +271,14 @@ export const api = { outline: (id: string, signal?: AbortSignal) => request(`/api/projects/${id}/outline`, signal ? { signal } : undefined), search: (id: string, query: string, signal?: AbortSignal) => request<{ query: string; matches: Array<{ path: string; line: number; excerpt: string }>; truncated: boolean }>(`/api/projects/${id}/search?query=${encodeURIComponent(query)}`, signal ? { signal } : undefined), readFile: (id: string, path: string, signal?: AbortSignal) => request(`/api/projects/${id}/file?path=${encodeURIComponent(path)}`, signal ? { signal } : undefined), + readAsset: async (id: string, path: string, signal?: AbortSignal): Promise => { + const response = await requestResponse(`/api/projects/${encodeURIComponent(id)}/asset?path=${encodeURIComponent(path)}`, signal ? { signal } : undefined); + return response.arrayBuffer(); + }, + assetBlob: async (id: string, path: string, signal?: AbortSignal): Promise => { + const response = await requestResponse(`/api/projects/${encodeURIComponent(id)}/asset?path=${encodeURIComponent(path)}`, signal ? { signal } : undefined); + return response.blob(); + }, saveFile: (id: string, path: string, body: SaveFileRequest, signal?: AbortSignal) => request(`/api/projects/${id}/file?path=${encodeURIComponent(path)}`, jsonInit("PUT", body, signal)), createFile: (id: string, path: string, content = "") => request(`/api/projects/${id}/files`, jsonInit("POST", { path, content })), addFile: (id: string, path: string, file: File, signal?: AbortSignal) => request(`/api/projects/${id}/assets?path=${encodeURIComponent(path)}`, { method: "PUT", headers: { "content-type": "application/octet-stream" }, body: file, ...(signal ? { signal } : {}) }), diff --git a/apps/web/src/api/download.ts b/apps/web/src/api/download.ts new file mode 100644 index 0000000..fe30973 --- /dev/null +++ b/apps/web/src/api/download.ts @@ -0,0 +1,44 @@ +export interface DownloadFile { + blob: Blob; + filename: string; +} + +/** Prefer the UTF-8 filename from the server, then its quoted/plain fallback. */ +export function attachmentFilename(disposition: string | null, fallback: string): string { + const extended = disposition?.match(/(?:^|;)\s*filename\*\s*=\s*(?:"([^"]*)"|([^;]*))/i); + const encoded = (extended?.[1] ?? extended?.[2])?.trim(); + if (encoded) { + const utf8 = encoded.match(/^utf-8'[^']*'(.*)$/i); + if (utf8) { + try { + const filename = safeFilename(decodeURIComponent(utf8[1] ?? "")); + if (filename) return filename; + } catch { + // A malformed extended value must not hide the ordinary filename. + } + } + } + const plain = disposition?.match(/(?:^|;)\s*filename\s*=\s*(?:"((?:\\.|[^"\\])*)"|([^;]*))/i); + return safeFilename((plain?.[1]?.replace(/\\(.)/g, "$1") ?? plain?.[2] ?? "").trim()) || fallback; +} + +function safeFilename(value: string): string { + return value.split(/[\\/]/).pop()?.replace(/[\u0000-\u001f\u007f]/g, "").trim().replace(/^\.+$/, "") ?? ""; +} + +/** Download a fetched file without navigating away or exposing its auth token. */ +export function downloadFile({ blob, filename }: DownloadFile): void { + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = filename; + link.hidden = true; + try { + document.body.appendChild(link); + link.click(); + } finally { + link.remove(); + // Give the browser time to begin consuming the blob before releasing it. + setTimeout(() => URL.revokeObjectURL(url), 1000); + } +} diff --git a/apps/web/src/api/exportArchive.test.ts b/apps/web/src/api/exportArchive.test.ts new file mode 100644 index 0000000..957c089 --- /dev/null +++ b/apps/web/src/api/exportArchive.test.ts @@ -0,0 +1,173 @@ +import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; +import { api, ApiClientError } from "./client"; +import { attachmentFilename } from "./download"; +import { projectExportErrorMessage } from "../lib/projectExport"; +import { appPath } from "../lib/appBase"; + +const storage = new Map(); +const originalFetch = globalThis.fetch; +const originalStorage = Object.getOwnPropertyDescriptor(globalThis, "localStorage"); +const fetchMock = mock(async (_path: RequestInfo | URL, _init?: RequestInit): Promise => { + throw new Error("Unexpected request"); +}); + +beforeEach(() => { + storage.clear(); + fetchMock.mockReset(); + globalThis.fetch = fetchMock as unknown as typeof fetch; + Object.defineProperty(globalThis, "localStorage", { configurable: true, value: { + getItem: (key: string) => storage.get(key) ?? null, + setItem: (key: string, value: string) => { storage.set(key, value); }, + removeItem: (key: string) => { storage.delete(key); } + } }); +}); + +afterEach(() => { + globalThis.fetch = originalFetch; + if (originalStorage) Object.defineProperty(globalThis, "localStorage", originalStorage); + else Reflect.deleteProperty(globalThis, "localStorage"); +}); + +function archive(filename = 'attachment; filename="My-Paper.tar.gz"'): Response { + return new Response(new Uint8Array([0x1f, 0x8b, 0x08, 0x00]), { + headers: { "content-type": "application/gzip", "content-disposition": filename } + }); +} + +describe("authenticated source export", () => { + test("sends the session header and safely encodes the project id", async () => { + storage.set("fastwrite.session-token", "session-token"); + fetchMock.mockImplementation(async () => archive()); + + const result = await api.projects.exportArchive("paper/a ?"); + + expect(fetchMock).toHaveBeenCalledTimes(1); + const [path, init] = fetchMock.mock.calls[0]!; + expect(path).toBe(appPath("/api/projects/paper%2Fa%20%3F/export")); + expect(new Headers(init?.headers).get("authorization")).toBe("Bearer session-token"); + expect(result.filename).toBe("My-Paper.tar.gz"); + expect(Array.from(new Uint8Array(await result.blob.arrayBuffer()))).toEqual([0x1f, 0x8b, 0x08, 0x00]); + }); + + test("refreshes an expired session before retrying the download", async () => { + storage.set("fastwrite.session-token", "expired-token"); + fetchMock.mockImplementation(async (path, init) => { + if (path === appPath("/api/auth/refresh")) { + expect(init?.method).toBe("POST"); + return Response.json({ token: "refreshed-token" }); + } + return new Headers(init?.headers).get("authorization") === "Bearer refreshed-token" + ? archive() + : Response.json({ error: { code: "unauthenticated", message: "Session expired" } }, { status: 401 }); + }); + + const result = await api.projects.exportArchive("paper"); + + expect(result.blob.size).toBe(4); + expect(fetchMock).toHaveBeenCalledTimes(3); + expect(storage.get("fastwrite.session-token")).toBe("refreshed-token"); + expect(fetchMock.mock.calls.map(([path]) => path)).toEqual([ + "/api/projects/paper/export", "/api/auth/refresh", "/api/projects/paper/export" + ].map((path) => appPath(path))); + }); + + test("returns a useful error when sign-in cannot be refreshed", async () => { + storage.set("fastwrite.session-token", "expired-token"); + fetchMock.mockImplementation(async () => Response.json({ error: { code: "unauthenticated", message: "Sign in required" } }, { status: 401 })); + + let failure: unknown; + try { await api.projects.exportArchive("paper"); } catch (error) { failure = error; } + + expect(failure).toBeInstanceOf(ApiClientError); + expect(projectExportErrorMessage(failure)).toContain("Sign in again"); + expect(storage.has("fastwrite.session-token")).toBe(false); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + test("preserves denied export permissions and does not retry a 403", async () => { + fetchMock.mockImplementation(async () => Response.json({ error: { code: "forbidden", message: "Export denied" } }, { status: 403 })); + + let failure: unknown; + try { await api.projects.exportArchive("paper"); } catch (error) { failure = error; } + + expect(projectExportErrorMessage(failure)).toContain("permission to export"); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + test("does not turn an HTML error page or an empty response into an archive", async () => { + fetchMock.mockImplementation(async () => new Response("Sign in", { headers: { "content-type": "text/html" } })); + await expect(api.projects.exportArchive("paper")).rejects.toMatchObject({ code: "invalid_export" }); + + fetchMock.mockImplementation(async () => new Response(null, { headers: { "content-type": "application/gzip" } })); + await expect(api.projects.exportArchive("paper")).rejects.toMatchObject({ code: "empty_export" }); + }); + + test("forwards cancellation through the same API request", async () => { + const controller = new AbortController(); + fetchMock.mockImplementation(async (_path, init) => { + expect(init?.signal).toBe(controller.signal); + throw new DOMException("Cancelled", "AbortError"); + }); + await expect(api.projects.exportArchive("paper", controller.signal)).rejects.toMatchObject({ name: "AbortError" }); + }); +}); + +describe("download filenames", () => { + test("prefers the UTF-8 filename and handles ordinary attachment names", () => { + expect(attachmentFilename("attachment; filename=paper.tar.gz; filename*=UTF-8''%E8%AE%BA%E6%96%87.tar.gz", "fallback.tar.gz")).toBe("论文.tar.gz"); + expect(attachmentFilename('attachment; filename="Paper with spaces.tar.gz"', "fallback.tar.gz")).toBe("Paper with spaces.tar.gz"); + }); + + test("falls back on malformed encoding and strips filename paths", () => { + expect(attachmentFilename("attachment; filename=paper.tar.gz; filename*=UTF-8''%ZZ", "fallback.tar.gz")).toBe("paper.tar.gz"); + expect(attachmentFilename('attachment; filename="../../paper.tar.gz"', "fallback.tar.gz")).toBe("paper.tar.gz"); + expect(attachmentFilename(null, "fallback.tar.gz")).toBe("fallback.tar.gz"); + }); +}); + +describe("authenticated assets and provenance", () => { + test("reads image bytes with session authentication, encoded paths and cancellation", async () => { + storage.set("fastwrite.session-token", "session-token"); + const controller = new AbortController(); + const image = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0, 255, 128]); + fetchMock.mockImplementation(async () => new Response(image, { headers: { "content-type": "image/png" } })); + + const result = await api.projects.readAsset("paper/a", "figures/图 1.png", controller.signal); + + expect(Array.from(new Uint8Array(result))).toEqual(Array.from(image)); + const [path, init] = fetchMock.mock.calls[0]!; + expect(path).toBe(appPath("/api/projects/paper%2Fa/asset?path=figures%2F%E5%9B%BE%201.png")); + expect(new Headers(init?.headers).get("authorization")).toBe("Bearer session-token"); + expect(init?.signal).toBe(controller.signal); + }); + + test("propagates asset permission errors instead of reading them as image bytes", async () => { + fetchMock.mockImplementation(async () => Response.json({ error: { code: "forbidden", message: "Image access denied" } }, { status: 403 })); + await expect(api.projects.readAsset("paper", "private.png")).rejects.toMatchObject({ status: 403, code: "forbidden", message: "Image access denied" }); + }); + + test("preserves the image MIME type for authenticated asset previews", async () => { + storage.set("fastwrite.session-token", "session-token"); + const image = new Uint8Array([0x89, 0x50, 0x4e, 0x47]); + fetchMock.mockImplementation(async () => new Response(image, { headers: { "content-type": "image/png" } })); + + const result = await api.projects.assetBlob("paper/a", "figures/result.png"); + + expect(result.type).toBe("image/png"); + expect(Array.from(new Uint8Array(await result.arrayBuffer()))).toEqual(Array.from(image)); + const [path, init] = fetchMock.mock.calls[0]!; + expect(path).toBe(appPath("/api/projects/paper%2Fa/asset?path=figures%2Fresult.png")); + expect(new Headers(init?.headers).get("authorization")).toBe("Bearer session-token"); + }); + + test("exports provenance through the authenticated JSON client", async () => { + storage.set("fastwrite.session-token", "session-token"); + const dossier = { projectId: "paper/a", runs: [], disclosureDraft: "AI usage" }; + fetchMock.mockImplementation(async () => Response.json(dossier)); + + expect(await api.projects.provenanceExport("paper/a")).toEqual(dossier); + const [path, init] = fetchMock.mock.calls[0]!; + expect(path).toBe(appPath("/api/projects/paper%2Fa/provenance/export")); + expect(new Headers(init?.headers).get("authorization")).toBe("Bearer session-token"); + }); +}); diff --git a/apps/web/src/components/FastCASDialog.tsx b/apps/web/src/components/FastCASDialog.tsx index 045dacc..36c2173 100644 --- a/apps/web/src/components/FastCASDialog.tsx +++ b/apps/web/src/components/FastCASDialog.tsx @@ -1,6 +1,7 @@ import { useEffect, useState } from "react"; import { api } from "../api/client"; import { Button, Dialog, Field, TextField } from "./ui"; +import { appPath } from "../lib/appBase"; export function FastCASDialog({ open, onClose }: { open: boolean; onClose: () => void }) { const [status, setStatus] = useState>>(); @@ -28,11 +29,11 @@ export function FastCASDialog({ open, onClose }: { open: boolean; onClose: () => {proof?.method === "password" ? : null} {proof?.method === "password" && proof.localLoginId ?

Local sign-in ID: {proof.localLoginId}. Keep this ID and your password for sign-in if FastCAS is unavailable.

: null} {proof?.method === "external" ?

{proof.recent ? "Your recent organization or campus sign-in proves this existing account for the next five minutes." : "Sign in again with your original organization or campus account, then return here within five minutes."}

: null} - {proof?.method === "external" && !proof.recent && providers?.oidc ? : null} - {proof?.method === "external" && !proof.recent && providers?.cas ? : null} + {proof?.method === "external" && !proof.recent && providers?.oidc ? : null} + {proof?.method === "external" && !proof.recent && providers?.cas ? : null} {proof?.method === "none" && current?.state === "active" ? <>

{proof.recent ? "Add a local password while this FastCAS sign-in is recent. Your projects and account ID will stay the same." : "Sign in with FastCAS again, then return within five minutes to add a local password."}

- {!proof.recent ? : <> + {!proof.recent ? : <> } diff --git a/apps/web/src/components/ui/chrome/Link.tsx b/apps/web/src/components/ui/chrome/Link.tsx index 88858a1..a6210a6 100644 --- a/apps/web/src/components/ui/chrome/Link.tsx +++ b/apps/web/src/components/ui/chrome/Link.tsx @@ -1,5 +1,6 @@ import type { AnchorHTMLAttributes, ReactNode } from "react"; import styles from "./Link.module.css"; +import { appPath } from "../../../lib/appBase"; export type LinkTarget = | { kind: "internal"; href: string } @@ -39,6 +40,7 @@ export interface LinkProps extends Omit, export function Link({ href, children, variant = "text", className = "", onClick, download, ...rest }: LinkProps) { const target = linkTarget(href); + const resolvedHref = target.kind === "internal" ? appPath(href) : href; /* * The button variant reuses the global button classes so it matches the * export/download anchors elsewhere in the app. The text variant needs its @@ -53,7 +55,7 @@ export function Link({ href, children, variant = "text", className = "", onClick return ( diff --git a/apps/web/src/components/workspace/ProjectExportButton.tsx b/apps/web/src/components/workspace/ProjectExportButton.tsx new file mode 100644 index 0000000..9f78ae3 --- /dev/null +++ b/apps/web/src/components/workspace/ProjectExportButton.tsx @@ -0,0 +1,56 @@ +import { useEffect, useRef, useState } from "react"; +import { downloadProjectArchive, projectExportErrorMessage } from "../../lib/projectExport"; +import { Button, type ButtonProps } from "../ui/primitives/Button"; +import { Icon } from "../ui/primitives/Icon"; +import { icons } from "../ui/icons"; + +interface ProjectExportButtonProps extends Omit { + projectId: string; + beforeExport?: () => Promise; + onError?: (message: string) => void; + onExported?: () => void; +} + +export function ProjectExportButton({ projectId, beforeExport, onError, onExported, children = "Export source", icon = , ...props }: ProjectExportButtonProps) { + const [busy, setBusy] = useState(false); + const [error, setError] = useState(""); + const controller = useRef(null); + useEffect(() => { + controller.current?.abort(); + controller.current = null; + setBusy(false); + setError(""); + return () => controller.current?.abort(); + }, [projectId]); + + const download = async () => { + if (controller.current) return; + const pending = new AbortController(); + controller.current = pending; + setError(""); + onError?.(""); + setBusy(true); + try { + await beforeExport?.(); + pending.signal.throwIfAborted(); + await downloadProjectArchive(projectId, pending.signal); + if (!pending.signal.aborted) onExported?.(); + } catch (failure) { + if (!pending.signal.aborted) { + const message = projectExportErrorMessage(failure); + setError(message); + onError?.(message); + } + } finally { + if (controller.current === pending) { + controller.current = null; + if (!pending.signal.aborted) setBusy(false); + } + } + }; + + return <> + + {!onError && error ? {error} : null} + ; +} diff --git a/apps/web/src/components/workspace/ProjectSettingsEditor.tsx b/apps/web/src/components/workspace/ProjectSettingsEditor.tsx index a84ac79..622e2c0 100644 --- a/apps/web/src/components/workspace/ProjectSettingsEditor.tsx +++ b/apps/web/src/components/workspace/ProjectSettingsEditor.tsx @@ -1,7 +1,8 @@ import { useEffect, useMemo, useState } from "react"; import { isIgnoredWorkspacePath, WRITING_PROFILES, type AgentWireApi, type PaperProject, type ProjectAclAction, type PublicationTarget, type PublishedSkillDescriptor, type WritingProfile, type WorkspaceTreeNode } from "@fastwrite/shared"; import { api } from "../../api/client"; -import { Button, Field, Icon, icons, Select, TextField, Link } from "../ui"; +import { Button, Field, Icon, icons, Select, TextField } from "../ui"; +import { ProjectExportButton } from "./ProjectExportButton"; import { PublicationTargetFields } from "../ui/PublicationTargetFields"; import { harnessSettingsSaveDecision, type HarnessSettingsBaseline, type HarnessSettingsDraft } from "./harnessSettings"; import styles from "./ProjectSettingsEditor.module.css"; @@ -11,12 +12,13 @@ interface ProjectSettingsEditorProps { project: PaperProject; tree: WorkspaceTreeNode[]; onSaved: (project: PaperProject) => void | Promise; + beforeExport?: () => Promise; } interface RememberedHarnessSettings { baseURL?: string; model?: string; wireAPI?: AgentWireApi } type AclRule = { id: string; pathPrefix: string; subjectType: "user" | "project_role" | "team_role" | "idp_group"; subjectId: string; action: ProjectAclAction; effect: "allow" | "deny" }; const ACL_ACTIONS: ProjectAclAction[] = ["read", "comment", "edit", "manage", "run_ai", "manage_harness", "export", "sync_github"]; -export function ProjectSettingsEditor({ project, tree, onSaved }: ProjectSettingsEditorProps) { +export function ProjectSettingsEditor({ project, tree, onSaved, beforeExport }: ProjectSettingsEditorProps) { const [category, setCategory] = useState("general"); const [query, setQuery] = useState(""); const [saved, setSaved] = useState(false); @@ -244,7 +246,7 @@ export function ProjectSettingsEditor({ project, tree, onSaved }: ProjectSetting

History & Export

Keep a local history of your work and download a portable copy.

Automatic Git historyAccepted saves create local Git checkpoints in the FastWrite project history.
-
Workspace snapshotDownload all source files as a portable tar.gz archive.
Export
+
Workspace snapshotDownload all source files as a portable tar.gz archive.
diff --git a/apps/web/src/components/workspace/ProvenanceDialog.tsx b/apps/web/src/components/workspace/ProvenanceDialog.tsx index fff7b3b..b3aecfe 100644 --- a/apps/web/src/components/workspace/ProvenanceDialog.tsx +++ b/apps/web/src/components/workspace/ProvenanceDialog.tsx @@ -1,6 +1,7 @@ import { useEffect, useState } from "react"; import type { AgentRun, ChangeSet } from "@fastwrite/shared"; import { api } from "../../api/client"; +import { downloadFile } from "../../api/download"; import { Button, Dialog, Disclosure, Icon, icons } from "../ui"; type ProvenanceDossier = { disclosureDraft: string; runs: Array>; changeSets: Array>; experiments: Array<{ id: string; projectVersion: number; scriptPath: string; status: string; inputSnapshotHash?: string; result?: { success: boolean; exitCode: number; artifactPaths: string[]; runId: string }; createdAt: string }> }; @@ -8,6 +9,7 @@ type ProvenanceDossier = { disclosureDraft: string; runs: Array void }) { const [dossier, setDossier] = useState(null); const [error, setError] = useState(""); + const [exporting, setExporting] = useState(false); useEffect(() => { if (!open) return; const controller = new AbortController(); @@ -16,11 +18,19 @@ export function ProvenanceDialog({ open, projectId, onClose }: { open: boolean; return () => controller.abort(); }, [open, projectId]); const download = async () => { - const response = await fetch(`/api/projects/${encodeURIComponent(projectId)}/provenance/export`); - if (!response.ok) throw new Error("Could not export provenance"); - const blob = await response.blob(); const url = URL.createObjectURL(blob); const link = document.createElement("a"); link.href = url; link.download = `fastwrite-provenance-${projectId}.json`; link.click(); setTimeout(() => URL.revokeObjectURL(url), 1000); + if (exporting) return; + setError(""); + setExporting(true); + try { + const dossier = await api.projects.provenanceExport(projectId); + downloadFile({ blob: new Blob([JSON.stringify(dossier, null, 2)], { type: "application/json" }), filename: `fastwrite-provenance-${encodeURIComponent(projectId)}.json` }); + } catch (failure) { + setError(failure instanceof Error ? `Could not export provenance: ${failure.message}` : "Could not export provenance. Please try again."); + } finally { + setExporting(false); + } }; - return }> + return }> {error ?

{error}

: dossier ?

Disclosure draft

Review this wording against the target venue before submission.

{dossier.disclosureDraft}

Operations

{dossier.runs.length} recorded AI run{dossier.runs.length === 1 ? "" : "s"} · {dossier.changeSets.length} ChangeSet{dossier.changeSets.length === 1 ? "" : "s"}

diff --git a/apps/web/src/components/workspace/SourceEditor.tsx b/apps/web/src/components/workspace/SourceEditor.tsx index 6908b31..80900ed 100644 --- a/apps/web/src/components/workspace/SourceEditor.tsx +++ b/apps/web/src/components/workspace/SourceEditor.tsx @@ -140,7 +140,7 @@ export const SourceEditor = forwardRef(fu smoothScrolling: true, roundedSelection: true, fontFamily: 'ui-monospace, "SFMono-Regular", "SF Mono", Menlo, Consolas, "Liberation Mono", monospace', - fontSize: 13, + fontSize: 14, lineHeight: 22, glyphMargin: false, folding: false, diff --git a/apps/web/src/lib/appBase.test.ts b/apps/web/src/lib/appBase.test.ts new file mode 100644 index 0000000..e10c308 --- /dev/null +++ b/apps/web/src/lib/appBase.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, test } from "bun:test"; +import { appPath, normalizeAppBase, relativeAppPath } from "./appBase"; + +describe("deployment path", () => { + test("root and nested routes share one prefix", () => { + expect(normalizeAppBase("/")).toBe(""); + expect(normalizeAppBase("/write/")).toBe("/write"); + expect(appPath("/api/projects", "/write")).toBe("/write/api/projects"); + expect(appPath("/write/api/projects", "/write")).toBe("/write/api/projects"); + expect(appPath("/projects/a", "")).toBe("/projects/a"); + expect(appPath("/writer/projects", "/write")).toBe("/write/writer/projects"); + }); + test("external links and fragments keep their targets", () => { + for (const path of ["https://example.com/paper", "//example.com/paper", "#workflow", "mailto:author@example.com"]) { + expect(appPath(path, "/write")).toBe(path); + } + }); + test("router strips only its own complete path segment", () => { + expect(relativeAppPath("/write/projects/a", "/write")).toBe("/projects/a"); + expect(relativeAppPath("/write/", "/write")).toBe("/"); + expect(relativeAppPath("/writer/projects/a", "/write")).toBe("/writer/projects/a"); + }); +}); diff --git a/apps/web/src/lib/appBase.ts b/apps/web/src/lib/appBase.ts new file mode 100644 index 0000000..8e31586 --- /dev/null +++ b/apps/web/src/lib/appBase.ts @@ -0,0 +1,19 @@ +/** Keep browser routes and requests within the configured Vite deployment path. */ +export function normalizeAppBase(base: string): string { + const path = base.trim().replace(/^\/+|\/+$/g, ""); + return path ? `/${path}` : ""; +} + +export const APP_BASE = normalizeAppBase(import.meta.env?.BASE_URL ?? "/"); + +export function appPath(path: string, base = APP_BASE): string { + if (!path.startsWith("/") || path.startsWith("//")) return path; + if (!base || path === base || path.startsWith(`${base}/`) || path.startsWith(`${base}?`) || path.startsWith(`${base}#`)) return path; + return `${base}${path}`; +} + +export function relativeAppPath(path: string, base = APP_BASE): string { + if (!base) return path; + if (path === base || path === `${base}/`) return "/"; + return path.startsWith(`${base}/`) ? path.slice(base.length) : path; +} diff --git a/apps/web/src/lib/editor/collaborationProvider.ts b/apps/web/src/lib/editor/collaborationProvider.ts index d13f0fd..6c372d1 100644 --- a/apps/web/src/lib/editor/collaborationProvider.ts +++ b/apps/web/src/lib/editor/collaborationProvider.ts @@ -6,6 +6,7 @@ import * as encoding from "lib0/encoding"; import { Awareness, applyAwarenessUpdate, encodeAwarenessUpdate } from "y-protocols/awareness"; import { readSyncMessage, writeSyncStep1, writeUpdate, messageYjsSyncStep2 } from "y-protocols/sync"; import { api } from "../../api/client"; +import { appPath } from "../appBase"; import type { DocumentEntry } from "./documentRegistry"; const LOCAL = Symbol("local-monaco-edit"); @@ -201,7 +202,7 @@ export class CollaborationProvider { this.tokenExpiresAt = Date.parse(grant.expiresAt); if (this.disposed) return; const protocol = location.protocol === "https:" ? "wss:" : "ws:"; - const socket = new WebSocket(`${protocol}//${location.host}/api/collaboration/socket?clientId=${this.clientId}&token=${encodeURIComponent(grant.token)}`); + const socket = new WebSocket(`${protocol}//${location.host}${appPath("/api/collaboration/socket")}?clientId=${this.clientId}&token=${encodeURIComponent(grant.token)}`); this.socket = socket; socket.binaryType = "arraybuffer"; socket.onopen = () => { this.attempts = 0; const encoder = encoding.createEncoder(); encoding.writeVarUint(encoder, 0); writeSyncStep1(encoder, this.doc); socket.send(encoding.toUint8Array(encoder)); this.onAwareness(null, LOCAL); }; socket.onmessage = event => { diff --git a/apps/web/src/lib/navigation.ts b/apps/web/src/lib/navigation.ts index d924065..ef0bd5e 100644 --- a/apps/web/src/lib/navigation.ts +++ b/apps/web/src/lib/navigation.ts @@ -1,8 +1,10 @@ +import { appPath } from "./appBase"; + export function navigate(path: string): void { - window.history.pushState({}, "", path); + window.history.pushState({}, "", appPath(path)); window.dispatchEvent(new PopStateEvent("popstate")); } export function projectPath(projectId: string): string { - return `/projects/${encodeURIComponent(projectId)}`; + return appPath(`/projects/${encodeURIComponent(projectId)}`); } diff --git a/apps/web/src/lib/projectExport.ts b/apps/web/src/lib/projectExport.ts new file mode 100644 index 0000000..dd35f91 --- /dev/null +++ b/apps/web/src/lib/projectExport.ts @@ -0,0 +1,16 @@ +import { api, ApiClientError } from "../api/client"; +import { downloadFile } from "../api/download"; + +export async function downloadProjectArchive(projectId: string, signal?: AbortSignal): Promise { + const file = await api.projects.exportArchive(projectId, signal); + signal?.throwIfAborted(); + downloadFile(file); +} + +export function projectExportErrorMessage(error: unknown): string { + if (error instanceof ApiClientError) { + if (error.status === 401) return "Your session has expired. Sign in again to export the source files."; + if (error.status === 403) return "You do not have permission to export this project's source files."; + } + return error instanceof Error ? `Could not export source files: ${error.message}` : "Could not export source files. Please try again."; +} diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index 5b4b7be..af31bdc 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -7,7 +7,7 @@ import "vscrui/dist/_name_.css"; if ("serviceWorker" in navigator && import.meta.env.PROD) { window.addEventListener("load", () => { - void navigator.serviceWorker.register("/sw.js").then((registration) => { + void navigator.serviceWorker.register(`${import.meta.env.BASE_URL}sw.js`, { scope: import.meta.env.BASE_URL }).then((registration) => { if (navigator.onLine) void registration.update().catch(() => undefined); }).catch(() => undefined); }); diff --git a/apps/web/src/pages/ProjectsPage.tsx b/apps/web/src/pages/ProjectsPage.tsx index 4776c25..2d5110d 100644 --- a/apps/web/src/pages/ProjectsPage.tsx +++ b/apps/web/src/pages/ProjectsPage.tsx @@ -13,6 +13,7 @@ import { FastCASDialog } from "../components/FastCASDialog"; import { Button, Checkbox, Dialog, Divider, Field, Icon, IconButton, Link, MultiSelect, NumberField, Select, TextArea, TextField, ThemeToggle, icons } from "../components/ui"; import { PublicationTargetFields } from "../components/ui/PublicationTargetFields"; import { navigate, projectPath } from "../lib/navigation"; +import { appPath } from "../lib/appBase"; import { publicationTargetAbbreviation } from "../lib/labels"; export function ProjectsPage() { @@ -86,7 +87,7 @@ export function ProjectsPage() { try { await api.invitations.accept(invitationToken); setInvitationState("accepted"); - window.history.replaceState({}, "", "/projects"); + window.history.replaceState({}, "", appPath("/projects")); await loadProjects(); } catch (failure) { setInvitationState("error"); @@ -450,11 +451,11 @@ function AccountDialog({ >
{ event.preventDefault(); if (!loading && email.trim() && password) void submit(); }}> {mode === "login" && (providers.oidc || providers.cas || providers.fastcas) ?
- {providers.fastcas ? : null} - {providers.oidc ? : null} - {providers.cas ? : null} + {providers.fastcas ? : null} + {providers.oidc ? : null} + {providers.cas ? : null}
: null} - {mode === "register" && providers.fastcasSignup ? : null} + {mode === "register" && providers.fastcasSignup ? : null} {mode === "register" ? ( Find +