diff --git a/README.ja.md b/README.ja.md index 527cfa82..c0a3e727 100644 --- a/README.ja.md +++ b/README.ja.md @@ -65,7 +65,7 @@ EdgeEver は、オープンソースのノートと知識ベースの作業領 - **Mermaid の描画**:フローチャート、シーケンス、マインドマップをノート内で描画し、Markdown とリッチテキストの両方で編集可能なソースを残します。 - **視覚的な図のノート**:Web とデスクトップでマインドマップ、フローチャート、アーキテクチャ図を作れます。意味のある部品、システム境界、ラベル付き接続、自動レイアウト、版履歴、PNG / SVG 書き出し。Android と iOS は同じデータを保持し、意味のある読み取り専用表示をします。詳しくは [visual diagram notes design](docs/visual-diagram-notes.md) を見てください。 - **版履歴**:過去の版を見て、戻せます。 -- **公開共有**:ノートを公開し、いつでも止められます。 +- **公開共有**:ノートを公開し、いつでも止められます。必要なら共有リンクに自動生成のアクセスパスワードを付けられます。 - **モバイルでの微信公式アカウント記事の取り込み**:スマホから微信公式アカウントの記事を EdgeEver に共有すると、本文を取り出して編集できるノートにします。 - **クライアント側の画像圧縮**:アップロード前に WebP 圧縮し、よくある画像で 50%〜90% 小さくします。サーバー追加料金はかかりません。 - **汎用添付**:PDF、Office、zip、音声、動画をノートに付けてプレビューできます。分割アップロードとストリーミングで最大 1 GiB まで扱えます。 diff --git a/README.md b/README.md index f159b8ea..1c262f12 100644 --- a/README.md +++ b/README.md @@ -65,7 +65,7 @@ The public demo resets every day at 3:00 AM (China Standard Time) and restores s - **Native Mermaid Diagram Rendering**: Render clear flowcharts, sequence diagrams, and mind maps directly in notes, preserving clean, editable source code across Markdown and rich text views. - **Visual Diagram Notes**: Create and edit mind maps, flowcharts, and architecture diagrams on Web and desktop with semantic components, system boundaries, labeled connections, automatic layout, revision history, and PNG/SVG export. The Android and iOS apps preserve the same diagram data and render a semantic read-only view. See the [visual diagram notes design](docs/visual-diagram-notes.md). - **Revision History**: Inspect and restore previous iterations of your notes with built-in version tracking. -- **Public Note Sharing**: Share a note publicly and stop sharing it at any time. +- **Public Note Sharing**: Share a note publicly and stop sharing it at any time. Optionally protect the link with an auto-generated access password. - **WeChat Article Clipping on Mobile**: Share a WeChat Official Account article to EdgeEver on your phone to extract its content and save it as an editable note. - **Smart Local Image Compression**: Client-side WebP compression reduces file sizes by 50%-90% before uploading, saving storage and speeding up page loads without extra server costs. - **Universal File Attachments**: Attach and preview PDFs, Office documents, zip files, audio, and video directly within notes. Chunked uploads and streaming safely support files up to 1 GiB. diff --git a/README.zh-CN.md b/README.zh-CN.md index 387f8c43..8f906e71 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -65,7 +65,7 @@ EdgeEver 是一款现代化的开源笔记与个人知识库工作区。它为 - **Mermaid 架构图与流程图渲染**:原生支持 Mermaid 代码块渲染,视图切换时完整保留可编辑源码,让绘制逻辑图表更直观。 - **可视化图表笔记**:Web 与桌面端支持创建、编辑思维导图、流程图和架构图,提供语义组件、系统边界、连线说明、自动布局、历史版本及 PNG/SVG 导出;Android 与 iOS App 保留同一份图表数据,并提供语义化只读视图。详见[可视化图表笔记设计说明](docs/visual-diagram-notes.zh-CN.md)。 - **笔记历史版本回溯**:自动记录修改历史,随时查阅与还原过往版本。 -- **公开笔记分享**:支持公开分享笔记,并可随时取消分享。 +- **公开笔记分享**:支持公开分享笔记,并可随时取消分享;需要时可为分享链接开启自动生成的访问密码。 - **移动 App 微信公众号文章剪藏**:在手机上将微信公众号文章分享至 EdgeEver,即可提取正文并保存为可继续编辑的笔记。 - **智能前端图片压缩**:图片上传前在浏览器端静默完成压缩,常见截图与大图精简 50%-90% 体积,加载更迅速、存储更省心。 - **通用文件附件支持**:支持轻松上传并插入 PDF、Office 文档、压缩包及音视频等各种附件;通过分片上传与流式处理,安全支持最大 1 GiB 附件。 diff --git a/README.zh-TW.md b/README.zh-TW.md index 6deb7ed1..2f89565a 100644 --- a/README.zh-TW.md +++ b/README.zh-TW.md @@ -65,7 +65,7 @@ EdgeEver 是一款現代化的開源筆記與個人知識庫工作區。它為 - **Mermaid 架構圖與流程圖繪製**:原生支援 Mermaid 程式碼區塊繪製,檢視切換時完整保留可編輯原始碼,讓繪製邏輯圖表更直觀。 - **視覺化圖表筆記**:Web 與桌面版支援建立、編輯心智圖、流程圖和架構圖,提供語意元件、系統邊界、連線說明、自動版面、歷史版本及 PNG/SVG 匯出;Android 與 iOS App 保留同一份圖表資料,並提供語意化唯讀檢視。詳見[視覺化圖表筆記設計說明](docs/visual-diagram-notes.zh-CN.md)。 - **筆記歷史版本回溯**:自動記錄修改歷史,隨時查閱與還原過往版本。 -- **公開筆記分享**:支援公開分享筆記,並可隨時取消分享。 +- **公開筆記分享**:支援公開分享筆記,並可隨時取消分享;需要時可為分享連結開啟自動產生的存取密碼。 - **行動 App 微信公眾號文章擷取**:在手機上將微信公眾號文章分享至 EdgeEver,即可擷取正文並儲存為可繼續編輯的筆記。 - **智慧前端圖片壓縮**:圖片上傳前在瀏覽器端靜默完成壓縮,常見截圖與大圖精簡 50%-90% 體積,載入更迅速、儲存更省心。 - **通用檔案附件支援**:支援輕鬆上傳並插入 PDF、Office 文件、壓縮檔及影音等各種附件;透過分塊上傳與串流處理,安全支援最大 1 GiB 附件。 diff --git a/apps/api/src/share-access.test.mjs b/apps/api/src/share-access.test.mjs new file mode 100644 index 00000000..afd3bea8 --- /dev/null +++ b/apps/api/src/share-access.test.mjs @@ -0,0 +1,40 @@ +import { describe, expect, test } from "bun:test"; +import { + createShareAccessCookieValue, + isShareUnlockBlocked, + isValidShareAccessCookieValue, + nextShareUnlockFailure, + SHARE_UNLOCK_MAX_ATTEMPTS, +} from "./share-access.ts"; + +describe("share access cookies", () => { + test("accepts a cookie created for the same password hash and token", async () => { + const value = await createShareAccessCookieValue("hash-a", "token-a", 1_000); + expect(await isValidShareAccessCookieValue(value, "hash-a", "token-a", 1_000)).toBe(true); + expect(await isValidShareAccessCookieValue(value, "hash-b", "token-a", 1_000)).toBe(false); + expect(await isValidShareAccessCookieValue(value, "hash-a", "token-b", 1_000)).toBe(false); + }); + + test("rejects expired cookies", async () => { + const value = await createShareAccessCookieValue("hash-a", "token-a", 1_000); + expect(await isValidShareAccessCookieValue(value, "hash-a", "token-a", 8 * 24 * 60 * 60 * 1000)).toBe(false); + }); +}); + +describe("share unlock rate limit", () => { + test("blocks after the allowed number of failures in a window", () => { + let state = { unlock_failed_count: 0, unlock_window_started_at: null, unlock_blocked_until: null }; + const now = Date.parse("2026-09-17T00:00:00.000Z"); + for (let attempt = 1; attempt <= SHARE_UNLOCK_MAX_ATTEMPTS; attempt += 1) { + const next = nextShareUnlockFailure(state, now); + state = { + unlock_failed_count: next.failureCount, + unlock_window_started_at: next.windowStartedAt, + unlock_blocked_until: next.blockedUntil, + }; + } + expect(state.unlock_failed_count).toBe(SHARE_UNLOCK_MAX_ATTEMPTS); + expect(isShareUnlockBlocked(state.unlock_blocked_until, now + 1000)).toBe(true); + expect(isShareUnlockBlocked(state.unlock_blocked_until, now + 16 * 60 * 1000)).toBe(false); + }); +}); diff --git a/apps/api/src/share-access.ts b/apps/api/src/share-access.ts new file mode 100644 index 00000000..2aa9db92 --- /dev/null +++ b/apps/api/src/share-access.ts @@ -0,0 +1,72 @@ +import { sha256 } from "./hash-utils"; + +export const SHARE_ACCESS_COOKIE = "ee_share"; +export const SHARE_ACCESS_MAX_AGE_SECONDS = 7 * 24 * 60 * 60; +export const SHARE_UNLOCK_MAX_ATTEMPTS = 8; +export const SHARE_UNLOCK_WINDOW_MS = 15 * 60 * 1000; + +export type ShareUnlockState = { + unlock_failed_count: number; + unlock_window_started_at: string | null; + unlock_blocked_until: string | null; +}; + +export const isShareUnlockBlocked = (blockedUntil: string | null, nowMs = Date.now()) => + Boolean(blockedUntil && Date.parse(blockedUntil) > nowMs); + +export const nextShareUnlockFailure = ( + state: ShareUnlockState, + nowMs = Date.now(), +): { failureCount: number; windowStartedAt: string; blockedUntil: string | null } => { + const windowStart = state.unlock_window_started_at ? Date.parse(state.unlock_window_started_at) : Number.NaN; + const inWindow = Number.isFinite(windowStart) && nowMs - windowStart < SHARE_UNLOCK_WINDOW_MS; + const previousCount = Number(state.unlock_failed_count); + const failureCount = inWindow ? (Number.isFinite(previousCount) ? previousCount : 0) + 1 : 1; + return { + failureCount, + windowStartedAt: inWindow && state.unlock_window_started_at + ? state.unlock_window_started_at + : new Date(nowMs).toISOString(), + blockedUntil: failureCount >= SHARE_UNLOCK_MAX_ATTEMPTS + ? new Date(nowMs + SHARE_UNLOCK_WINDOW_MS).toISOString() + : null, + }; +}; + +export const createShareAccessCookieValue = async ( + passwordHash: string, + token: string, + nowMs = Date.now(), +) => { + const expiryMs = nowMs + SHARE_ACCESS_MAX_AGE_SECONDS * 1000; + const mac = await sha256(shareAccessPayload(passwordHash, token, expiryMs)); + return `${expiryMs}.${mac}`; +}; + +export const isValidShareAccessCookieValue = async ( + value: string | undefined, + passwordHash: string, + token: string, + nowMs = Date.now(), +) => { + if (!value) return false; + const separator = value.indexOf("."); + if (separator <= 0) return false; + const expiryMs = Number(value.slice(0, separator)); + const mac = value.slice(separator + 1); + if (!Number.isFinite(expiryMs) || expiryMs <= nowMs || !mac) return false; + const expected = await sha256(shareAccessPayload(passwordHash, token, expiryMs)); + return timingSafeEqualString(mac, expected); +}; + +const shareAccessPayload = (passwordHash: string, token: string, expiryMs: number) => + `${passwordHash}\n${token}\n${expiryMs}`; + +const timingSafeEqualString = (left: string, right: string) => { + const length = Math.max(left.length, right.length); + let diff = left.length ^ right.length; + for (let index = 0; index < length; index += 1) { + diff |= (left.charCodeAt(index % left.length) ?? 0) ^ (right.charCodeAt(index % right.length) ?? 0); + } + return diff === 0; +}; diff --git a/apps/api/src/share-password.test.mjs b/apps/api/src/share-password.test.mjs new file mode 100644 index 00000000..23825f7e --- /dev/null +++ b/apps/api/src/share-password.test.mjs @@ -0,0 +1,15 @@ +import { describe, expect, test } from "bun:test"; +import { generateSharePassword, SHARE_PASSWORD_ALPHABET, SHARE_PASSWORD_LENGTH } from "./share-password.ts"; + +describe("generateSharePassword", () => { + test("returns an unambiguous password of the expected length", () => { + const password = generateSharePassword(); + expect(password).toHaveLength(SHARE_PASSWORD_LENGTH); + expect([...password].every((character) => SHARE_PASSWORD_ALPHABET.includes(character))).toBe(true); + }); + + test("does not repeat the same password across draws", () => { + const passwords = new Set(Array.from({ length: 20 }, () => generateSharePassword())); + expect(passwords.size).toBe(20); + }); +}); diff --git a/apps/api/src/share-password.ts b/apps/api/src/share-password.ts new file mode 100644 index 00000000..1c13fa27 --- /dev/null +++ b/apps/api/src/share-password.ts @@ -0,0 +1,17 @@ +export const SHARE_PASSWORD_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789"; +export const SHARE_PASSWORD_LENGTH = 8; + +/** Unguessable enough for a second factor on an already-secret link, short enough to copy or read aloud. */ +export const generateSharePassword = (): string => { + const result: string[] = []; + while (result.length < SHARE_PASSWORD_LENGTH) { + const bytes = crypto.getRandomValues(new Uint8Array(SHARE_PASSWORD_LENGTH * 2)); + const maxUnbiased = Math.floor(256 / SHARE_PASSWORD_ALPHABET.length) * SHARE_PASSWORD_ALPHABET.length; + for (const byte of bytes) { + if (byte >= maxUnbiased) continue; + result.push(SHARE_PASSWORD_ALPHABET[byte % SHARE_PASSWORD_ALPHABET.length]!); + if (result.length === SHARE_PASSWORD_LENGTH) break; + } + } + return result.join(""); +}; diff --git a/apps/api/src/share-routes.test.mjs b/apps/api/src/share-routes.test.mjs index d1c91b51..afd1e021 100644 --- a/apps/api/src/share-routes.test.mjs +++ b/apps/api/src/share-routes.test.mjs @@ -2,7 +2,8 @@ import { describe, expect, test } from "bun:test"; import { Database } from "bun:sqlite"; import { globSync, readFileSync } from "node:fs"; import { Hono } from "hono"; -import { registerPublicShareRoutes } from "./share-routes.ts"; +import { hashPassword } from "./auth-crypto.ts"; +import { registerMemoShareRoutes, registerPublicShareRoutes } from "./share-routes.ts"; class SqliteD1PreparedStatement { constructor(db, sql, bindings = []) { @@ -203,3 +204,138 @@ describe("public memo shares", () => { sqlite.close(); }); }); + +const memberAuth = { + kind: "user", + actorType: "user", + actorId: "user_member", + username: "member", + displayName: "Member", + scopes: [], + workspaceId: "ws_member", + role: "member", +}; + +const createShareApp = (environment) => { + const app = new Hono(); + app.use("/api/v1/*", async (c, next) => { + c.set("auth", memberAuth); + await next(); + }); + registerPublicShareRoutes(app); + registerMemoShareRoutes(app); + return app; +}; + +const readCookieValue = (response, name) => { + const header = response.headers.get("Set-Cookie") ?? ""; + const prefix = `${name}=`; + const part = header.split(";").find((item) => item.trim().startsWith(prefix)); + return part ? part.trim().slice(prefix.length) : ""; +}; + +describe("password-protected memo shares", () => { + test("keeps existing shares public until a password is enabled", async () => { + const { sqlite, environment } = createDatabaseEnvironment(); + const app = createShareApp(environment); + + const publicResponse = await app.request(`/api/public/shares/${sourceToken}`, {}, environment); + expect(publicResponse.status).toBe(200); + expect(await publicResponse.json()).toMatchObject({ share: { title: "Source" } }); + + const enabled = await app.request(`/api/v1/memos/memo_source/share`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ passwordProtected: true }), + }, environment); + expect(enabled.status).toBe(200); + const enabledBody = await enabled.json(); + expect(enabledBody.share.passwordProtected).toBe(true); + expect(enabledBody.share.password).toHaveLength(8); + expect(enabledBody.share).not.toHaveProperty("passwordHash"); + + const locked = await app.request(`/api/public/shares/${sourceToken}`, {}, environment); + expect(locked.status).toBe(403); + expect(await locked.json()).toEqual({ + error: { code: "share_password_required", message: "Password required to view this shared note" }, + }); + sqlite.close(); + }); + + test("unlocks with the generated password and then serves the note and attachments", async () => { + const { sqlite, environment } = createDatabaseEnvironment(); + const password = "testPass"; + sqlite.query("UPDATE memo_shares SET password_hash = ? WHERE token = ?") + .run(await hashPassword(password), sourceToken); + sqlite.query( + `INSERT INTO resources (id, memo_id, object_key, kind, mime_type, filename, byte_size) + VALUES (?, ?, ?, 'attachment', 'application/pdf', 'shared.pdf', 10)`, + ).run("res_locked", "memo_source", "locked-key"); + environment.storage.resources = { + get: async () => ({ + body: new Blob([new Uint8Array(10)]).stream(), + size: 10, + writeHttpMetadata: () => {}, + }), + }; + const app = createShareApp(environment); + + const deniedResource = await app.request( + `/api/public/shares/${sourceToken}/resources/res_locked/blob`, + {}, + environment, + ); + expect(deniedResource.status).toBe(403); + + const wrong = await app.request(`/api/public/shares/${sourceToken}/unlock`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ password: "wrong-password" }), + }, environment); + expect(wrong.status).toBe(403); + expect((await wrong.json()).error.code).toBe("share_password_invalid"); + + const unlocked = await app.request(`/api/public/shares/${sourceToken}/unlock`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ password }), + }, environment); + expect(unlocked.status).toBe(200); + const cookie = readCookieValue(unlocked, "ee_share"); + expect(cookie).toBeTruthy(); + + const headers = { Cookie: `ee_share=${cookie}` }; + const share = await app.request(`/api/public/shares/${sourceToken}`, { headers }, environment); + expect(share.status).toBe(200); + expect(await share.json()).toMatchObject({ share: { title: "Source" } }); + + const resource = await app.request( + `/api/public/shares/${sourceToken}/resources/res_locked/blob`, + { headers }, + environment, + ); + expect(resource.status).toBe(200); + sqlite.close(); + }); + + test("clearing the password makes the existing link public again", async () => { + const { sqlite, environment } = createDatabaseEnvironment(); + sqlite.query("UPDATE memo_shares SET password_hash = ? WHERE token = ?") + .run(await hashPassword("secretPwd"), sourceToken); + const app = createShareApp(environment); + + const cleared = await app.request(`/api/v1/memos/memo_source/share`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ passwordProtected: false }), + }, environment); + expect(cleared.status).toBe(200); + const clearedBody = await cleared.json(); + expect(clearedBody.share).toMatchObject({ passwordProtected: false }); + expect(clearedBody.share.password).toBeUndefined(); + + const publicResponse = await app.request(`/api/public/shares/${sourceToken}`, {}, environment); + expect(publicResponse.status).toBe(200); + sqlite.close(); + }); +}); diff --git a/apps/api/src/share-routes.ts b/apps/api/src/share-routes.ts index 80a3ccf1..f7465c0d 100644 --- a/apps/api/src/share-routes.ts +++ b/apps/api/src/share-routes.ts @@ -1,19 +1,37 @@ -import { collectMemoLinkIds, isPdfAttachment, resolveMemoContentDoc, resolvePlayableMediaMimeType, type MemoShare, type PublicMemoShare, type TiptapDoc } from "@edgeever/shared"; +import { collectMemoLinkIds, isPdfAttachment, MemoShareUpdateSchema, PublicShareUnlockSchema, resolveMemoContentDoc, resolvePlayableMediaMimeType, type MemoShare, type PublicMemoShare, type TiptapDoc } from "@edgeever/shared"; +import { zValidator } from "@hono/zod-validator"; import type { Hono } from "hono"; -import type { AppEnv } from "./api-context"; +import { getCookie, setCookie } from "hono/cookie"; +import type { AppContext, AppEnv } from "./api-context"; import { audit } from "./audit"; -import { randomToken } from "./auth-crypto"; +import { authenticateRequest } from "./auth-service"; +import { hashPassword, randomToken, verifyPassword } from "./auth-crypto"; import { parseByteRange, rangeNotSatisfiable } from "./byte-range"; import { createId, isoNow, parseJsonArray } from "./entity-utils"; -import { notFound } from "./http-errors"; +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 { + createShareAccessCookieValue, + isShareUnlockBlocked, + isValidShareAccessCookieValue, + nextShareUnlockFailure, + SHARE_ACCESS_COOKIE, + SHARE_ACCESS_MAX_AGE_SECONDS, +} from "./share-access"; +import { generateSharePassword } from "./share-password"; const SHARE_TOKEN_BYTES = 32; const SHARE_TOKEN_PATTERN = /^[A-Za-z0-9_-]{43}$/; -type MemoShareRow = { memo_id: string; token: string; created_at: string; updated_at: string }; +type MemoShareRow = { + memo_id: string; + token: string; + created_at: string; + updated_at: string; + password_hash: string | null; +}; type PublicMemoShareRow = { workspace_id: string; title: string | null; @@ -21,6 +39,14 @@ type PublicMemoShareRow = { content_markdown: string; tags_json: string; updated_at: string; + password_hash: string | null; +}; +type ShareGateRow = { + workspace_id: string; + password_hash: string | null; + unlock_failed_count: number; + unlock_window_started_at: string | null; + unlock_blocked_until: string | null; }; type ReferencedMemoShareRow = { memo_id: string; token: string }; type SharedResourceRow = { @@ -30,13 +56,17 @@ type SharedResourceRow = { mime_type: string | null; filename: string | null; byte_size: number; + workspace_id: string; + password_hash: string | null; }; -const mapMemoShare = (row: MemoShareRow): MemoShare => ({ +const mapMemoShare = (row: MemoShareRow, password?: string): MemoShare => ({ memoId: row.memo_id, token: row.token, createdAt: row.created_at, updatedAt: row.updated_at, + passwordProtected: Boolean(row.password_hash), + ...(password ? { password } : {}), }); const contentDisposition = (kind: SharedResourceRow["kind"], mimeType: string | null, filename: string | null) => { @@ -49,13 +79,62 @@ const normalizeShareToken = (value: string) => { return SHARE_TOKEN_PATTERN.test(token) ? token : null; }; +const sharePasswordRequired = (c: AppContext) => + apiError(c, "share_password_required", "Password required to view this shared note", 403); + +const sharePasswordInvalid = (c: AppContext) => + apiError(c, "share_password_invalid", "Incorrect share password", 403); + +const shareUnlockRateLimited = (c: AppContext) => + apiError(c, "share_unlock_rate_limited", "Too many share password attempts. Try again later.", 429); + +const shareAccessCookiePath = (token: string) => `/api/public/shares/${encodeURIComponent(token)}`; + +const setShareAccessCookie = async (c: AppContext, token: string, passwordHash: string) => { + const value = await createShareAccessCookieValue(passwordHash, token); + setCookie(c, SHARE_ACCESS_COOKIE, value, { + httpOnly: true, + secure: new URL(c.req.url).protocol === "https:", + sameSite: "Lax", + path: shareAccessCookiePath(token), + maxAge: SHARE_ACCESS_MAX_AGE_SECONDS, + }); +}; + +const hasShareAccessCookie = async (c: AppContext, token: string, passwordHash: string) => + isValidShareAccessCookieValue(getCookie(c, SHARE_ACCESS_COOKIE), passwordHash, token); + +const isShareOwner = async (c: AppContext, workspaceId: string) => { + const auth = await authenticateRequest(c, false); + return auth?.workspaceId === workspaceId; +}; + +const allowPasswordProtectedShare = async ( + c: AppContext, + token: string, + workspaceId: string, + passwordHash: string, +) => hasShareAccessCookie(c, token, passwordHash) || isShareOwner(c, workspaceId); + +const loadShareGate = async (c: AppContext, token: string) => + c.env.storage.db.prepare( + `SELECT ms.workspace_id, ms.password_hash, ms.unlock_failed_count, ms.unlock_window_started_at, ms.unlock_blocked_until + FROM memo_shares ms + INNER JOIN memos m ON m.id = ms.memo_id AND m.workspace_id = ms.workspace_id + WHERE ms.token = ? AND m.is_deleted = 0 + LIMIT 1` + ).bind(token).first(); + +const selectMemoShareSql = `SELECT memo_id, token, created_at, updated_at, password_hash + FROM memo_shares WHERE memo_id = ? AND workspace_id = ?`; + export const registerPublicShareRoutes = (app: Hono) => { app.get("/api/public/shares/:token", async (c) => { const token = normalizeShareToken(c.req.param("token")); if (!token) return notFound(c, "Shared note not found"); const row = await c.env.storage.db.prepare( - `SELECT ms.workspace_id, m.title, mc.content_json, mc.content_markdown, m.tags_json, m.updated_at + `SELECT ms.workspace_id, m.title, mc.content_json, mc.content_markdown, m.tags_json, m.updated_at, ms.password_hash FROM memo_shares ms INNER JOIN memos m ON m.id = ms.memo_id AND m.workspace_id = ms.workspace_id INNER JOIN memo_contents mc ON mc.memo_id = m.id @@ -63,6 +142,9 @@ export const registerPublicShareRoutes = (app: Hono) => { LIMIT 1` ).bind(token).first(); if (!row) return notFound(c, "Shared note not found"); + if (row.password_hash && !(await allowPasswordProtectedShare(c, token, row.workspace_id, row.password_hash))) { + return sharePasswordRequired(c); + } const contentJson = JSON.parse(row.content_json) as TiptapDoc; const referencedMemoIds = collectMemoLinkIds(resolveMemoContentDoc(contentJson, row.content_markdown)); @@ -94,12 +176,48 @@ export const registerPublicShareRoutes = (app: Hono) => { return c.json({ share }); }); + app.post("/api/public/shares/:token/unlock", zValidator("json", PublicShareUnlockSchema), async (c) => { + const token = normalizeShareToken(c.req.param("token")); + if (!token) return notFound(c, "Shared note not found"); + + const gate = await loadShareGate(c, token); + if (!gate) return notFound(c, "Shared note not found"); + if (!gate.password_hash) { + return apiError(c, "share_password_not_required", "This shared note does not require a password", 400); + } + if (isShareUnlockBlocked(gate.unlock_blocked_until)) { + return shareUnlockRateLimited(c); + } + + const password = c.req.valid("json").password; + if (!(await verifyPassword(password, gate.password_hash))) { + const failure = nextShareUnlockFailure(gate); + await c.env.storage.db.prepare( + `UPDATE memo_shares + SET unlock_failed_count = ?, unlock_window_started_at = ?, unlock_blocked_until = ? + WHERE token = ?` + ).bind(failure.failureCount, failure.windowStartedAt, failure.blockedUntil, token).run(); + return failure.blockedUntil ? shareUnlockRateLimited(c) : sharePasswordInvalid(c); + } + + await c.env.storage.db.prepare( + `UPDATE memo_shares + SET unlock_failed_count = 0, unlock_window_started_at = NULL, unlock_blocked_until = NULL + WHERE token = ?` + ).bind(token).run(); + await setShareAccessCookie(c, token, gate.password_hash); + c.header("Cache-Control", "private, no-store"); + c.header("X-Robots-Tag", "noindex, nofollow, noarchive"); + return c.json({ ok: true }); + }); + app.get("/api/public/shares/:token/resources/:resourceId/blob", async (c) => { const token = normalizeShareToken(c.req.param("token")); if (!token) return notFound(c, "Shared resource not found"); const resource = await c.env.storage.db.prepare( - `SELECT r.object_key, r.storage_config_id, r.kind, r.mime_type, r.filename, r.byte_size + `SELECT r.object_key, r.storage_config_id, r.kind, r.mime_type, r.filename, r.byte_size, + ms.workspace_id, ms.password_hash FROM memo_shares ms INNER JOIN memos m ON m.id = ms.memo_id AND m.workspace_id = ms.workspace_id INNER JOIN resources r ON r.memo_id = m.id @@ -107,6 +225,9 @@ export const registerPublicShareRoutes = (app: Hono) => { LIMIT 1` ).bind(token, c.req.param("resourceId")).first(); if (!resource) return notFound(c, "Shared resource not found"); + if (resource.password_hash && !(await allowPasswordProtectedShare(c, token, resource.workspace_id, resource.password_hash))) { + return sharePasswordRequired(c); + } const byteRange = parseByteRange(c.req.header("Range"), resource.byte_size); if (byteRange.kind === "invalid") { @@ -157,7 +278,7 @@ export const registerMemoShareRoutes = (app: Hono) => { if (denied) return denied; const row = await c.env.storage.db.prepare( - `SELECT ms.memo_id, ms.token, ms.created_at, ms.updated_at + `SELECT ms.memo_id, ms.token, ms.created_at, ms.updated_at, ms.password_hash FROM memo_shares ms INNER JOIN memos m ON m.id = ms.memo_id AND m.workspace_id = ms.workspace_id WHERE ms.memo_id = ? AND ms.workspace_id = ? AND m.is_deleted = 0 @@ -177,9 +298,8 @@ export const registerMemoShareRoutes = (app: Hono) => { ).bind(memoId, workspaceId).first<{ id: string }>(); if (!memo) return notFound(c, "Memo not found"); - const existing = await c.env.storage.db.prepare( - `SELECT memo_id, token, created_at, updated_at FROM memo_shares WHERE memo_id = ? AND workspace_id = ?` - ).bind(memoId, workspaceId).first(); + const existing = await c.env.storage.db.prepare(selectMemoShareSql) + .bind(memoId, workspaceId).first(); if (existing) return c.json({ share: mapMemoShare(existing) }); const now = isoNow(); @@ -189,9 +309,8 @@ export const registerMemoShareRoutes = (app: Hono) => { `INSERT OR IGNORE INTO memo_shares (id, memo_id, workspace_id, token, created_by, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)` ).bind(createId("share"), memoId, workspaceId, token, actor.actorId, now, now).run(); - const created = await c.env.storage.db.prepare( - `SELECT memo_id, token, created_at, updated_at FROM memo_shares WHERE memo_id = ? AND workspace_id = ?` - ).bind(memoId, workspaceId).first(); + const created = await c.env.storage.db.prepare(selectMemoShareSql) + .bind(memoId, workspaceId).first(); if (!created) { throw new Error("Could not allocate a unique memo share token"); } @@ -202,6 +321,56 @@ export const registerMemoShareRoutes = (app: Hono) => { return c.json({ share: mapMemoShare(created) }, isNewShare ? 201 : 200); }); + app.patch("/api/v1/memos/:id/share", zValidator("json", MemoShareUpdateSchema), async (c) => { + const denied = requireUser(c); + if (denied) return denied; + + const memoId = c.req.param("id"); + const workspaceId = getWorkspaceId(c); + const existing = await c.env.storage.db.prepare( + `${selectMemoShareSql} AND EXISTS ( + SELECT 1 FROM memos m WHERE m.id = memo_shares.memo_id AND m.workspace_id = memo_shares.workspace_id AND m.is_deleted = 0 + )` + ).bind(memoId, workspaceId).first(); + if (!existing) return notFound(c, "Active share not found"); + + const now = isoNow(); + const actor = getAuditActor(c); + if (!c.req.valid("json").passwordProtected) { + await c.env.storage.db.prepare( + `UPDATE memo_shares + SET password_hash = NULL, unlock_failed_count = 0, unlock_window_started_at = NULL, + unlock_blocked_until = NULL, updated_at = ? + WHERE memo_id = ? AND workspace_id = ?` + ).bind(now, memoId, workspaceId).run(); + await audit(c.env.storage.db, actor.actorType, actor.actorId, "memo.share_password_clear", "memo", memoId, {}); + const cleared = await c.env.storage.db.prepare(selectMemoShareSql) + .bind(memoId, workspaceId).first(); + return c.json({ share: mapMemoShare(cleared ?? { ...existing, password_hash: null, updated_at: now }) }); + } + + const password = generateSharePassword(); + const passwordHash = await hashPassword(password); + await c.env.storage.db.prepare( + `UPDATE memo_shares + SET password_hash = ?, unlock_failed_count = 0, unlock_window_started_at = NULL, + unlock_blocked_until = NULL, updated_at = ? + WHERE memo_id = ? AND workspace_id = ?` + ).bind(passwordHash, now, memoId, workspaceId).run(); + await audit( + c.env.storage.db, + actor.actorType, + actor.actorId, + existing.password_hash ? "memo.share_password_rotate" : "memo.share_password_set", + "memo", + memoId, + {}, + ); + const updated = await c.env.storage.db.prepare(selectMemoShareSql) + .bind(memoId, workspaceId).first(); + return c.json({ share: mapMemoShare(updated ?? { ...existing, password_hash: passwordHash, updated_at: now }, password) }); + }); + app.delete("/api/v1/memos/:id/share", async (c) => { const denied = requireUser(c); if (denied) return denied; diff --git a/apps/ios/EdgeEver/Data/Models/Models.swift b/apps/ios/EdgeEver/Data/Models/Models.swift index f85a7acb..18034883 100644 --- a/apps/ios/EdgeEver/Data/Models/Models.swift +++ b/apps/ios/EdgeEver/Data/Models/Models.swift @@ -319,6 +319,7 @@ struct MemoShare: Codable, Equatable, Sendable { var token: String var createdAt: String var updatedAt: String + var passwordProtected: Bool? } struct InstanceUser: Codable, Equatable, Sendable, Identifiable { diff --git a/apps/web/src/components/DiagramEditorPane.tsx b/apps/web/src/components/DiagramEditorPane.tsx index 6548bb1c..d4c0bc6c 100644 --- a/apps/web/src/components/DiagramEditorPane.tsx +++ b/apps/web/src/components/DiagramEditorPane.tsx @@ -40,7 +40,7 @@ import { KeyRound, Layers3, ListTree, - Link2, + Share2, LockKeyhole, LoaderCircle, MonitorSmartphone, @@ -2790,7 +2790,7 @@ export const DiagramEditorPane = ({ {!readOnly && ( setShareOpen(true)}> - + {t(isLocalMemoId(memo.id) ? "sharing.afterSync" : "sharing.action")} )} diff --git a/apps/web/src/components/EditorPane.tsx b/apps/web/src/components/EditorPane.tsx index 1c9919c5..671d071e 100644 --- a/apps/web/src/components/EditorPane.tsx +++ b/apps/web/src/components/EditorPane.tsx @@ -24,7 +24,7 @@ import { FileDown, FileCode2, Printer, - Link2, + Image as ImageIcon, Share2, Copy, Lock, @@ -3343,7 +3343,7 @@ const RichEditorPane = ({ disabled={effectiveReadOnly} onClick={() => setShareOpen(true)} > -