diff --git a/README.ja.md b/README.ja.md index d939efd3..34847f08 100644 --- a/README.ja.md +++ b/README.ja.md @@ -81,7 +81,7 @@ EdgeEver は、オープンソースのノートと知識ベースの作業領 ## 機能とワークフローマトリクス -「インスピレーションの収集 · 深い創作 · 知識の蓄積」をシームレスにつなぐオールインワンワークスペース(👉 **[全 17 の実機スクリーンショットと活用ガイドを見る](docs/best-practices.md)**): +「インスピレーションの収集 · 深い創作 · 知識の蓄積」をシームレスにつなぐオールインワンワークスペース(👉 **[全 19 の実機スクリーンショットと活用ガイドを見る](docs/best-practices.md)**): | モジュール | 基本機能 | 高度な機能 | | :--- | :--- | :--- | @@ -324,12 +324,11 @@ EdgeEver はセルフホスト型ソフトウェアです。公式デモを除 @dkoryto @sivdead @thorufur + @claude @yulei666 @arsaboo @alyyasser @MadlyFX - @claude - @Crowszzz @CottonChou @Z-DDV @0xfelixli @@ -339,5 +338,6 @@ EdgeEver はセルフホスト型ソフトウェアです。公式デモを除 @mookim-eth @nzhdotme @Shunamxiao + @Crowszzz

diff --git a/README.md b/README.md index 9c3cd0c2..47896a66 100644 --- a/README.md +++ b/README.md @@ -81,7 +81,7 @@ The public demo resets every day at 3:00 AM (China Standard Time) and restores s ## Feature Matrix & Capabilities -An all-in-one workspace connecting inspiration capture, deep creation, and knowledge compounding (👉 **[Explore 17 step-by-step showcases with screenshots](docs/best-practices.md)**): +An all-in-one workspace connecting inspiration capture, deep creation, and knowledge compounding (👉 **[Explore 19 step-by-step showcases with screenshots](docs/best-practices.md)**): | Module | Core Features | Advanced Features | | :--- | :--- | :--- | @@ -325,12 +325,11 @@ EdgeEver is self-hosted software. Except for official demo instances, project ma @dkoryto @sivdead @thorufur + @claude @yulei666 @arsaboo @alyyasser @MadlyFX - @claude - @Crowszzz @CottonChou @Z-DDV @0xfelixli @@ -340,5 +339,6 @@ EdgeEver is self-hosted software. Except for official demo instances, project ma @mookim-eth @nzhdotme @Shunamxiao + @Crowszzz

diff --git a/README.pl-PL.md b/README.pl-PL.md index fad61de6..cd352188 100644 --- a/README.pl-PL.md +++ b/README.pl-PL.md @@ -81,7 +81,7 @@ Publiczne demo resetuje się codziennie o 3:00 czasu chińskiego (UTC+8) i przyw ## Macierz funkcji i zastosowań -Wszechstronny obszar roboczy łączący zbieranie inspiracji, głębokie tworzenie i gromadzenie wiedzy (👉 **[Zobacz wszystkie 17 scenariuszy ze zrzutami ekranu](docs/best-practices.md)**): +Wszechstronny obszar roboczy łączący zbieranie inspiracji, głębokie tworzenie i gromadzenie wiedzy (👉 **[Zobacz wszystkie 19 scenariuszy ze zrzutami ekranu](docs/best-practices.md)**): | Moduł | Funkcje podstawowe | Funkcje zaawansowane | | :--- | :--- | :--- | @@ -325,12 +325,11 @@ EdgeEver jest oprogramowaniem do samodzielnego hostingu. Poza oficjalnymi instan @dkoryto @sivdead @thorufur + @claude @yulei666 @arsaboo @alyyasser @MadlyFX - @claude - @Crowszzz @CottonChou @Z-DDV @0xfelixli @@ -340,5 +339,6 @@ EdgeEver jest oprogramowaniem do samodzielnego hostingu. Poza oficjalnymi instan @mookim-eth @nzhdotme @Shunamxiao + @Crowszzz

diff --git a/README.zh-CN.md b/README.zh-CN.md index b8386279..e4ba4faf 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -81,7 +81,7 @@ EdgeEver 是一款现代化的开源笔记与个人知识库工作区。它保 ## 核心功能与场景矩阵 -一站式连接「灵感捕获 · 深度创作 · 知识沉淀」的全能工作区(👉 **[查看 17 个实机案例演示与最佳实践](docs/best-practices.zh-CN.md)**): +一站式连接「灵感捕获 · 深度创作 · 知识沉淀」的全能工作区(👉 **[查看 19 个实机案例演示与最佳实践](docs/best-practices.zh-CN.md)**): | 模块 | 基础功能 | 进阶特性 | | :--- | :--- | :--- | @@ -329,12 +329,11 @@ EdgeEver 是自托管软件。除官方演示实例外,项目维护者不托 @dkoryto @sivdead @thorufur + @claude @yulei666 @arsaboo @alyyasser @MadlyFX - @claude - @Crowszzz @CottonChou @Z-DDV @0xfelixli @@ -344,5 +343,6 @@ EdgeEver 是自托管软件。除官方演示实例外,项目维护者不托 @mookim-eth @nzhdotme @Shunamxiao + @Crowszzz

diff --git a/README.zh-TW.md b/README.zh-TW.md index b3d40606..1e0717a6 100644 --- a/README.zh-TW.md +++ b/README.zh-TW.md @@ -81,7 +81,7 @@ EdgeEver 是一款現代化的開源筆記與個人知識庫工作區。它延 ## 核心功能與場景矩陣 -一站式連結「靈感擷取 · 深度創作 · 知識沉澱」的全能工作區(👉 **[查看 17 個實機案例展示與最佳實踐](docs/best-practices.zh-CN.md)**): +一站式連結「靈感擷取 · 深度創作 · 知識沉澱」的全能工作區(👉 **[查看 19 個實機案例展示與最佳實踐](docs/best-practices.zh-CN.md)**): | 模組 | 基礎功能 | 進階特性 | | :--- | :--- | :--- | @@ -329,12 +329,11 @@ EdgeEver 是自行託管軟體。除官方展示實例外,專案維護者不 @dkoryto @sivdead @thorufur + @claude @yulei666 @arsaboo @alyyasser @MadlyFX - @claude - @Crowszzz @CottonChou @Z-DDV @0xfelixli @@ -344,5 +343,6 @@ EdgeEver 是自行託管軟體。除官方展示實例外,專案維護者不 @mookim-eth @nzhdotme @Shunamxiao + @Crowszzz

diff --git a/apps/api/package.json b/apps/api/package.json index e2f173a2..4be49132 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -4,6 +4,7 @@ "private": true, "type": "module", "dependencies": { - "@edgeever/public-network": "workspace:*" + "@edgeever/public-network": "workspace:*", + "content-type": "3.1.1" } } diff --git a/apps/api/src/backup-routes.ts b/apps/api/src/backup-routes.ts index cc62c5cb..e5a67c6e 100644 --- a/apps/api/src/backup-routes.ts +++ b/apps/api/src/backup-routes.ts @@ -25,6 +25,7 @@ import { MAX_IMAGE_UPLOAD_BYTES, mapResource, normalizeFilename, + validateRestoredResourceMimeType, type ResourceRow, } from "./resource-service"; import { getWorkspaceId, requireScopes, requireUser } from "./request-auth"; @@ -489,6 +490,9 @@ export const registerBackupRoutes = ( return apiError(context, "upload_too_large", "Backup resource size is invalid.", 413); } + const mimeType = metadata.mimeType ?? file.type ?? "application/octet-stream"; + validateRestoredResourceMimeType(metadata.kind, mimeType, file.size); + const filename = normalizeFilename(metadata.filename || file.name) || `${metadata.kind}-${metadata.id}`; const objectKey = `workspaces/${workspaceId}/restores/${metadata.memoId}/${metadata.id}/${Date.now()}-${filename}`; const bytes = new Uint8Array(await file.arrayBuffer()); @@ -511,7 +515,7 @@ export const registerBackupRoutes = ( const destination = await resolveObjectStorage(context.env); await destination.store.put(objectKey, bytes, { - httpMetadata: { contentType: metadata.mimeType ?? file.type ?? "application/octet-stream" }, + httpMetadata: { contentType: mimeType }, customMetadata: { memoId: metadata.memoId, resourceId: metadata.id, restored: "true" }, }); @@ -547,7 +551,7 @@ export const registerBackupRoutes = ( objectKey, destination.configId, metadata.kind, - metadata.mimeType ?? file.type ?? null, + mimeType, filename, bytes.byteLength, await dependencies.sha256Bytes(bytes), diff --git a/apps/api/src/database-write-optimizations.test.mjs b/apps/api/src/database-write-optimizations.test.mjs index 8293f240..f54f2889 100644 --- a/apps/api/src/database-write-optimizations.test.mjs +++ b/apps/api/src/database-write-optimizations.test.mjs @@ -137,6 +137,26 @@ describe("database write optimizations", () => { sqlite.close(); }); + test("persists literal tilde ranges through Markdown and JSON updates (#698)", async () => { + const { database, sqlite } = createDatabase(); + try { + const memo = getSeedMemo(sqlite); + const actor = { actorType: "user", actorId: "user_owner" }; + const source = "成本 1~2~3 元,10~20, 30~40"; + const first = await updateMemoRecord(database, memo.workspace_id, memo.id, + { contentMarkdown: source }, actor, "owner"); + expect(first.memo.contentJson.content[0].content).toEqual([{ type: "text", text: source }]); + const second = await updateMemoRecord(database, memo.workspace_id, memo.id, + { contentJson: first.memo.contentJson }, actor, "owner"); + const stored = sqlite.query("SELECT content_json, content_markdown FROM memo_contents WHERE memo_id = ?").get(memo.id); + expect(JSON.parse(stored.content_json)).toEqual(first.memo.contentJson); + expect(markdownToDoc(stored.content_markdown)).toEqual(first.memo.contentJson); + expect(second.memo.contentJson).toEqual(first.memo.contentJson); + } finally { + sqlite.close(); + } + }); + test("returns an unchanged memo without revision, audit, or search writes", async () => { const { database, sqlite } = createDatabase(); const memo = getSeedMemo(sqlite); diff --git a/apps/api/src/notebook-share-routes.test.mjs b/apps/api/src/notebook-share-routes.test.mjs index 66b880e1..5ade80ed 100644 --- a/apps/api/src/notebook-share-routes.test.mjs +++ b/apps/api/src/notebook-share-routes.test.mjs @@ -334,6 +334,11 @@ describe("public notebook site controls", () => { expect(tokenHtml).toContain(``); expect(tokenHtml).toContain(`rel="canonical" href="https://example.org/s/docs${suffix}"`); } + // The stored zh-CN default title means "untitled"; the page falls back to the notebook name. + f.db.run("UPDATE memos SET title = ? WHERE id = 'memo_child'", ["无标题笔记"]); + const untitledHtml = await (await render("/s/docs/memo_child")).text(); + expect(untitledHtml).not.toContain("无标题笔记"); + expect(untitledHtml).toMatch(/[^<]+ · EdgeEver<\/title>/); await manage(f, "/memos/memo_child", { excluded: true }); const hidden = await render("/s/docs/memo_child"); expect(hidden.status).toBe(404); diff --git a/apps/api/src/notebook-site-page.ts b/apps/api/src/notebook-site-page.ts index 8458a1ba..557b3af8 100644 --- a/apps/api/src/notebook-site-page.ts +++ b/apps/api/src/notebook-site-page.ts @@ -1,3 +1,4 @@ +import { getMemoDisplayTitle } from "@edgeever/shared/memo-title"; import { PUBLIC_PAGE_HEADERS, PUBLIC_PAGE_ROBOTS } from "@edgeever/shared/public-page-headers"; import type { DatabaseAdapter } from "./storage-contract"; import { getNotebookSite, publicTreeSql, resolveNotebookSiteToken } from "./notebook-site-service"; @@ -31,7 +32,7 @@ export const renderNotebookSitePage = async ( .bind(site.token, selected).first<{ title: string | null; excerpt: string }>() : null; if (memoId && !note) status = 404; if (status === 200) { - title = `${note?.title?.trim() || site.name} · EdgeEver`; + title = `${getMemoDisplayTitle(note?.title, site.name)} · EdgeEver`; description = note?.excerpt || site.name; indexable = alias && site.settings.allowIndexing; const base = site.site_slug ? `/s/${encodeURIComponent(site.site_slug)}` : `/share/notebook/${encodeURIComponent(site.token)}`; diff --git a/apps/api/src/resource-restore-security.test.mjs b/apps/api/src/resource-restore-security.test.mjs new file mode 100644 index 00000000..04ba0f7a --- /dev/null +++ b/apps/api/src/resource-restore-security.test.mjs @@ -0,0 +1,424 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { Database } from "bun:sqlite"; +import { mkdtemp, readdir, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { parseEdgeEverZip, restoreEdgeEverZip } from "@edgeever/shared/backup-archive"; +import { createEdgeEverClient } from "../../../packages/client/src/index.ts"; +import { fetchEdgeEverApp } from "./index.ts"; +import { createSelfHostedStorageAdapter } from "./self-hosted-storage-adapter.ts"; + +const fixtures = []; +const html = "<!doctype html><p>Untrusted attachment</p>"; +const png = Uint8Array.from(Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jRZkAAAAASUVORK5CYII=", "base64")); +const executionContext = { waitUntil() {}, passThroughOnException() {} }; + +afterEach(async () => { + for (const { database, directory, server } of fixtures.splice(0)) { + server?.stop(true); + database.close(); + await rm(directory, { recursive: true, force: true }); + } +}); + +const fixture = async () => { + const directory = await mkdtemp(join(tmpdir(), "edgeever-701-test-")); + const database = new Database(":memory:"); + const cleanup = { database, directory }; + fixtures.push(cleanup); + database.exec("PRAGMA foreign_keys = ON"); + const migrations = new URL("../../../migrations/", import.meta.url); + for (const name of (await readdir(migrations)).filter((name) => name.endsWith(".sql")).sort()) { + database.exec(await readFile(new URL(name, migrations), "utf8")); + } + database.run("INSERT OR IGNORE INTO workspaces (id, name, is_personal) VALUES ('ws_default', 'Personal', 1)"); + const environment = { + storage: createSelfHostedStorageAdapter(database, directory), + EDGE_EVER_ALLOW_UNAUTHENTICATED: "true", + EDGE_EVER_RUNTIME: "self-hosted-bun", + }; + const request = (path, init = {}) => fetchEdgeEverApp(new Request(`http://edgeever.test${path}`, init), environment, executionContext); + const json = async (path, body, method = "POST") => { + const response = await request(path, { method, headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) }); + const result = await response.json(); + if (!response.ok) throw new Error(`${response.status}: ${JSON.stringify(result)}`); + return result; + }; + const { notebook } = await json("/api/v1/notebooks", { name: "Security regression" }); + const { memo } = await json("/api/v1/memos", { notebookId: notebook.id, title: "Restore regression", contentMarkdown: "Original note" }); + const metadata = (overrides = {}) => ({ + id: "res_security", memoId: memo.id, originalMemoId: null, kind: "image", mimeType: "image/png", + filename: "resource.png", byteSize: png.length, sha256: null, width: 1, height: 1, + createdAt: "2026-08-31T00:00:00.000Z", updatedAt: "2026-08-31T00:00:00.000Z", + archivePath: "resources/resource.png", ...overrides, + }); + const restore = (resource, bytes = png, type = resource.mimeType ?? "application/octet-stream") => { + const form = new FormData(); + form.set("metadata", JSON.stringify(resource)); + form.set("file", new File([bytes], resource.filename ?? "resource", { type })); + return request(`/api/v1/restores/json/resources/${resource.id}`, { method: "PUT", body: form }); + }; + const resourceRow = () => database.query("SELECT * FROM resources WHERE id = 'res_security'").get(); + const serve = () => { + cleanup.server = Bun.serve({ hostname: "127.0.0.1", port: 0, fetch: (request) => fetchEdgeEverApp(request, environment, executionContext) }); + return cleanup.server.url.origin; + }; + return { directory, database, environment, request, json, notebook, memo, metadata, restore, resourceRow, serve }; +}; + +describe("restored resource security", () => { + for (const mimeType of ["text/html", "application/xhtml+xml", "image/unknown", null]) { + test(`legacy image restore rejects ${mimeType ?? "HTML file MIME fallback"} without replacing existing bytes`, async () => { + const f = await fixture(); + expect((await f.restore(f.metadata())).status).toBe(200); + const previous = f.resourceRow(); + const response = await f.restore(f.metadata({ mimeType, filename: "resource.html", byteSize: html.length }), html, "text/html"); + expect(response.status).toBe(415); + expect(await response.json()).toMatchObject({ error: { code: "unsupported_media_type" } }); + expect(f.resourceRow()).toEqual(previous); + expect(new Uint8Array(await readFile(join(f.directory, previous.object_key)))).toEqual(png); + }); + } + + for (const mimeType of ["text/html", "application/xhtml+xml"]) { + test(`resumable image restore rejects ${mimeType} before creating a multipart upload`, async () => { + const f = await fixture(); + const previousObjects = (await readdir(f.directory, { recursive: true })).sort(); + const response = await f.request("/api/v1/restores/json/resources/res_security/uploads", { + method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify(f.metadata({ mimeType, filename: "resource.html", byteSize: html.length })), + }); + expect(response.status).toBe(415); + expect(await response.json()).toMatchObject({ error: { code: "unsupported_media_type" } }); + expect(f.database.query("SELECT COUNT(*) AS count FROM resource_uploads").get().count).toBe(0); + expect(f.resourceRow()).toBeNull(); + expect((await readdir(f.directory, { recursive: true })).sort()).toEqual(previousObjects); + }); + } + + for (const mode of ["legacy", "resumable"]) { + for (const mimeType of [null, "", "application/octet-stream"]) { + test(`${mode} preserves unknown image MIME ${JSON.stringify(mimeType)} as a download`, async () => { + const f = await fixture(); + const metadata = f.metadata({ mimeType, filename: "unknown.bin" }); + if (mode === "legacy") { + expect((await f.restore(metadata, png, "application/octet-stream")).status).toBe(200); + } else { + const client = createEdgeEverClient({ baseUrl: f.serve() }); + await client.restoreJsonResource(metadata.id, metadata, new Blob([png])); + } + expect(f.resourceRow().mime_type).toBe(mimeType ?? "application/octet-stream"); + const response = await f.request("/api/v1/resources/res_security/blob"); + expect(response.headers.get("Content-Disposition")).toStartWith("attachment;"); + expect(new Uint8Array(await response.arrayBuffer())).toEqual(png); + }); + } + } + + for (const mimeType of ["application/pdf", "audio/mpeg", "video/mp4"]) { + test(`untyped attachments with ${mimeType} object metadata keep their download behavior`, async () => { + const f = await fixture(); + const token = "s".repeat(43); + const notebookToken = "n".repeat(43); + f.database.run("INSERT INTO memo_shares (id, memo_id, workspace_id, token) VALUES ('security_share', ?, 'ws_default', ?)", [f.memo.id, token]); + f.database.run("INSERT INTO notebook_shares (id, notebook_id, workspace_id, token) VALUES ('security_notebook', ?, 'ws_default', ?)", [f.notebook.id, notebookToken]); + await f.environment.storage.resources.put("legacy-attachment", Uint8Array.of(65, 66)); + const getObject = f.environment.storage.resources.get.bind(f.environment.storage.resources); + f.environment.storage.resources.get = async (...args) => { + const object = await getObject(...args); + object.writeHttpMetadata = (headers) => headers.set("Content-Type", mimeType); + return object; + }; + f.database.run("INSERT INTO resources (id, memo_id, object_key, kind, mime_type, filename, byte_size) VALUES ('res_security', ?, 'legacy-attachment', 'attachment', NULL, 'untitled.bin', 2)", [f.memo.id]); + for (const path of [ + "/api/v1/resources/res_security/blob", + `/api/public/shares/${token}/resources/res_security/blob`, + `/api/public/notebook-shares/${notebookToken}/resources/res_security/blob`, + ]) { + const response = await f.request(path); + expect(response.status).toBe(200); + expect(response.headers.get("Content-Type")).toBe(mimeType); + expect(response.headers.get("Content-Disposition")).toStartWith("attachment;"); + expect(new Uint8Array(await response.arrayBuffer())).toEqual(Uint8Array.of(65, 66)); + } + }); + } + + test("completion rejects an incompatible restore session persisted before the fix, before assembling or overwriting its object", async () => { + const f = await fixture(); + expect((await f.restore(f.metadata())).status).toBe(200); + const previous = f.resourceRow(); + const metadata = f.metadata({ kind: "attachment", mimeType: "text/html", filename: "resource.html", byteSize: html.length }); + const { upload } = await f.json("/api/v1/restores/json/resources/res_security/uploads", metadata); + // This is the exact persisted metadata shape accepted by the old restore path. + f.database.run("UPDATE resource_uploads SET kind = 'image', restore_metadata_json = ? WHERE id = ?", [JSON.stringify({ ...metadata, kind: "image" }), upload.id]); + const session = f.database.query("SELECT * FROM resource_uploads WHERE id = ?").get(upload.id); + const part = await f.request(`/api/v1/resource-uploads/${upload.id}/parts/1`, { + method: "PUT", headers: { "Content-Type": "application/octet-stream", "Content-Length": String(html.length) }, body: html, + }); + expect(part.status).toBe(200); + const response = await f.request(`/api/v1/resource-uploads/${upload.id}/complete`, { method: "POST" }); + expect(response.status).toBe(415); + expect(await response.json()).toMatchObject({ error: { code: "unsupported_media_type" } }); + expect(f.resourceRow()).toEqual(previous); + expect(await Bun.file(join(f.directory, session.object_key)).exists()).toBe(false); + expect(new Uint8Array(await readFile(join(f.directory, previous.object_key)))).toEqual(png); + }); + + for (const mimeType of ["text/html", "application/xhtml+xml", "TEXT/HTML; charset=utf-8", null]) { + test(`legacy image rows with ${mimeType ?? "HTML object metadata"} download through authenticated and both public routes`, async () => { + const f = await fixture(); + const token = "s".repeat(43); + const notebookToken = "n".repeat(43); + f.database.run("INSERT INTO memo_shares (id, memo_id, workspace_id, token) VALUES ('security_share', ?, 'ws_default', ?)", [f.memo.id, token]); + f.database.run("INSERT INTO notebook_shares (id, notebook_id, workspace_id, token) VALUES ('security_notebook', ?, 'ws_default', ?)", [f.notebook.id, notebookToken]); + await f.environment.storage.resources.put("legacy-html", new TextEncoder().encode(html), { httpMetadata: { contentType: "text/html" } }); + // Unlike R2/S3, the filesystem adapter does not persist HTTP metadata. + const getObject = f.environment.storage.resources.get.bind(f.environment.storage.resources); + f.environment.storage.resources.get = async (...args) => { + const object = await getObject(...args); + object.writeHttpMetadata = (headers) => headers.set("Content-Type", "text/html"); + return object; + }; + f.database.run("INSERT INTO resources (id, memo_id, object_key, kind, mime_type, filename, byte_size) VALUES ('res_security', ?, 'legacy-html', 'image', ?, 'resource.html', ?)", [f.memo.id, mimeType, html.length]); + for (const path of [ + "/api/v1/resources/res_security/blob", + `/api/public/shares/${token}/resources/res_security/blob`, + `/api/public/notebook-shares/${notebookToken}/resources/res_security/blob`, + ]) { + const response = await f.request(path); + expect(response.status).toBe(200); + expect(response.headers.get("Content-Disposition")).toStartWith("attachment;"); + expect(response.headers.get("Content-Type")).toBe(mimeType === "TEXT/HTML; charset=utf-8" ? "text/html; charset=utf-8" : mimeType ?? "text/html"); + expect(response.headers.get("X-Content-Type-Options")).toBe("nosniff"); + expect(await response.text()).toBe(html); + } + }); + } + + test("legacy image restores accept image MIME parameters and a valid file MIME fallback without rewriting metadata", async () => { + const f = await fixture(); + for (const mimeType of ["IMAGE/PNG; charset=binary", null]) { + const response = await f.restore(f.metadata({ mimeType }), png, "image/png"); + expect(response.status).toBe(200); + expect(f.resourceRow().mime_type).toBe(mimeType ?? "image/png"); + const blob = await f.request("/api/v1/resources/res_security/blob"); + expect(blob.headers.get("Content-Disposition")).toStartWith("inline;"); + expect(new Uint8Array(await blob.arrayBuffer())).toEqual(png); + } + }); + + test("HTML remains a downloadable attachment through legacy and resumable restores", async () => { + const f = await fixture(); + const metadata = f.metadata({ kind: "attachment", mimeType: "text/html", filename: "resource.html", byteSize: html.length }); + expect((await f.restore(metadata, html)).status).toBe(200); + const { upload } = await f.json("/api/v1/restores/json/resources/res_security/uploads", metadata); + expect((await f.request(`/api/v1/resource-uploads/${upload.id}/parts/1`, { + method: "PUT", headers: { "Content-Type": "application/octet-stream", "Content-Length": String(html.length) }, body: html, + })).status).toBe(200); + await f.json(`/api/v1/resource-uploads/${upload.id}/complete`, {}); + const blob = await f.request("/api/v1/resources/res_security/blob"); + expect(blob.headers.get("Content-Disposition")).toStartWith("attachment;"); + expect(await blob.text()).toBe(html); + }); + + test("resumable image restores validate MIME without rewriting its parameters", async () => { + const f = await fixture(); + const { upload } = await f.json("/api/v1/restores/json/resources/res_security/uploads", f.metadata({ mimeType: "IMAGE/PNG; charset=binary" })); + expect(f.database.query("SELECT mime_type FROM resource_uploads WHERE id = ?").get(upload.id).mime_type).toBe("IMAGE/PNG; charset=binary"); + expect((await f.request(`/api/v1/resource-uploads/${upload.id}/parts/1`, { + method: "PUT", headers: { "Content-Type": "application/octet-stream", "Content-Length": String(png.length) }, body: png, + })).status).toBe(200); + await f.json(`/api/v1/resource-uploads/${upload.id}/complete`, {}); + expect(f.resourceRow().mime_type).toBe("IMAGE/PNG; charset=binary"); + expect(new Uint8Array(await readFile(join(f.directory, f.resourceRow().object_key)))).toEqual(png); + }); + + test("SVG encoding parameters and bytes survive both restore paths", async () => { + const mimeType = "image/svg+xml; charset=utf-16le"; + const bytes = Buffer.from('<svg xmlns="http://www.w3.org/2000/svg" width="1" height="1"><text>旧备份</text></svg>', "utf16le"); + for (const mode of ["legacy", "resumable"]) { + const f = await fixture(); + const metadata = f.metadata({ mimeType, filename: "encoded.svg", byteSize: bytes.length }); + if (mode === "legacy") expect((await f.restore(metadata, bytes)).status).toBe(200); + else await createEdgeEverClient({ baseUrl: f.serve() }).restoreJsonResource(metadata.id, metadata, new Blob([bytes])); + expect(f.resourceRow().mime_type).toBe(mimeType); + const response = await f.request("/api/v1/resources/res_security/blob"); + expect(response.headers.get("Content-Type")).toBe(mimeType); + expect(response.headers.get("Content-Disposition")).toStartWith("inline;"); + expect(response.headers.get("Content-Security-Policy")).toContain("sandbox"); + expect(new Uint8Array(await response.arrayBuffer())).toEqual(new Uint8Array(bytes)); + } + }); + + for (const mode of ["legacy", "resumable"]) { + test(`restores a real v2.12.0-exported archive through the patched ${mode} API`, async () => { + const f = await fixture(); + const fixtureDirectory = new URL("../../../tests/fixtures/restore-resource-security/", import.meta.url); + const source = JSON.parse(await readFile(new URL("source.json", fixtureDirectory), "utf8")); + const backup = await parseEdgeEverZip(new Blob([await readFile(new URL("v2.12.0.zip", fixtureDirectory))])); + expect(backup.manifest.edgeeverVersion).toBe("2.12.0"); + expect(backup.manifest.buildId).toBe(source.commit); + const client = createEdgeEverClient({ baseUrl: f.serve() }); + await restoreEdgeEverZip(backup, { + restoreNotebooks: client.restoreJsonNotebooks, + restoreMemos: client.restoreJsonMemos, + restorePrompts: client.restoreJsonAiPrompts, + createResourceRestoreSink: mode === "resumable" ? client.createJsonResourceRestoreSink : async (_id, metadata) => { + const chunks = []; + return { + write: async (chunk) => { chunks.push(chunk); }, + close: async () => { + const response = await f.restore(metadata, await new Blob(chunks).arrayBuffer()); + expect(response.status).toBe(200); + }, + abort: async () => {}, + }; + }, + }); + const restoredMemo = await (await f.request("/api/v1/memos/memo_release")).json(); + expect(restoredMemo.memo).toMatchObject(source.memos[0]); + const notebooks = await (await f.request("/api/v1/notebooks")).json(); + expect(notebooks.notebooks.find((notebook) => notebook.id === "nb_release")).toMatchObject(source.notebooks[0]); + const revisions = await (await f.request("/api/v1/memos/memo_release/revisions")).json(); + const { contentJson, ...revisionSummary } = source.revisions[0]; + expect(revisions.revisions).toEqual(expect.arrayContaining([expect.objectContaining(revisionSummary)])); + expect(JSON.parse(f.database.query("SELECT content_json FROM memo_revisions WHERE id = ?").get(revisionSummary.id).content_json)).toEqual(contentJson); + for (const resource of source.resources) { + const row = f.database.query("SELECT * FROM resources WHERE id = ?").get(resource.id); + expect(row).toMatchObject({ memo_id: resource.memoId, kind: resource.kind, mime_type: resource.mimeType ?? "application/octet-stream", filename: resource.filename, byte_size: resource.byteSize, width: resource.width, height: resource.height, created_at: resource.createdAt }); + const response = await f.request(resource.url); + expect(response.status).toBe(200); + expect(response.headers.get("Content-Disposition")).toStartWith(resource.kind === "image" && resource.mimeType ? "inline;" : "attachment;"); + if (resource.mimeType === "image/svg+xml") expect(response.headers.get("Content-Security-Policy")).toContain("sandbox"); + expect(new Uint8Array(await response.arrayBuffer())).toEqual(Uint8Array.from(Buffer.from(resource.bytesBase64, "base64"))); + } + expect((await (await f.request(`/api/v1/memos/${f.memo.id}`)).json()).memo.contentMarkdown).toBe("Original note"); + }); + } +}); + +const ambiguousTypes = [ + ["image", "image/png; charset=utf-8, text/html"], + ["image", 'image/png; note="quoted, value", text/html'], + ["image", "image/png,"], + ["attachment", "audio/mpeg; ignored=x, text/html"], + ["attachment", "video/mp4; ignored=x, application/xhtml+xml"], + ["attachment", "application/pdf, text/html"], + ["attachment", "not-a-mime"], + ["attachment", 'text/plain; note="line\nbreak"'], + ["image", 'image/png; note="ok"\r\n; charset=utf-8'], + ["attachment", 'audio/mpeg; charset=utf-8; charset="line\nbreak"'], + ["attachment", "\r\n"], +]; + +const seedSharedResource = async (f, { kind, mimeType, filename = "resource.bin", objectMimeType = "application/octet-stream", body = html }) => { + const token = "s".repeat(43); + const notebookToken = "n".repeat(43); + f.database.run("INSERT INTO memo_shares (id, memo_id, workspace_id, token) VALUES ('security_share', ?, 'ws_default', ?)", [f.memo.id, token]); + f.database.run("INSERT INTO notebook_shares (id, notebook_id, workspace_id, token) VALUES ('security_notebook', ?, 'ws_default', ?)", [f.notebook.id, notebookToken]); + const bytes = typeof body === "string" ? new TextEncoder().encode(body) : body; + await f.environment.storage.resources.put("header-regression", bytes); + const getObject = f.environment.storage.resources.get.bind(f.environment.storage.resources); + f.environment.storage.resources.get = async (...args) => { + const object = await getObject(...args); + object.writeHttpMetadata = (headers) => headers.set("Content-Type", objectMimeType); + return object; + }; + f.database.run("INSERT INTO resources (id, memo_id, object_key, kind, mime_type, filename, byte_size) VALUES ('res_security', ?, 'header-regression', ?, ?, ?, ?)", [f.memo.id, kind, mimeType, filename, bytes.length]); + return [ + "/api/v1/resources/res_security/blob", + `/api/public/shares/${token}/resources/res_security/blob`, + `/api/public/notebook-shares/${notebookToken}/resources/res_security/blob`, + ]; +}; + +describe("single-valued resource content types", () => { + for (const [kind, mimeType] of ambiguousTypes) { + for (const mode of ["legacy", "resumable"]) { + test(`${mode} restore rejects the entire ambiguous ${mimeType} declaration`, async () => { + const f = await fixture(); + const metadata = f.metadata({ kind, mimeType, filename: "resource.bin", byteSize: html.length }); + const previousObjects = (await readdir(f.directory, { recursive: true })).sort(); + const response = mode === "legacy" + ? await f.restore(metadata, html, "application/octet-stream") + : await f.request("/api/v1/restores/json/resources/res_security/uploads", { + method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(metadata), + }); + expect(response.status).toBe(415); + expect(await response.json()).toMatchObject({ error: { code: "unsupported_media_type" } }); + expect(f.resourceRow()).toBeNull(); + expect(f.database.query("SELECT COUNT(*) AS count FROM resource_uploads").get().count).toBe(0); + expect((await readdir(f.directory, { recursive: true })).sort()).toEqual(previousObjects); + }); + } + + test(`legacy ${kind} resource with ${mimeType} downloads on all blob routes`, async () => { + const f = await fixture(); + const paths = await seedSharedResource(f, { kind, mimeType }); + for (const path of paths) { + const response = await f.request(path); + expect(response.status).toBe(200); + expect(response.headers.get("Content-Type")).toBe("application/octet-stream"); + expect(response.headers.get("Content-Disposition")).toStartWith("attachment;"); + expect(response.headers.get("X-Content-Type-Options")).toBe("nosniff"); + expect(await response.text()).toBe(html); + } + }); + } + + for (const [kind, mimeType] of [ambiguousTypes[0], ambiguousTypes[3], ambiguousTypes[ambiguousTypes.length - 2]]) { + test(`completion revalidates an old ${kind} upload with ambiguous MIME before assembly`, async () => { + const f = await fixture(); + expect((await f.restore(f.metadata())).status).toBe(200); + const previous = f.resourceRow(); + const metadata = f.metadata({ kind: "attachment", mimeType: "text/plain", filename: "resource.bin", byteSize: html.length }); + const { upload } = await f.json("/api/v1/restores/json/resources/res_security/uploads", metadata); + f.database.run("UPDATE resource_uploads SET kind = ?, mime_type = ?, restore_metadata_json = ? WHERE id = ?", [kind, mimeType, JSON.stringify({ ...metadata, kind, mimeType }), upload.id]); + const session = f.database.query("SELECT object_key FROM resource_uploads WHERE id = ?").get(upload.id); + expect((await f.request(`/api/v1/resource-uploads/${upload.id}/parts/1`, { + method: "PUT", headers: { "Content-Type": "application/octet-stream", "Content-Length": String(html.length) }, body: html, + })).status).toBe(200); + const response = await f.request(`/api/v1/resource-uploads/${upload.id}/complete`, { method: "POST" }); + expect(response.status).toBe(415); + expect(await response.json()).toMatchObject({ error: { code: "unsupported_media_type" } }); + expect(f.resourceRow()).toEqual(previous); + expect(await Bun.file(join(f.directory, session.object_key)).exists()).toBe(false); + expect(new Uint8Array(await readFile(join(f.directory, previous.object_key)))).toEqual(png); + }); + } + + test("ambiguous object MIME and a misleading PDF filename cannot enable inline rendering", async () => { + for (const metadata of [ + { kind: "image", mimeType: null, objectMimeType: ambiguousTypes[0][1] }, + { kind: "attachment", mimeType: null, objectMimeType: ambiguousTypes[3][1] }, + { kind: "image", mimeType: ambiguousTypes[0][1], filename: "resource.pdf" }, + ]) { + const f = await fixture(); + for (const path of await seedSharedResource(f, metadata)) { + const response = await f.request(path); + expect(response.headers.get("Content-Type")).toBe("application/octet-stream"); + expect(response.headers.get("Content-Disposition")).toStartWith("attachment;"); + expect(await response.text()).toBe(html); + } + } + }); + + test("quoted commas, semicolons, escaped quotes and SVG charset remain one safely serialized type", async () => { + const mimeType = 'IMAGE/SVG+XML;\tcharset="utf-16le"; note="a,b; text/html; \\"quoted\\""'; + const header = 'image/svg+xml; charset=utf-16le; note="a,b; text/html; \\"quoted\\""'; + const bytes = Buffer.from('<svg xmlns="http://www.w3.org/2000/svg"><text>旧备份</text></svg>', "utf16le"); + for (const mode of ["legacy", "resumable"]) { + const f = await fixture(); + const metadata = f.metadata({ mimeType, filename: "encoded.svg", byteSize: bytes.length }); + if (mode === "legacy") expect((await f.restore(metadata, bytes)).status).toBe(200); + else await createEdgeEverClient({ baseUrl: f.serve() }).restoreJsonResource(metadata.id, metadata, new Blob([bytes])); + expect(f.resourceRow().mime_type).toBe(mimeType); + const response = await f.request("/api/v1/resources/res_security/blob"); + expect(response.headers.get("Content-Type")).toBe(header); + expect(response.headers.get("Content-Disposition")).toStartWith("inline;"); + expect(response.headers.get("Content-Security-Policy")).toContain("sandbox"); + expect(new Uint8Array(await response.arrayBuffer())).toEqual(new Uint8Array(bytes)); + } + }); +}); diff --git a/apps/api/src/resource-routes.ts b/apps/api/src/resource-routes.ts index 24f59c0d..29e5dcbd 100644 --- a/apps/api/src/resource-routes.ts +++ b/apps/api/src/resource-routes.ts @@ -1,4 +1,4 @@ -import { resolveResourceUploadMimeType, setSvgResourceSecurityHeaders, isPdfAttachment, resolvePlayableMediaMimeType, ResourceUpdateSchema, type MemoDetail, type Resource } from "@edgeever/shared"; +import { resolveResourceUploadMimeType, ResourceUpdateSchema, type MemoDetail, type Resource } from "@edgeever/shared"; import { zValidator } from "@hono/zod-validator"; import type { Hono } from "hono"; import { auditStatement } from "./audit"; @@ -10,8 +10,7 @@ import { apiError, badRequest, notFound } from "./http-errors"; import { resolveObjectStorage } from "./object-storage"; import { SUPPORTED_IMAGE_MIME_TYPES, - contentDispositionAttachment, - contentDispositionInline, + setResourceContentHeaders, mapResource, mapResourceListItem, mapResourceStorageSummary, @@ -294,13 +293,6 @@ export const registerResourceRoutes = ( const headers = new Headers(); object.writeHttpMetadata(headers); - const playableMimeType = resolvePlayableMediaMimeType(resource.mime_type, resource.filename); - headers.set( - "Content-Type", - isPdfAttachment(resource.mime_type, resource.filename) - ? "application/pdf" - : playableMimeType ?? resource.mime_type ?? headers.get("Content-Type") ?? "application/octet-stream", - ); headers.set("Cache-Control", headers.get("Cache-Control") ?? "private, max-age=3600"); headers.set("Accept-Ranges", "bytes"); if (byteRange.kind === "range") { @@ -313,14 +305,7 @@ export const registerResourceRoutes = ( } else { headers.set("Content-Length", String(resource.byte_size)); } - headers.set( - "Content-Disposition", - resource.kind === "image" || isPdfAttachment(resource.mime_type, resource.filename) || playableMimeType - ? contentDispositionInline(resource.filename) - : contentDispositionAttachment(resource.filename), - ); - headers.set("X-Content-Type-Options", "nosniff"); - setSvgResourceSecurityHeaders(headers); + setResourceContentHeaders(headers, resource); return new Response(object.body, { headers, status: byteRange.kind === "range" ? 206 : 200 }); }); diff --git a/apps/api/src/resource-service.ts b/apps/api/src/resource-service.ts index 265f95a9..54b63a86 100644 --- a/apps/api/src/resource-service.ts +++ b/apps/api/src/resource-service.ts @@ -1,9 +1,13 @@ import { SUPPORTED_IMAGE_MIME_TYPES, + isPdfAttachment, + resolvePlayableMediaMimeType, + setSvgResourceSecurityHeaders, type Resource, type ResourceListItem, type ResourceStorageSummary, } from "@edgeever/shared"; +import { format as formatContentType, parse as parseContentType } from "content-type"; import { auditStatement } from "./audit"; import type { AppContext, AuditActor, Bindings } from "./api-context"; import { AppError } from "./app-error"; @@ -105,6 +109,30 @@ export const validateImageUpload = (mimeType: string, size: number) => { } }; +// HTTP field bytes: HTAB, visible ASCII and obs-text, never other controls or DEL. +const HTTP_FIELD_VALUE_PATTERN = /^[\t\u0020-\u007e\u0080-\u00ff]*$/; + +const parseResourceContentType = (value: string) => { + if (!HTTP_FIELD_VALUE_PATTERN.test(value)) return null; + if (!value.trim()) return { type: "application/octet-stream", header: "application/octet-stream" }; + try { + // A top-level comma ends the first type. Never silently accept the remainder. + const parsed = parseContentType(value, { comma: true }); + if (parsed.index !== value.length) return null; + return { type: parsed.type, header: formatContentType(parsed) }; + } catch { + return null; + } +}; + +export const validateRestoredResourceMimeType = (kind: Resource["kind"], mimeType: string, size: number) => { + const parsed = parseResourceContentType(mimeType); + if (!parsed) { + throw new AppError("unsupported_media_type", "Resource MIME type must contain a single valid media type.", 415); + } + if (kind === "image" && parsed.type !== "application/octet-stream") validateImageUpload(parsed.type, size); +}; + export const deleteReleasedResourceObjects = async ( env: Bindings, resources: { objectKey: string; storageConfigId: string }[], @@ -472,3 +500,31 @@ const contentDisposition = (kind: "inline" | "attachment", filename: string | nu export const contentDispositionInline = (filename: string | null) => contentDisposition("inline", filename); export const contentDispositionAttachment = (filename: string | null) => contentDisposition("attachment", filename); + +/** One policy for both the browser MIME and the decision to render it inline. */ +export const setResourceContentHeaders = ( + headers: Headers, + resource: Pick<ResourceRow, "kind" | "mime_type" | "filename">, +) => { + const sourceMimeType = resource.mime_type ?? headers.get("Content-Type") ?? ""; + const parsed = parseResourceContentType(sourceMimeType); + let contentType = "application/octet-stream"; + let inline = false; + + // Invalid legacy metadata must not be rescued into a preview by its filename. + if (parsed) { + const pdf = isPdfAttachment(resource.mime_type, resource.filename); + const media = resolvePlayableMediaMimeType(resource.mime_type ? parsed.type : null, resource.filename); + if (pdf) contentType = "application/pdf"; + else if (media) contentType = resource.mime_type && parsed.type === media ? parsed.header : media; + else contentType = parsed.header; + + inline = pdf || Boolean(media) + || (resource.kind === "image" && SUPPORTED_IMAGE_MIME_TYPES.has(parsed.type)); + } + + headers.set("Content-Type", contentType); + headers.set("Content-Disposition", inline ? contentDispositionInline(resource.filename) : contentDispositionAttachment(resource.filename)); + headers.set("X-Content-Type-Options", "nosniff"); + setSvgResourceSecurityHeaders(headers); +}; diff --git a/apps/api/src/resource-upload-service.ts b/apps/api/src/resource-upload-service.ts index 3797ba0d..aeaa81f5 100644 --- a/apps/api/src/resource-upload-service.ts +++ b/apps/api/src/resource-upload-service.ts @@ -17,6 +17,7 @@ import { MAX_ATTACHMENT_UPLOAD_BYTES, MAX_IMAGE_UPLOAD_BYTES, normalizeFilename, + validateRestoredResourceMimeType, validateAttachmentUpload, validateImageUpload, } from "./resource-service"; @@ -185,6 +186,8 @@ export const initiateResourceRestoreUpload = async ( if (!Number.isSafeInteger(metadata.byteSize) || metadata.byteSize <= 0 || metadata.byteSize > maxBytes) { throw new AppError("upload_too_large", "Backup resource size is invalid.", 413); } + const mimeType = metadata.mimeType?.trim() || "application/octet-stream"; + validateRestoredResourceMimeType(metadata.kind, metadata.mimeType ?? mimeType, metadata.byteSize); const workspaceId = getWorkspaceId(context); const memo = await context.env.storage.db.prepare( @@ -207,7 +210,6 @@ export const initiateResourceRestoreUpload = async ( const uploadId = createId("upload"); const filename = normalizeFilename(metadata.filename || "") || `${metadata.kind}-${metadata.id}`; - const mimeType = metadata.mimeType?.trim() || "application/octet-stream"; const objectKey = `workspaces/${workspaceId}/restores/${metadata.memoId}/${metadata.id}/${Date.now()}-${filename}`; const destination = await resolveObjectStorage(context.env); const multipart = await destination.store.createMultipartUpload( @@ -300,11 +302,7 @@ export const uploadResourcePart = async ( return { partNumber: part.partNumber, byteSize }; }; -const completeRestoredResourceUpload = async ( - context: AppContext, - upload: ResourceUploadRow, - actor: AuditActor, -): Promise<Resource> => { +const parseRestoredResourceMetadata = (upload: ResourceUploadRow): JsonBackupResource => { let metadataInput: unknown; try { metadataInput = JSON.parse(upload.restore_metadata_json ?? "null"); @@ -316,6 +314,18 @@ const completeRestoredResourceUpload = async ( throw new AppError("invalid_restore_resource", "Restore resource metadata is invalid.", 400); } const metadata = parsed.data as JsonBackupResource; + validateRestoredResourceMimeType( + metadata.kind, metadata.mimeType ?? upload.mime_type, upload.byte_size, + ); + return metadata; +}; + +const completeRestoredResourceUpload = async ( + context: AppContext, + upload: ResourceUploadRow, + actor: AuditActor, + metadata: JsonBackupResource, +): Promise<Resource> => { const originalMemo = metadata.originalMemoId ? await context.env.storage.db.prepare( "SELECT id FROM memos WHERE id = ? AND workspace_id = ? LIMIT 1", @@ -430,12 +440,14 @@ export const completeResourceUpload = async ( throw new AppError("upload_incomplete", "Resource upload size does not match the expected file size.", 409); } + // Revalidate sessions persisted by an older server before assembling any bytes. + const restoredMetadata = upload.restore_metadata_json ? parseRestoredResourceMetadata(upload) : null; const destination = await resolveObjectStorage(context.env, upload.storage_config_id); const multipart = destination.store.resumeMultipartUpload(upload.object_key, upload.provider_upload_id); await multipart.complete(parts); - if (upload.restore_metadata_json) { - return completeRestoredResourceUpload(context, upload, actor); + if (restoredMetadata) { + return completeRestoredResourceUpload(context, upload, actor, restoredMetadata); } const now = isoNow(); diff --git a/apps/api/src/share-page.test.mjs b/apps/api/src/share-page.test.mjs index f0bc7fc5..bd6dfef0 100644 --- a/apps/api/src/share-page.test.mjs +++ b/apps/api/src/share-page.test.mjs @@ -174,6 +174,19 @@ describe("public memo share pages", () => { expect(await lockedMarkdown.text()).not.toContain("secret()"); }); + test("treats the stored default title as untitled in metadata and markdown", async () => { + const sqlite = createDatabase(); + sqlite.query("UPDATE memos SET title = ? WHERE id = 'memo_source'").run("无标题笔记"); + sqlite.query("UPDATE memo_contents SET content_markdown = ? WHERE memo_id = 'memo_source'").run("Body text\n"); + const html = await (await render(sqlite, `/share/${token}`)).text(); + expect(html).not.toContain("无标题笔记"); + expect(html).toContain("<title>Shared note · EdgeEver"); + expect(html).toContain(''); + expect(html).toContain(''); + expect(html).toContain("Body text"); + expect(await (await render(sqlite, `/share/${token}.md`)).text()).toBe("Body text\n"); + }); + test("HEAD keeps the headers and omits the body", async () => { const response = await renderPublicMemoSharePage( new Request(`https://note.example/share/${token}.md`, { method: "HEAD" }), diff --git a/apps/api/src/share-page.ts b/apps/api/src/share-page.ts index 4aa50969..68ddfa40 100644 --- a/apps/api/src/share-page.ts +++ b/apps/api/src/share-page.ts @@ -1,3 +1,4 @@ +import { getMemoDisplayTitle } from "@edgeever/shared/memo-title"; import { isValidMemoShareSlug } from "@edgeever/shared/share-slug"; import { PUBLIC_PAGE_HEADERS, PUBLIC_PAGE_ROBOTS } from "@edgeever/shared/public-page-headers"; import type { DatabaseAdapter } from "./storage-contract"; @@ -80,7 +81,8 @@ export const PUBLIC_SHARE_MARKDOWN_PASSWORD = "This shared note requires a passw /** Markdown body for a public note. A title already used as the first heading is kept once. */ export const publicShareMarkdown = (title: string | null, body: string) => { - const heading = (title ?? "").replace(/[\r\n]+/g, " ").trim(); + // The stored zh-CN default title means "untitled"; it is not a heading. + const heading = getMemoDisplayTitle(title, "").replace(/[\r\n]+/g, " ").trim(); if (!heading) return body; const firstLine = body.split(/\r?\n/, 1)[0] ?? ""; if (firstLine.replace(/^#\s+/, "").trim() === heading) return body; @@ -153,7 +155,7 @@ export const renderPublicMemoSharePage = async ( return new Response(request.method === "HEAD" ? null : publicShareMarkdown(row.title, body), { status: 200, headers }); } - const title = readable ? (row?.title?.replace(/[\r\n]+/g, " ").trim() || "Shared note") : "Shared note"; + const title = readable ? (getMemoDisplayTitle(row?.title, "").replace(/[\r\n]+/g, " ").trim() || "Shared note") : "Shared note"; const description = readable ? describeNote(body) : locked diff --git a/apps/api/src/share-routes.ts b/apps/api/src/share-routes.ts index aba6d249..0d74aca4 100644 --- a/apps/api/src/share-routes.ts +++ b/apps/api/src/share-routes.ts @@ -1,4 +1,4 @@ -import { setSvgResourceSecurityHeaders, accountNoteProseFromRow, collectMemoLinkIds, isPdfAttachment, MemoShareUpdateSchema, NoteBodyFontUpdateSchema, NoteProseUpdateSchema, parsePublishedNoteBodyFont, publicNoteProseFromAccount, PublicShareUnlockSchema, resolveMemoContentDoc, resolvePlayableMediaMimeType, sanitizeNoteProseCss, type AccountNoteProse, type ManagedMemoShare, type MemoShare, type NoteProseAccountRow, type PublicMemoShare, type TiptapDoc } from "@edgeever/shared"; +import { accountNoteProseFromRow, collectMemoLinkIds, MemoShareUpdateSchema, NoteBodyFontUpdateSchema, NoteProseUpdateSchema, parsePublishedNoteBodyFont, publicNoteProseFromAccount, PublicShareUnlockSchema, resolveMemoContentDoc, sanitizeNoteProseCss, type AccountNoteProse, type ManagedMemoShare, type MemoShare, type NoteProseAccountRow, type PublicMemoShare, type TiptapDoc } from "@edgeever/shared"; import { zValidator } from "@hono/zod-validator"; import type { Hono } from "hono"; import type { AppContext, AppEnv } from "./api-context"; @@ -9,7 +9,7 @@ import { createId, isoNow, parseJsonArray } from "./entity-utils"; import { apiError, notFound } from "./http-errors"; import { resolveObjectStorage } from "./object-storage"; import { getAuditActor, getWorkspaceId, requireUser } from "./request-auth"; -import { contentDispositionAttachment, contentDispositionInline } from "./resource-service"; +import { setResourceContentHeaders } from "./resource-service"; import { isShareUnlockBlocked, nextShareUnlockFailure, @@ -74,11 +74,6 @@ const mapMemoShare = (row: MemoShareRow, password?: string): MemoShare => ({ ...(password ? { password } : {}), }); -const contentDisposition = (kind: SharedResourceRow["kind"], mimeType: string | null, filename: string | null) => { - const inline = kind === "image" || isPdfAttachment(mimeType, filename) || Boolean(resolvePlayableMediaMimeType(mimeType, filename)); - return inline ? contentDispositionInline(filename) : contentDispositionAttachment(filename); -}; - const resolveShareToken = (c: AppContext, value: string) => resolveMemoShareToken(c.env.storage.db, value); const shareSlugTaken = (c: AppContext) => @@ -141,13 +136,6 @@ export const serveSharedResource = async (c: AppContext, resource: SharedResourc const headers = new Headers(); object.writeHttpMetadata(headers); - const playableMimeType = resolvePlayableMediaMimeType(resource.mime_type, resource.filename); - headers.set( - "Content-Type", - isPdfAttachment(resource.mime_type, resource.filename) - ? "application/pdf" - : playableMimeType ?? resource.mime_type ?? headers.get("Content-Type") ?? "application/octet-stream", - ); headers.set("Accept-Ranges", "bytes"); if (byteRange.kind === "range") { const length = object.range?.length ?? byteRange.range.length; @@ -159,10 +147,8 @@ export const serveSharedResource = async (c: AppContext, resource: SharedResourc } else { headers.set("Content-Length", String(resource.byte_size)); } - headers.set("Content-Disposition", contentDisposition(resource.kind, resource.mime_type, resource.filename)); + setResourceContentHeaders(headers, resource); headers.set("Cache-Control", "private, no-store"); - headers.set("X-Content-Type-Options", "nosniff"); - setSvgResourceSecurityHeaders(headers); headers.set("X-Robots-Tag", "noindex, nofollow, noarchive"); return new Response(object.body, { headers, status: byteRange.kind === "range" ? 206 : 200 }); }; diff --git a/apps/desktop/package.json b/apps/desktop/package.json index baa08c94..881163f9 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,6 +1,6 @@ { "name": "@edgeever/desktop", - "version": "2.12.0", + "version": "2.14.0", "desktopName": "EdgeEver", "private": true, "description": "Native EdgeEver desktop client", diff --git a/apps/desktop/src/backup/worker-integration.test.mjs b/apps/desktop/src/backup/worker-integration.test.mjs index 2a3d7751..2fc741d0 100644 --- a/apps/desktop/src/backup/worker-integration.test.mjs +++ b/apps/desktop/src/backup/worker-integration.test.mjs @@ -209,6 +209,7 @@ test("manager creates named files, restores from history and deletes only the se }); cleanups.push(() => manager.close()); await manager.setContext({ baseUrl: source.baseUrl, accountId: "local", token: "" }); + await manager.save({ ...manager.status().config, retention: 3 }); const id = manager.status().backupSetId; for (const encrypted of [false, true]) { await manager.run(encrypted ? "fixture password" : undefined, id, undefined, { name: encrypted ? "Encrypted snapshot.zip" : "Plain snapshot", encrypted }); diff --git a/apps/desktop/src/main/backup-manager.test.mjs b/apps/desktop/src/main/backup-manager.test.mjs index 26141d6d..9d8c7a74 100644 --- a/apps/desktop/src/main/backup-manager.test.mjs +++ b/apps/desktop/src/main/backup-manager.test.mjs @@ -22,7 +22,7 @@ test("publishes only completed jobs, retains registered files and isolates accou const f = await fixture(); try { let status = f.manager.status(); - expect(status.config.enabled).toBe(false); expect(status.config.retention).toBe(5); + expect(status.config.enabled).toBe(false); expect(status.config.retention).toBe(1); await f.manager.save({ ...status.config, retention: 3 }); for (let i = 0; i < 4; i++) { await f.manager.run(); await complete(f); } status = f.manager.status(); expect(status.history).toHaveLength(3); @@ -38,6 +38,41 @@ test("publishes only completed jobs, retains registered files and isolates accou } finally { await f.cleanup(); } }); +test("default retention keeps the latest successful archive through failures and reloads", async () => { + const f = await fixture(); + try { + await f.manager.run(); await complete(f); + const first = f.manager.status().history[0]; + await f.manager.run(); + f.workers.at(-1).emit("message", { type: "error", error: { code: "backup_source_changed", message: "Changed" } }); + await f.manager.waitForIdle(); + expect(f.manager.status().history).toEqual([first]); + expect(await readFile(join(f.manager.status().actualDirectory, first.name), "utf8")).toBe("complete archive"); + await f.manager.run(); await complete(f); + const status = f.manager.status(); + expect(status.history).toHaveLength(1); + expect(status.history[0].name).not.toBe(first.name); + expect((await readdir(status.actualDirectory)).filter((name) => name.endsWith(".zip"))).toEqual([status.history[0].name]); + await f.manager.setContext(null); + await f.manager.setContext({ baseUrl: "http://localhost:1111", accountId: "a", token: "session-a" }); + expect(f.manager.status().config.retention).toBe(1); + expect(f.manager.status().history).toEqual(status.history); + } finally { await f.cleanup(); } +}); + +test("loading an existing configuration preserves the previous default of five copies", async () => { + const f = await fixture(); + try { + const record = JSON.parse(await readFile(f.manager.statePath(), "utf8")); + record.config.retention = 5; + await writeFile(f.manager.statePath(), JSON.stringify(record)); + await f.manager.setContext(null); + await f.manager.setContext({ baseUrl: "http://localhost:1111", accountId: "a", token: "session-a" }); + expect(f.manager.status().config).toEqual(record.config); + expect(f.manager.status().error).toBeNull(); + } finally { await f.cleanup(); } +}); + test("failure and cancellation preserve success and never rotate; credentials stay out of config", async () => { const f = await fixture(); try { @@ -451,6 +486,7 @@ const finishSnapshot = async (f, result = {}) => { test('named manual backups preserve formats/settings and never overwrite a collision', async () => { const f = await fixture(); try { + await f.manager.save({ ...f.manager.status().config, retention: 5 }); await f.manager.save({ ...f.manager.status().config, encrypted: true }, 'saved'); const id = f.manager.status().backupSetId; await f.manager.run(undefined, id, undefined, { name: ' Travel.zip ', encrypted: false }); @@ -481,6 +517,7 @@ test('named manual backups preserve formats/settings and never overwrite a colli test('history restore/delete are scoped, reject replacements and remove only the selected file', async () => { const f = await fixture(); try { + await f.manager.save({ ...f.manager.status().config, retention: 5 }); await f.manager.run(); await complete(f); await f.manager.run(); await complete(f); const s = f.manager.status(); const [first, second] = s.history; @@ -508,6 +545,7 @@ test('history restore/delete are scoped, reject replacements and remove only the test('automatic no-change advances checks, not archive time/retention, and invalidates its baseline correctly', async () => { const f = await fixture(); try { + await f.manager.save({ ...f.manager.status().config, retention: 5 }); await f.manager.save({ ...f.manager.status().config, enabled: true, interval: 'daily' }); await f.manager.run(); await finishSnapshot(f); const first = f.manager.status(); const due = Date.parse(first.nextDueAt); diff --git a/apps/desktop/src/main/backup-settings.mjs b/apps/desktop/src/main/backup-settings.mjs index 7f996f80..b5538907 100644 --- a/apps/desktop/src/main/backup-settings.mjs +++ b/apps/desktop/src/main/backup-settings.mjs @@ -1,9 +1,9 @@ export const BACKUP_INTERVALS = ["daily", "weekly", "biweekly", "monthly"]; -export const BACKUP_RETENTION = [3, 5, 10, 20]; +export const BACKUP_RETENTION = [1, 3, 5, 10, 20]; export const backupError = (code, message) => Object.assign(new Error(message), { code }); export const normalizeBackupConfig = (input, directory) => { - if (input === undefined) return { enabled: false, interval: "weekly", retention: 5, directory, encrypted: false }; + if (input === undefined) return { enabled: false, interval: "weekly", retention: 1, directory, encrypted: false }; if (!input || typeof input.enabled !== "boolean" || typeof input.encrypted !== "boolean" || !BACKUP_INTERVALS.includes(input.interval) || !BACKUP_RETENTION.includes(input.retention) || typeof input.directory !== "string" || !input.directory) throw backupError("backup_invalid_configuration", "The backup configuration is invalid."); diff --git a/apps/desktop/src/main/backup-settings.test.mjs b/apps/desktop/src/main/backup-settings.test.mjs index a5f45d26..3fbfd69e 100644 --- a/apps/desktop/src/main/backup-settings.test.mjs +++ b/apps/desktop/src/main/backup-settings.test.mjs @@ -13,7 +13,10 @@ test("calendar schedules clamp month end and preserve successful history when fr test("accepts the specified defaults and choices, rejects invalid configuration", () => { const defaults = normalizeBackupConfig(undefined, "/Documents/EdgeEver Backups"); - expect(defaults).toEqual({ enabled: false, interval: "weekly", retention: 5, directory: "/Documents/EdgeEver Backups", encrypted: false }); + expect(defaults).toEqual({ enabled: false, interval: "weekly", retention: 1, directory: "/Documents/EdgeEver Backups", encrypted: false }); + for (const retention of [1, 3, 5, 10, 20]) { + expect(normalizeBackupConfig({ ...defaults, retention }, defaults.directory).retention).toBe(retention); + } expect(() => normalizeBackupConfig({ ...defaults, interval: "hourly" }, defaults.directory)).toThrow(); expect(() => normalizeBackupConfig({ ...defaults, retention: 4 }, defaults.directory)).toThrow(); expect(() => normalizeBackupConfig({ ...defaults, directory: "" }, defaults.directory)).toThrow(); diff --git a/apps/extension/instagram-clip.test.mjs b/apps/extension/instagram-clip.test.mjs new file mode 100644 index 00000000..2996ab7b --- /dev/null +++ b/apps/extension/instagram-clip.test.mjs @@ -0,0 +1,62 @@ +import { expect, test } from 'bun:test'; +import { createDocument, createWindow } from '@mixmark-io/domino'; +import { runInNewContext } from 'node:vm'; +import { bundleTestScripts } from './bundle-test-scripts.mjs'; +import { readMetaSocial, metaPostCard } from './src/social-clip.ts'; +import { platformTarget } from './src/platform-clip.ts'; + +const url = 'https://www.instagram.com/p/SelectedPost/'; +const source = 'https://www.instagram.com/'; +const media = { code: 'SelectedPost', user: { username: 'author' }, caption: { text: 'A full caption beyond the preview' }, taken_at: 1700000000, + media_type: 8, carousel_media_count: 3, carousel_media: [1,2,3].map(i => ({ media_type: 1, image_versions2: { candidates: [{url:`https://images.example.test/${i}.jpg`,width:1000,height:1000}] } })) }; +const json = value => ``; +const feed = `
author

A full caption...

`; +const [bundle] = bundleTestScripts(['capture-platform']); + +async function capture({ html=feed, data=media, status=200, change, contentType='text/html', responseUrl=url, sourceUrl=source }={}) { + const w=createWindow(html,sourceUrl), location={href:sourceUrl}; + Object.getPrototypeOf(w.document.querySelectorAll('*'))[Symbol.iterator] ??= Array.prototype[Symbol.iterator]; + const calls=[]; + let done; + const result = new Promise(resolve=>{done=resolve;}); + runInNewContext(bundle, { window:w,document:w.document,location,URL,AbortSignal,setTimeout,clearTimeout,setInterval,clearInterval, + DOMParser:class {parseFromString(html){return createDocument(html);}}, + __edgeeverPlatformClipPayload:{requestId:'instagram',url,sourceUrl}, + fetch:async (request,init)=>{calls.push({url:request,init});change?.(w.document,location);return {ok:status===200,url:responseUrl,headers:new Headers({'content-type':contentType}),text:async()=>json(data)};}, + chrome:{i18n:{getMessage:k=>k},runtime:{sendMessage:async message=>done(message.result)}}, + }); + return {result:await result,calls}; +} + +test('Instagram dynamic feed fetches only the selected same-origin detail and keeps all carousel images',async()=>{ + const {result,calls}=await capture(); + expect(result.ok).toBe(true);expect(result.clip.url).toBe(url);expect(result.clip.author).toBe('author'); + expect(result.clip.markdown).toContain(media.caption.text);expect(result.clip.markdown.match(/!\[/g)).toHaveLength(3); + expect(calls).toHaveLength(1);expect(calls[0].url).toBe(url); + expect(calls[0].init.credentials).toBe('same-origin');expect(calls[0].init.redirect).toBe('error'); +}); + +for (const failure of ['wrong-post','login','redirect','http','not-html','incomplete','navigation','recycled','hidden','no-card']) test(`Instagram detail fallback rejects ${failure}`,async()=>{ + const {result,calls}=await capture({ + data:failure==='wrong-post'?{...media,code:'OtherPost'}:failure==='login'?{login:true}:failure==='incomplete'?{...media,carousel_media:media.carousel_media.slice(0,1)}:media, + status:failure==='http'?403:200,contentType:failure==='not-html'?'application/json':'text/html', + responseUrl:failure==='redirect'?'https://www.instagram.com/accounts/login/':url, + html:failure==='hidden'?feed.replace('
','