feat(sharing): add optional password protection for public note shares
Allow owners to turn on an auto-generated access password when sharing a note. Visitors must unlock the share page before reading the note or its attachments; existing public links stay open until a password is enabled.
This commit is contained in:
1 parent
f0a5c870b5
commit
dcfd11468d
25 files changed
+772
-48
No files matched your search
+1
-1
@@ -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 まで扱えます。
|
||||
|
||||
@@ -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.
|
||||
|
||||
+1
-1
@@ -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 附件。
|
||||
|
||||
+1
-1
@@ -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 附件。
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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("");
|
||||
};
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
+184
-15
@@ -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<ShareGateRow>();
|
||||
|
||||
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<AppEnv>) => {
|
||||
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<AppEnv>) => {
|
||||
LIMIT 1`
|
||||
).bind(token).first<PublicMemoShareRow>();
|
||||
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<AppEnv>) => {
|
||||
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<AppEnv>) => {
|
||||
LIMIT 1`
|
||||
).bind(token, c.req.param("resourceId")).first<SharedResourceRow>();
|
||||
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<AppEnv>) => {
|
||||
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<AppEnv>) => {
|
||||
).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<MemoShareRow>();
|
||||
const existing = await c.env.storage.db.prepare(selectMemoShareSql)
|
||||
.bind(memoId, workspaceId).first<MemoShareRow>();
|
||||
if (existing) return c.json({ share: mapMemoShare(existing) });
|
||||
|
||||
const now = isoNow();
|
||||
@@ -189,9 +309,8 @@ export const registerMemoShareRoutes = (app: Hono<AppEnv>) => {
|
||||
`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<MemoShareRow>();
|
||||
const created = await c.env.storage.db.prepare(selectMemoShareSql)
|
||||
.bind(memoId, workspaceId).first<MemoShareRow>();
|
||||
if (!created) {
|
||||
throw new Error("Could not allocate a unique memo share token");
|
||||
}
|
||||
@@ -202,6 +321,56 @@ export const registerMemoShareRoutes = (app: Hono<AppEnv>) => {
|
||||
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<MemoShareRow>();
|
||||
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<MemoShareRow>();
|
||||
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<MemoShareRow>();
|
||||
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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -40,7 +40,7 @@ import {
|
||||
KeyRound,
|
||||
Layers3,
|
||||
ListTree,
|
||||
Link2,
|
||||
Share2,
|
||||
LockKeyhole,
|
||||
LoaderCircle,
|
||||
MonitorSmartphone,
|
||||
@@ -2790,7 +2790,7 @@ export const DiagramEditorPane = ({
|
||||
</DropdownMenuItem>
|
||||
{!readOnly && (
|
||||
<DropdownMenuItem disabled={isLocalMemoId(memo.id)} onClick={() => setShareOpen(true)}>
|
||||
<Link2 className="h-4 w-4 text-slate-500" />
|
||||
<Share2 className="h-4 w-4 text-slate-500" />
|
||||
{t(isLocalMemoId(memo.id) ? "sharing.afterSync" : "sharing.action")}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
@@ -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)}
|
||||
>
|
||||
<Link2 className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
<Share2 className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
<span className="hidden sm:inline">{t("sharing.active")}</span>
|
||||
</button>
|
||||
)}
|
||||
@@ -3566,7 +3566,7 @@ const RichEditorPane = ({
|
||||
disabled={isLocalMemoId(memo.id)}
|
||||
onClick={() => setShareOpen(true)}
|
||||
>
|
||||
<Link2 className={cn("h-4 w-4", isMemoShared ? "text-emerald-600" : "text-slate-500")} />
|
||||
<Share2 className={cn("h-4 w-4", isMemoShared ? "text-emerald-600" : "text-slate-500")} />
|
||||
{t(isLocalMemoId(memo.id) ? "sharing.afterSync" : isMemoShared ? "sharing.manage" : "sharing.action")}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
@@ -3595,7 +3595,7 @@ const RichEditorPane = ({
|
||||
className="flex h-9 w-full items-center gap-2 px-3 text-left text-sm text-slate-700 hover:bg-slate-50 cursor-pointer outline-none"
|
||||
onClick={handleOpenImageShare}
|
||||
>
|
||||
<Share2 className="h-4 w-4 text-slate-500" />
|
||||
<ImageIcon className="h-4 w-4 text-slate-500" />
|
||||
{t("editor.imageShare.action")}
|
||||
</DropdownMenuItem>
|
||||
{readOnly ? (
|
||||
|
||||
@@ -40,7 +40,7 @@ import {
|
||||
MoreVertical,
|
||||
CheckCircle2,
|
||||
TagX,
|
||||
Link2,
|
||||
Image as ImageIcon,
|
||||
Share2,
|
||||
FileDown,
|
||||
FileCode2,
|
||||
@@ -1635,7 +1635,7 @@ export const MemoListPane = ({
|
||||
disabled={isLocalMemoId(memoContextMenu.memo.id)}
|
||||
onClick={() => requestContextDocumentAction("share")}
|
||||
>
|
||||
<Link2 className="h-4 w-4 text-slate-500" />
|
||||
<Share2 className="h-4 w-4 text-slate-500" />
|
||||
{t(isLocalMemoId(memoContextMenu.memo.id) ? "sharing.afterSync" : "sharing.action")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
@@ -1663,7 +1663,7 @@ export const MemoListPane = ({
|
||||
className="flex h-9 w-full items-center gap-2 px-3 text-left text-sm text-slate-700 hover:bg-slate-50 cursor-pointer outline-none"
|
||||
onClick={() => requestContextDocumentAction("share-image")}
|
||||
>
|
||||
<Share2 className="h-4 w-4 text-slate-500" />
|
||||
<ImageIcon className="h-4 w-4 text-slate-500" />
|
||||
{t("editor.imageShare.action")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
|
||||
@@ -2,12 +2,14 @@ import "katex/dist/katex.min.css";
|
||||
import { Node, mergeAttributes } from "@tiptap/core";
|
||||
import Image from "@tiptap/extension-image";
|
||||
import { EditorContent, useEditor } from "@tiptap/react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Clock3, FileText, LoaderCircle, ShieldCheck } from "lucide-react";
|
||||
import { lazy, Suspense, useEffect, useMemo } from "react";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { Clock3, FileText, LoaderCircle, LockKeyhole, ShieldCheck } from "lucide-react";
|
||||
import { lazy, Suspense, useEffect, useMemo, useState, type FormEvent } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useParams } from "react-router";
|
||||
import { api } from "@/lib/api";
|
||||
import { ApiRequestError, api } from "@/lib/api";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { EdgeEverCodeBlock, codeBlockLowlight } from "@/lib/code-block";
|
||||
import { withEnvironmentTitlePrefix } from "@/lib/environment-title";
|
||||
import { resolvePublicShareBody } from "@/lib/public-share-body";
|
||||
@@ -126,6 +128,59 @@ const SharedDocument = ({
|
||||
return <SharedRichText content={body.content} />;
|
||||
};
|
||||
|
||||
const isSharePasswordError = (error: unknown, code: string) =>
|
||||
error instanceof ApiRequestError && error.code === code;
|
||||
|
||||
const PublicSharePasswordForm = ({
|
||||
token,
|
||||
onUnlocked,
|
||||
}: {
|
||||
token: string;
|
||||
onUnlocked: () => Promise<unknown>;
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [password, setPassword] = useState("");
|
||||
const unlockMutation = useMutation({
|
||||
mutationFn: () => api.unlockPublicMemoShare(token, password),
|
||||
onSuccess: () => onUnlocked(),
|
||||
});
|
||||
const submit = (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (!password.trim() || unlockMutation.isPending) return;
|
||||
unlockMutation.mutate();
|
||||
};
|
||||
const errorKey = isSharePasswordError(unlockMutation.error, "share_unlock_rate_limited")
|
||||
? "sharing.passwordRateLimited"
|
||||
: unlockMutation.error
|
||||
? "sharing.passwordInvalid"
|
||||
: null;
|
||||
|
||||
return (
|
||||
<main className="flex min-h-[100dvh] items-center justify-center bg-slate-50 px-5">
|
||||
<section className="w-full max-w-md rounded-2xl border border-slate-200 bg-card p-8 shadow-sm">
|
||||
<LockKeyhole className="mx-auto h-9 w-9 text-emerald-600" />
|
||||
<h1 className="mt-4 text-center text-xl font-semibold text-slate-900">{t("sharing.passwordRequiredTitle")}</h1>
|
||||
<p className="mt-2 text-center text-sm leading-6 text-slate-500">{t("sharing.passwordRequiredHint")}</p>
|
||||
<form className="mt-6 space-y-3" onSubmit={submit}>
|
||||
<Input
|
||||
type="password"
|
||||
value={password}
|
||||
autoComplete="off"
|
||||
autoFocus
|
||||
aria-label={t("sharing.passwordLabel")}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
/>
|
||||
<Button className="w-full" variant="solid" type="submit" disabled={!password.trim() || unlockMutation.isPending}>
|
||||
{unlockMutation.isPending ? <LoaderCircle className="h-4 w-4 animate-spin" /> : <LockKeyhole className="h-4 w-4" />}
|
||||
{t("sharing.passwordSubmit")}
|
||||
</Button>
|
||||
{errorKey ? <p className="text-sm text-rose-600" role="alert">{t(errorKey)}</p> : null}
|
||||
</form>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
};
|
||||
|
||||
export const PublicSharePage = () => {
|
||||
const { t, i18n } = useTranslation();
|
||||
const { token = "" } = useParams();
|
||||
@@ -136,6 +191,7 @@ export const PublicSharePage = () => {
|
||||
retry: false,
|
||||
});
|
||||
const share = shareQuery.data?.share;
|
||||
const passwordRequired = isSharePasswordError(shareQuery.error, "share_password_required");
|
||||
|
||||
useEffect(() => {
|
||||
const previousTitle = document.title;
|
||||
@@ -148,12 +204,17 @@ export const PublicSharePage = () => {
|
||||
`${share.title?.trim() || t("common.untitledMemo")} · EdgeEver`,
|
||||
{ development: import.meta.env.DEV, profile: __EDGEEVER_DEVELOPMENT_PROFILE__ },
|
||||
);
|
||||
} else if (passwordRequired) {
|
||||
document.title = withEnvironmentTitlePrefix(t("sharing.passwordRequiredTitle"), {
|
||||
development: import.meta.env.DEV,
|
||||
profile: __EDGEEVER_DEVELOPMENT_PROFILE__,
|
||||
});
|
||||
}
|
||||
return () => {
|
||||
document.title = previousTitle;
|
||||
robots.remove();
|
||||
};
|
||||
}, [share, t]);
|
||||
}, [passwordRequired, share, t]);
|
||||
|
||||
if (shareQuery.isLoading) {
|
||||
return (
|
||||
@@ -163,6 +224,10 @@ export const PublicSharePage = () => {
|
||||
);
|
||||
}
|
||||
|
||||
if (passwordRequired) {
|
||||
return <PublicSharePasswordForm token={token} onUnlocked={() => shareQuery.refetch()} />;
|
||||
}
|
||||
|
||||
if (!share) {
|
||||
return (
|
||||
<main className="flex min-h-[100dvh] items-center justify-center bg-slate-50 px-5">
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Check, Copy, ExternalLink, Link2, LoaderCircle, Trash2 } from "lucide-react";
|
||||
import { Check, Copy, ExternalLink, Link2, LoaderCircle, RefreshCw, Share2, Trash2 } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { api, getConfiguredDesktopApiBaseUrl } from "@/lib/api";
|
||||
import { copyTextToClipboard } from "@/lib/clipboard";
|
||||
|
||||
@@ -13,6 +14,35 @@ const getPublicShareUrl = (token: string) => {
|
||||
return `${baseUrl.replace(/\/$/, "")}/share/${encodeURIComponent(token)}`;
|
||||
};
|
||||
|
||||
const sharePasswordStorageKey = (memoId: string) => `edgeever.sharePassword.${memoId}`;
|
||||
|
||||
const readStoredSharePassword = (memoId: string, token: string) => {
|
||||
try {
|
||||
const raw = window.sessionStorage.getItem(sharePasswordStorageKey(memoId));
|
||||
if (!raw) return "";
|
||||
const parsed = JSON.parse(raw) as { token?: string; password?: string };
|
||||
return parsed.token === token && typeof parsed.password === "string" ? parsed.password : "";
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
};
|
||||
|
||||
const writeStoredSharePassword = (memoId: string, token: string, password: string) => {
|
||||
try {
|
||||
window.sessionStorage.setItem(sharePasswordStorageKey(memoId), JSON.stringify({ token, password }));
|
||||
} catch {
|
||||
// sessionStorage may be unavailable; the generated password is still shown in this dialog session.
|
||||
}
|
||||
};
|
||||
|
||||
const clearStoredSharePassword = (memoId: string) => {
|
||||
try {
|
||||
window.sessionStorage.removeItem(sharePasswordStorageKey(memoId));
|
||||
} catch {
|
||||
// Ignore restricted storage contexts.
|
||||
}
|
||||
};
|
||||
|
||||
export const memoShareQueryKey = (memoId: string) => ["memo-share", memoId] as const;
|
||||
|
||||
export const ShareMemoDialog = ({
|
||||
@@ -27,6 +57,8 @@ export const ShareMemoDialog = ({
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
const [copyState, setCopyState] = useState<"idle" | "copied" | "error">("idle");
|
||||
const [copyTarget, setCopyTarget] = useState<"link" | "password" | "both">("link");
|
||||
const [revealedPassword, setRevealedPassword] = useState("");
|
||||
const copyResetTimerRef = useRef<number | null>(null);
|
||||
const queryKey = memoShareQueryKey(memoId);
|
||||
const shareQuery = useQuery({
|
||||
@@ -39,26 +71,54 @@ export const ShareMemoDialog = ({
|
||||
mutationFn: () => api.createMemoShare(memoId),
|
||||
onSuccess: (data) => queryClient.setQueryData(queryKey, data),
|
||||
});
|
||||
const passwordMutation = useMutation({
|
||||
mutationFn: (passwordProtected: boolean) => api.updateMemoShare(memoId, { passwordProtected }),
|
||||
onSuccess: (data) => {
|
||||
queryClient.setQueryData(queryKey, { share: { ...data.share, password: undefined } });
|
||||
if (data.share.password) {
|
||||
writeStoredSharePassword(memoId, data.share.token, data.share.password);
|
||||
setRevealedPassword(data.share.password);
|
||||
} else {
|
||||
clearStoredSharePassword(memoId);
|
||||
setRevealedPassword("");
|
||||
}
|
||||
},
|
||||
});
|
||||
const revokeMutation = useMutation({
|
||||
mutationFn: () => api.revokeMemoShare(memoId),
|
||||
onSuccess: () => queryClient.setQueryData(queryKey, { share: null }),
|
||||
onSuccess: () => {
|
||||
clearStoredSharePassword(memoId);
|
||||
setRevealedPassword("");
|
||||
queryClient.setQueryData(queryKey, { share: null });
|
||||
},
|
||||
});
|
||||
useEffect(() => {
|
||||
createMutation.reset();
|
||||
passwordMutation.reset();
|
||||
revokeMutation.reset();
|
||||
setCopyState("idle");
|
||||
setCopyTarget("link");
|
||||
setRevealedPassword("");
|
||||
}, [memoId]);
|
||||
useEffect(() => () => {
|
||||
if (copyResetTimerRef.current !== null) window.clearTimeout(copyResetTimerRef.current);
|
||||
}, []);
|
||||
|
||||
const share = shareQuery.data?.share ?? null;
|
||||
const shareUrl = share ? getPublicShareUrl(share.token) : "";
|
||||
const isWorking = shareQuery.isLoading || createMutation.isPending || revokeMutation.isPending;
|
||||
const error = shareQuery.error || createMutation.error || revokeMutation.error;
|
||||
useEffect(() => {
|
||||
if (!share?.passwordProtected) {
|
||||
setRevealedPassword("");
|
||||
return;
|
||||
}
|
||||
setRevealedPassword(readStoredSharePassword(memoId, share.token));
|
||||
}, [memoId, share?.passwordProtected, share?.token]);
|
||||
|
||||
const copyLink = async () => {
|
||||
const copied = await copyTextToClipboard(shareUrl);
|
||||
const shareUrl = share ? getPublicShareUrl(share.token) : "";
|
||||
const isWorking = shareQuery.isLoading || createMutation.isPending || passwordMutation.isPending || revokeMutation.isPending;
|
||||
const error = shareQuery.error || createMutation.error || passwordMutation.error || revokeMutation.error;
|
||||
|
||||
const markCopied = (target: "link" | "password" | "both", copied: boolean) => {
|
||||
setCopyTarget(target);
|
||||
setCopyState(copied ? "copied" : "error");
|
||||
if (copyResetTimerRef.current !== null) window.clearTimeout(copyResetTimerRef.current);
|
||||
copyResetTimerRef.current = window.setTimeout(() => {
|
||||
@@ -67,15 +127,28 @@ export const ShareMemoDialog = ({
|
||||
}, 3000);
|
||||
};
|
||||
|
||||
const copyValue = async (target: "link" | "password" | "both", value: string) => {
|
||||
markCopied(target, await copyTextToClipboard(value));
|
||||
};
|
||||
|
||||
const copiedLabel = (target: "link" | "password" | "both", idleKey: string) =>
|
||||
copyState === "copied" && copyTarget === target
|
||||
? "sharing.copied"
|
||||
: copyState === "error" && copyTarget === target
|
||||
? "sharing.copyFailed"
|
||||
: idleKey;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-lg gap-0 overflow-hidden p-0">
|
||||
<DialogHeader className="border-b border-slate-200 px-5 py-5 pr-12 text-left">
|
||||
<DialogTitle className="flex items-center gap-2 text-base">
|
||||
<Link2 className="h-5 w-5 text-emerald-600" />
|
||||
<Share2 className="h-5 w-5 text-emerald-600" />
|
||||
{t("sharing.title")}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="pt-1 leading-5">{t("sharing.description")}</DialogDescription>
|
||||
<DialogDescription className="pt-1 leading-5">
|
||||
{t(share?.passwordProtected ? "sharing.descriptionProtected" : "sharing.description")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 px-5 py-5">
|
||||
@@ -88,15 +161,71 @@ export const ShareMemoDialog = ({
|
||||
<div className="flex gap-2">
|
||||
<Input value={shareUrl} readOnly aria-label={t("sharing.linkLabel")} className="min-w-0 font-mono text-xs" />
|
||||
<Button
|
||||
variant={copyState === "copied" ? "solid" : copyState === "error" ? "danger" : "outline"}
|
||||
variant={copyState === "copied" && copyTarget === "link" ? "solid" : copyState === "error" && copyTarget === "link" ? "danger" : "outline"}
|
||||
className="min-w-28"
|
||||
aria-live="polite"
|
||||
onClick={() => void copyLink()}
|
||||
onClick={() => void copyValue("link", shareUrl)}
|
||||
>
|
||||
{copyState === "copied" ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
|
||||
{t(copyState === "copied" ? "sharing.copied" : copyState === "error" ? "sharing.copyFailed" : "sharing.copy")}
|
||||
{copyState === "copied" && copyTarget === "link" ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
|
||||
{t(copiedLabel("link", "sharing.copy"))}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 rounded-lg border border-slate-200 px-3 py-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="text-sm font-medium text-slate-900">{t("sharing.passwordToggle")}</div>
|
||||
<Switch
|
||||
checked={share.passwordProtected}
|
||||
disabled={isWorking}
|
||||
onCheckedChange={(enabled) => passwordMutation.mutate(enabled)}
|
||||
aria-label={t("sharing.passwordToggle")}
|
||||
/>
|
||||
</div>
|
||||
{share.passwordProtected ? (
|
||||
<div className="space-y-2">
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={revealedPassword}
|
||||
readOnly
|
||||
placeholder={t("sharing.passwordSet")}
|
||||
aria-label={t("sharing.passwordLabel")}
|
||||
className="min-w-0 font-mono text-xs tracking-wide"
|
||||
/>
|
||||
<Button
|
||||
variant={copyState === "copied" && copyTarget === "password" ? "solid" : copyState === "error" && copyTarget === "password" ? "danger" : "outline"}
|
||||
className="min-w-28"
|
||||
disabled={!revealedPassword}
|
||||
aria-live="polite"
|
||||
onClick={() => void copyValue("password", revealedPassword)}
|
||||
>
|
||||
{copyState === "copied" && copyTarget === "password" ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
|
||||
{t(copiedLabel("password", "sharing.passwordCopy"))}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
disabled={isWorking}
|
||||
title={t("sharing.regeneratePassword")}
|
||||
aria-label={t("sharing.regeneratePassword")}
|
||||
onClick={() => passwordMutation.mutate(true)}
|
||||
>
|
||||
{passwordMutation.isPending ? <LoaderCircle className="h-4 w-4 animate-spin" /> : <RefreshCw className="h-4 w-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
{revealedPassword ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="h-8 px-0 text-xs text-slate-600 hover:bg-transparent hover:text-slate-900"
|
||||
onClick={() => void copyValue("both", `${shareUrl}\n${t("sharing.passwordPrefix")}${revealedPassword}`)}
|
||||
>
|
||||
{copyState === "copied" && copyTarget === "both" ? <Check className="h-3.5 w-3.5" /> : <Copy className="h-3.5 w-3.5" />}
|
||||
{t(copiedLabel("both", "sharing.copyLinkAndPassword"))}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<p className="text-xs leading-5 text-slate-500">{t("sharing.liveContentHint")}</p>
|
||||
<div className="flex flex-col-reverse gap-2 sm:flex-row sm:justify-between">
|
||||
<Button variant="danger" disabled={isWorking} onClick={() => revokeMutation.mutate()}>
|
||||
|
||||
@@ -225,7 +225,7 @@ const beforeRequest = ({ path }: EdgeEverClientRequestContext) => {
|
||||
};
|
||||
|
||||
const handleUnauthorized = ({ path, token }: EdgeEverClientRequestContext) => {
|
||||
if (path === "/api/v1/auth/login" || typeof window === "undefined") return;
|
||||
if (path === "/api/v1/auth/login" || path.startsWith("/api/public/") || typeof window === "undefined") return;
|
||||
const isDesktop = Boolean(window.edgeeverDesktop?.isAvailable);
|
||||
void notifyUnauthorized(isDesktop, token);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
PRAGMA foreign_keys = ON;
|
||||
|
||||
ALTER TABLE memo_shares ADD COLUMN password_hash TEXT;
|
||||
ALTER TABLE memo_shares ADD COLUMN unlock_failed_count INTEGER NOT NULL DEFAULT 0;
|
||||
ALTER TABLE memo_shares ADD COLUMN unlock_window_started_at TEXT;
|
||||
ALTER TABLE memo_shares ADD COLUMN unlock_blocked_until TEXT;
|
||||
@@ -803,6 +803,12 @@ export const createEdgeEverClient = (options: EdgeEverClientOptions = {}) => {
|
||||
getPublicMemoShare: (token: string) =>
|
||||
request<PublicMemoShareResponse>(`/api/public/shares/${encodeURIComponent(token)}`),
|
||||
|
||||
unlockPublicMemoShare: (token: string, password: string) =>
|
||||
request<{ ok: true }>(`/api/public/shares/${encodeURIComponent(token)}/unlock`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ password }),
|
||||
}),
|
||||
|
||||
listLoginDeviceSessions: () =>
|
||||
request<ListLoginDeviceSessionsResponse>("/api/v1/auth/sessions"),
|
||||
|
||||
@@ -1489,6 +1495,12 @@ export const createEdgeEverClient = (options: EdgeEverClientOptions = {}) => {
|
||||
body: JSON.stringify({}),
|
||||
}),
|
||||
|
||||
updateMemoShare: (memoId: string, payload: { passwordProtected: boolean }) =>
|
||||
request<{ share: MemoShare }>(`/api/v1/memos/${memoId}/share`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(payload),
|
||||
}),
|
||||
|
||||
revokeMemoShare: (memoId: string) =>
|
||||
request<{ ok: true }>(`/api/v1/memos/${memoId}/share`, { method: "DELETE" }),
|
||||
|
||||
|
||||
@@ -1864,6 +1864,7 @@ export const enUS = {
|
||||
manage: "Manage sharing",
|
||||
title: "Share note",
|
||||
description: "Anyone with the link can view the latest saved version of this note without signing in.",
|
||||
descriptionProtected: "Anyone with the link must enter the password to view the latest saved version of this note.",
|
||||
linkLabel: "Public share link",
|
||||
inactiveHint: "Create a private, unguessable link that you can revoke at any time.",
|
||||
liveContentHint: "The link always shows the latest saved content. Revoking it disables the note and its attachments immediately.",
|
||||
@@ -1874,6 +1875,18 @@ export const enUS = {
|
||||
open: "Open shared note",
|
||||
revoke: "Revoke sharing",
|
||||
error: "Sharing could not be updated. Please try again.",
|
||||
passwordToggle: "Access password",
|
||||
passwordLabel: "Access password",
|
||||
passwordCopy: "Copy password",
|
||||
copyLinkAndPassword: "Copy link and password",
|
||||
regeneratePassword: "Regenerate",
|
||||
passwordSet: "Password is set",
|
||||
passwordPrefix: "Password: ",
|
||||
passwordRequiredTitle: "Password required",
|
||||
passwordRequiredHint: "This shared note is password-protected. Enter the password to continue.",
|
||||
passwordSubmit: "View note",
|
||||
passwordInvalid: "Incorrect password. Please try again.",
|
||||
passwordRateLimited: "Too many attempts. Please try again later.",
|
||||
readOnly: "Read-only share",
|
||||
publicLoading: "Loading shared note",
|
||||
publicUpdated: "Updated {{time}}",
|
||||
|
||||
@@ -1864,6 +1864,7 @@ export const ja = {
|
||||
manage: "共有を管理",
|
||||
title: "ノートを共有",
|
||||
description: "リンクを持つ人は、サインインなしでこのノートの最新の保存版を閲覧できます。",
|
||||
descriptionProtected: "リンクを持つ人は、パスワードを入力するとこのノートの最新の保存版を閲覧できます。",
|
||||
linkLabel: "公開共有リンク",
|
||||
inactiveHint: "推測しにくい専用リンクを作成します。いつでも取り消せます。",
|
||||
liveContentHint: "リンクは常に最新の保存内容を表示します。取り消すと、ノートとその添付はすぐに非公開になります。",
|
||||
@@ -1874,6 +1875,18 @@ export const ja = {
|
||||
open: "共有ノートを開く",
|
||||
revoke: "共有を取り消す",
|
||||
error: "共有を更新できませんでした。再試行してください。",
|
||||
passwordToggle: "アクセスパスワード",
|
||||
passwordLabel: "アクセスパスワード",
|
||||
passwordCopy: "パスワードをコピー",
|
||||
copyLinkAndPassword: "リンクとパスワードをコピー",
|
||||
regeneratePassword: "再生成",
|
||||
passwordSet: "パスワード設定済み",
|
||||
passwordPrefix: "パスワード:",
|
||||
passwordRequiredTitle: "パスワードが必要です",
|
||||
passwordRequiredHint: "この共有ノートはパスワード保護されています。パスワードを入力してください。",
|
||||
passwordSubmit: "ノートを表示",
|
||||
passwordInvalid: "パスワードが正しくありません。再試行してください。",
|
||||
passwordRateLimited: "試行回数が多すぎます。しばらくしてから再試行してください。",
|
||||
readOnly: "読み取り専用の共有",
|
||||
publicLoading: "共有ノートを読み込み中",
|
||||
publicUpdated: "更新 {{time}}",
|
||||
|
||||
@@ -1862,6 +1862,7 @@ export const zhCN = {
|
||||
manage: "管理分享",
|
||||
title: "分享笔记",
|
||||
description: "任何获得链接的人无需登录,即可查看这篇笔记最新保存的内容。",
|
||||
descriptionProtected: "获得链接的人需要输入密码后,才能查看这篇笔记最新保存的内容。",
|
||||
linkLabel: "公开分享链接",
|
||||
inactiveHint: "创建一个难以猜测的专属链接;你可以随时撤销。",
|
||||
liveContentHint: "链接会展示最新保存的内容。撤销后,笔记及其中的附件会立即停止公开访问。",
|
||||
@@ -1872,6 +1873,18 @@ export const zhCN = {
|
||||
open: "打开分享页",
|
||||
revoke: "取消分享",
|
||||
error: "分享状态更新失败,请稍后重试。",
|
||||
passwordToggle: "访问密码",
|
||||
passwordLabel: "访问密码",
|
||||
passwordCopy: "复制密码",
|
||||
copyLinkAndPassword: "复制链接和密码",
|
||||
regeneratePassword: "重新生成",
|
||||
passwordSet: "已设置访问密码",
|
||||
passwordPrefix: "密码:",
|
||||
passwordRequiredTitle: "需要访问密码",
|
||||
passwordRequiredHint: "这篇分享笔记设有访问密码,请输入后查看。",
|
||||
passwordSubmit: "查看笔记",
|
||||
passwordInvalid: "密码不正确,请重试。",
|
||||
passwordRateLimited: "尝试次数过多,请稍后再试。",
|
||||
readOnly: "只读分享",
|
||||
publicLoading: "正在加载分享笔记",
|
||||
publicUpdated: "更新于 {{time}}",
|
||||
|
||||
@@ -382,6 +382,14 @@ export const AiPromptTemplateUpdateSchema = z.object({
|
||||
message: "At least one field is required.",
|
||||
});
|
||||
|
||||
export const MemoShareUpdateSchema = z.object({
|
||||
passwordProtected: z.boolean(),
|
||||
});
|
||||
|
||||
export const PublicShareUnlockSchema = z.object({
|
||||
password: z.string().min(1).max(64),
|
||||
});
|
||||
|
||||
export type NotebookCreateInput = z.infer<typeof NotebookCreateSchema>;
|
||||
export type NotebookUpdateInput = z.infer<typeof NotebookUpdateSchema>;
|
||||
export type MemoCreateInput = z.infer<typeof MemoCreateSchema>;
|
||||
@@ -418,3 +426,5 @@ export type AiTagSuggestionsRequestInput = z.infer<typeof AiTagSuggestionsReques
|
||||
export type AiTagSuggestionPromptUpdateInput = z.infer<typeof AiTagSuggestionPromptUpdateSchema>;
|
||||
export type AiPromptTemplateCreateInput = z.input<typeof AiPromptTemplateCreateSchema>;
|
||||
export type AiPromptTemplateUpdateInput = z.infer<typeof AiPromptTemplateUpdateSchema>;
|
||||
export type MemoShareUpdateInput = z.infer<typeof MemoShareUpdateSchema>;
|
||||
export type PublicShareUnlockInput = z.infer<typeof PublicShareUnlockSchema>;
|
||||
@@ -6,6 +6,9 @@ export type MemoShare = {
|
||||
token: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
passwordProtected: boolean;
|
||||
/** Plaintext is returned only immediately after the owner enables or regenerates a password. */
|
||||
password?: string;
|
||||
};
|
||||
|
||||
export type PublicMemoShare = {
|
||||
|
||||
@@ -7,6 +7,8 @@ const readSource = (path: string) => readFileSync(new URL(path, import.meta.url)
|
||||
|
||||
const editorSource = readSource("../apps/web/src/components/EditorPane.tsx");
|
||||
const memoListSource = readSource("../apps/web/src/components/MemoListPane.tsx");
|
||||
const shareDialogSource = readSource("../apps/web/src/components/dialogs/ShareMemoDialog.tsx");
|
||||
const publicShareSource = readSource("../apps/web/src/components/PublicSharePage.tsx");
|
||||
|
||||
describe("note sharing menu", () => {
|
||||
test("explains why sharing a newly created local note is disabled", () => {
|
||||
@@ -15,4 +17,12 @@ describe("note sharing menu", () => {
|
||||
expect(editorSource).toContain('isLocalMemoId(memo.id) ? "sharing.afterSync"');
|
||||
expect(memoListSource).toContain('isLocalMemoId(memoContextMenu.memo.id) ? "sharing.afterSync"');
|
||||
});
|
||||
|
||||
test("share dialog can enable an auto-generated access password", () => {
|
||||
expect(zhCN.sharing.passwordToggle).toBe("访问密码");
|
||||
expect(enUS.sharing.passwordToggle).toBe("Access password");
|
||||
expect(shareDialogSource).toContain('t("sharing.passwordToggle")');
|
||||
expect(shareDialogSource).toContain("api.updateMemoShare(memoId, { passwordProtected })");
|
||||
expect(publicShareSource).toContain("api.unlockPublicMemoShare");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user