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:
tianma-if committed 2026-09-17 14:44:43 +08:00
1 parent f0a5c870b5
commit dcfd11468d
25 files changed
+772 -48

No files matched your search

+40
View File
@@ -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);
});
});
+72
View File
@@ -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;
};
+15
View File
@@ -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);
});
});
+17
View File
@@ -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("");
};
+137 -1
View File
@@ -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
View File
@@ -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;