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 はセルフホスト型ソフトウェアです。公式デモを除
+
-
-
@@ -339,5 +338,6 @@ EdgeEver はセルフホスト型ソフトウェアです。公式デモを除
+
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
+
-
-
@@ -340,5 +339,6 @@ EdgeEver is self-hosted software. Except for official demo instances, project ma
+
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
+
-
-
@@ -340,5 +339,6 @@ EdgeEver jest oprogramowaniem do samodzielnego hostingu. Poza oficjalnymi instan
+
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 是自托管软件。除官方演示实例外,项目维护者不托
+
-
-
@@ -344,5 +343,6 @@ EdgeEver 是自托管软件。除官方演示实例外,项目维护者不托
+
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 是自行託管軟體。除官方展示實例外,專案維護者不
+
-
-
@@ -344,5 +343,6 @@ EdgeEver 是自行託管軟體。除官方展示實例外,專案維護者不
+
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 = "Untrusted attachment
";
+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('', "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('', "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,
+) => {
+ 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 => {
+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 => {
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("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('',''):failure==='no-card'?'Log in':feed,
+ change:(doc,location)=>{if(failure==='navigation')location.href='https://www.instagram.com/p/OtherPost/';if(failure==='recycled')doc.querySelector('a[href="/p/SelectedPost/"]').setAttribute('href','/p/OtherPost/');},
+ });
+ expect(result.ok).toBe(false);expect(result.clip).toBeUndefined();
+ expect(calls).toHaveLength(['hidden','no-card'].includes(failure)?0:1);
+});
+
+test('Instagram fallback preserves the current host, including the non-www origin',async()=>{
+ const {result,calls}=await capture({sourceUrl:'https://instagram.com/',responseUrl:'https://instagram.com/p/SelectedPost/'});
+ expect(result.ok).toBe(true);expect(calls[0].url).toBe('https://instagram.com/p/SelectedPost/');
+});
+
+test('Instagram detail card includes media and caption rather than the likes footer or comments',()=>{
+ const doc=createDocument(`COMMENT SENTINEL
${json(media)}`);
+ expect(metaPostCard(doc.querySelector('time').parentElement,'instagram',url).id).toBe('post');
+ const result=readMetaSocial(doc,platformTarget(url));
+ expect(result.ok).toBe(true);expect(result.clip.markdown).toContain(media.caption.text);
+ expect(result.clip.markdown).not.toContain('COMMENT SENTINEL');
+});
diff --git a/apps/extension/package.json b/apps/extension/package.json
index 462390c8..736751da 100644
--- a/apps/extension/package.json
+++ b/apps/extension/package.json
@@ -9,7 +9,7 @@
"dev": "vite --config vite.config.ts",
"lint:firefox": "web-ext lint --source-dir dist-firefox --output text",
"package:firefox": "bun run build:firefox && bun run lint:firefox && web-ext build --source-dir dist-firefox --artifacts-dir web-ext-artifacts --overwrite-dest",
- "test": "bun test manifest.test.mjs context-menus.test.mjs popup.test.mjs capture-bundle.test.mjs image-clip.test.mjs tweet-clip.test.mjs selection-clip.test.mjs github-repo.test.mjs xhs-clip.test.mjs zhihu-clip.test.mjs reddit-clip.test.mjs video-clip.test.mjs page-images.test.mjs notebook-clip.test.mjs clip-time.test.mjs platform-clip.test.mjs social-clip.test.mjs platform-capture.test.mjs platform-save.test.mjs vertical-clip.test.mjs article-clip.test.mjs"
+ "test": "bun test manifest.test.mjs context-menus.test.mjs popup.test.mjs capture-bundle.test.mjs image-clip.test.mjs tweet-clip.test.mjs selection-clip.test.mjs github-repo.test.mjs xhs-clip.test.mjs zhihu-clip.test.mjs reddit-clip.test.mjs video-clip.test.mjs page-images.test.mjs notebook-clip.test.mjs clip-time.test.mjs platform-clip.test.mjs social-clip.test.mjs instagram-clip.test.mjs platform-capture.test.mjs platform-save.test.mjs vertical-clip.test.mjs article-clip.test.mjs"
},
"dependencies": {
"@mixmark-io/domino": "2.2.0",
diff --git a/apps/extension/src/capture-platform.ts b/apps/extension/src/capture-platform.ts
index 7e5c1d97..43c199ae 100644
--- a/apps/extension/src/capture-platform.ts
+++ b/apps/extension/src/capture-platform.ts
@@ -6,6 +6,7 @@ import { readXiaoyuzhou } from "./xiaoyuzhou-clip";
import { readBoss } from "./boss-clip";
import { readXueqiu } from "./xueqiu-clip";
import { readXueqiuFeedPost } from "./xueqiu-feed";
+import { readInstagramDetail } from "./instagram-clip";
import { readArticle } from "./article-clip";
import { readJike, jikeExpandButton, jikePostCard } from "./jike-clip";
import { readDouban } from "./douban-clip";
@@ -68,6 +69,9 @@ import { platformHtmlMarkdown, platformTarget, type PlatformLabels, type Platfor
}
if(expected.platform === "xueqiu" && !platformTarget(location.href)) return readXueqiuFeedPost(document,expected,sourceUrl);
const initial = read();
+ if (expected.platform === "instagram" && !initial.ok && (initial.reason === "unreadable" || initial.reason === "incomplete")) {
+ return readInstagramDetail(document, expected, sourceUrl, samePage);
+ }
if (!initial.ok && initial.reason === "incomplete" && ["facebook", "linkedin", "jike"].includes(expected.platform)) {
const card = expected.platform === "jike" ? jikePostCard(document,expected,location.href) : socialPostCard(document,expected);
const expand = expected.platform === "jike" ? jikeExpandButton(document,card) : expected.platform === "facebook" ? facebookExpandButton(card) : linkedinExpandButton(card);
diff --git a/apps/extension/src/instagram-clip.ts b/apps/extension/src/instagram-clip.ts
new file mode 100644
index 00000000..dc4ec0b1
--- /dev/null
+++ b/apps/extension/src/instagram-clip.ts
@@ -0,0 +1,34 @@
+import { instagramPostCard, readMetaSocial } from "./social-clip";
+import { platformTarget, type PlatformRead, type PlatformTarget } from "./platform-clip";
+
+/** Feed items loaded after navigation are absent from the document's JSON.
+ * Read only the selected post's same-origin HTML, without executing its scripts. */
+export const readInstagramDetail = async (
+ doc: Document, target: PlatformTarget, sourceUrl: string, samePage: () => boolean,
+): Promise => {
+ const source = new URL(sourceUrl);
+ if (target.platform !== "instagram" || source.protocol !== "https:"
+ || !["www.instagram.com", "instagram.com"].includes(source.hostname)) return { ok: false, reason: "not-target" };
+ const card = instagramPostCard(doc, target);
+ if (!card) return { ok: false, reason: "unreadable" };
+ const current = () => samePage() && doc.contains(card) && instagramPostCard(doc, target) === card;
+ try {
+ const url = new URL(`/p/${target.id}/`, source);
+ const response = await fetch(url.href, {
+ credentials: "same-origin", redirect: "error", headers: { Accept: "text/html" },
+ signal: AbortSignal.timeout(8000),
+ });
+ if (!current()) return { ok: false, reason: "changed" };
+ if (!response.ok || !response.headers.get("content-type")?.includes("text/html")
+ || Number(response.headers.get("content-length")) > 10_000_000) return { ok: false, reason: "unreadable" };
+ if (response.url && (new URL(response.url).origin !== source.origin
+ || platformTarget(response.url)?.url !== target.url)) return { ok: false, reason: "changed" };
+ const html = await response.text();
+ if (!current()) return { ok: false, reason: "changed" };
+ if (html.length > 10_000_000) return { ok: false, reason: "unreadable" };
+ const detail = new DOMParser().parseFromString(html, "text/html");
+ return readMetaSocial(doc, target, detail);
+ } catch {
+ return { ok: false, reason: current() ? "unreadable" : "changed" };
+ }
+};
diff --git a/apps/extension/src/social-clip.ts b/apps/extension/src/social-clip.ts
index bb73899e..908508e3 100644
--- a/apps/extension/src/social-clip.ts
+++ b/apps/extension/src/social-clip.ts
@@ -235,6 +235,12 @@ export const readBluesky = (doc: Document, target: PlatformTarget): PlatformRead
return saved;
};
+const instagramCardHasMedia = (card: Element, base: string) => Array.from(card.querySelectorAll('img[src], video')).some(media => {
+ if (platformElementHidden(media)) return false;
+ const link = media.closest('a[href]');
+ return !link || platformTarget(postLinkUrl(link, base))?.platform === "instagram";
+});
+
/** Bound Meta posts by their timestamp header and rendered action controls. */
export const metaPostCard = (link: Element, platform: string, base: string): Element | null => {
if (!link.querySelector("time[datetime]") || link.closest("[hidden], [aria-hidden='true'], script, style, template")) return null;
@@ -257,7 +263,10 @@ export const metaPostCard = (link: Element, platform: string, base: string): Ele
return socialPlatform(url.href) === platform && (platform === "threads" ? /^\/@[^/]+\/?$/.test(url.pathname) : /^\/[^/]+\/?$/.test(url.pathname));
} catch { return false; }
});
- if (card.matches('article, [role="article"]') || (profile && card.querySelectorAll('button, [role="button"]').length >= 3)) return card;
+ // Instagram's detail timestamp lives in the action footer. Keep walking
+ // until the card also contains the media, rather than returning that footer.
+ if (card.matches('article, [role="article"]') || (profile && card.querySelectorAll('button, [role="button"]').length >= 3
+ && (platform !== "instagram" || instagramCardHasMedia(card, base)))) return card;
}
return null;
};
@@ -313,8 +322,18 @@ const mediaImages = (item: RecordValue, base: string): string[] | null => {
}
return images.filter(Boolean);
};
-export const readMetaSocial = (doc: Document, target: PlatformTarget): PlatformRead => {
- const media = metaMedia(doc, target);
+export const instagramPostCard = (doc: Document, target: PlatformTarget): Element | null => {
+ if (target.platform !== "instagram") return null;
+ for (const link of ownPostLinks(doc.documentElement, "instagram")) {
+ if (!sameTarget(postLinkUrl(link, target.url), target)) continue;
+ const card = metaPostCard(link, "instagram", target.url);
+ if (card && !platformElementHidden(card) && instagramCardHasMedia(card, target.url)) return card;
+ }
+ return null;
+};
+
+export const readMetaSocial = (doc: Document, target: PlatformTarget, detail?: Document): PlatformRead => {
+ const media = metaMedia(detail || doc, target);
if (!media) return { ok: false, reason: "unreadable" };
const username = text(record(media.user)?.username);
if (target.platform === "threads" && username && username.toLowerCase() !== target.id.split("/")[0]) return { ok:false, reason:"changed" };
@@ -324,7 +343,9 @@ export const readMetaSocial = (doc: Document, target: PlatformTarget): PlatformR
if (target.platform === "instagram" && Array.isArray(media.carousel_media)) {
captions.push(...media.carousel_media.map((slide) => text(record(record(slide)?.caption)?.text)));
}
- if (!readableMetaCard(doc,target,captions)) return {ok:false,reason:"unreadable"};
+ // A fetched, identity-matched detail supplies the full caption for a visible
+ // feed preview. Do not require that preview to contain the untruncated text.
+ if (detail ? !instagramPostCard(doc, target) : !readableMetaCard(doc,target,captions)) return {ok:false,reason:"unreadable"};
const slides = Array.from(new Set(captions.slice(1).filter(Boolean)));
const combined = slides.join("\n\n");
const normalized = (value: string) => value.replace(/\s+/g,"");
diff --git a/apps/ios/Config/Version.xcconfig b/apps/ios/Config/Version.xcconfig
index 04955efe..2c88998e 100644
--- a/apps/ios/Config/Version.xcconfig
+++ b/apps/ios/Config/Version.xcconfig
@@ -8,5 +8,5 @@
// CI_BUILD_NUMBER into the archive when present.
// Last completed ASC store build: 48 (2026-09-14, Xcode Cloud, v1.70.0).
// Build 49 is reserved for the 1.74.0 App Store submission.
-MARKETING_VERSION = 2.12.0
+MARKETING_VERSION = 2.14.0
CURRENT_PROJECT_VERSION = 49
diff --git a/apps/ios/EdgeEver.xcodeproj/project.pbxproj b/apps/ios/EdgeEver.xcodeproj/project.pbxproj
index 3c5de46f..060b1933 100644
--- a/apps/ios/EdgeEver.xcodeproj/project.pbxproj
+++ b/apps/ios/EdgeEver.xcodeproj/project.pbxproj
@@ -812,7 +812,7 @@
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
- MARKETING_VERSION = 2.12.0;
+ MARKETING_VERSION = 2.14.0;
MTL_ENABLE_DEBUG_INFO = NO;
MTL_FAST_MATH = YES;
PRODUCT_NAME = "$(TARGET_NAME)";
@@ -902,7 +902,7 @@
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
- MARKETING_VERSION = 2.12.0;
+ MARKETING_VERSION = 2.14.0;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
ONLY_ACTIVE_ARCH = YES;
diff --git a/apps/ios/EdgeEver/Resources/EditorBundle/index.html b/apps/ios/EdgeEver/Resources/EditorBundle/index.html
index e654fa4f..50d3a2df 100644
--- a/apps/ios/EdgeEver/Resources/EditorBundle/index.html
+++ b/apps/ios/EdgeEver/Resources/EditorBundle/index.html
@@ -7,7 +7,7 @@
content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no"
/>
EdgeEver Editor
-