From 5dc9db543615827b983b43cf50230bac2780e471 Mon Sep 17 00:00:00 2001 From: Saurabh Date: Sat, 18 Jul 2026 20:09:04 +0530 Subject: [PATCH 01/14] feat: rebuild linked-page publishing --- .../migrations/0003_publish_releases.sql | 81 +++ services/cloud-api/src/crypto.ts | 29 +- services/cloud-api/src/index.ts | 100 ++- services/cloud-api/src/publishing.ts | 583 ++++++++++++++++++ services/cloud-api/src/r2-signing.ts | 63 ++ services/cloud-api/src/shares.ts | 255 -------- services/cloud-api/src/types.ts | 82 ++- services/cloud-api/src/validation.ts | 133 ++-- services/cloud-api/test/publishing.test.ts | 141 ++++- services/cloud-api/wrangler.jsonc | 19 +- site/app/s/[slug]/{ => [[...path]]}/page.tsx | 46 +- .../published/PublishedMarkdown.tsx | 101 ++- site/lib/published-note.ts | 30 +- site/middleware.ts | 12 + src-tauri/Cargo.lock | 3 + src-tauri/Cargo.toml | 3 + src-tauri/src/cloud.rs | 142 +---- src-tauri/src/cloud_metadata.rs | 14 +- src-tauri/src/cloud_publish.rs | 464 ++++++++++++++ src-tauri/src/commands.rs | 10 +- src-tauri/src/lib.rs | 1 + src/components/editor/PublishNoteModal.tsx | 108 +++- src/components/settings/SettingsPanels.tsx | 8 +- src/lib/ipc.ts | 25 +- src/lib/publishBundle.ts | 163 +++++ src/lib/types.ts | 10 +- 26 files changed, 2047 insertions(+), 579 deletions(-) create mode 100644 services/cloud-api/migrations/0003_publish_releases.sql create mode 100644 services/cloud-api/src/publishing.ts create mode 100644 services/cloud-api/src/r2-signing.ts delete mode 100644 services/cloud-api/src/shares.ts rename site/app/s/[slug]/{ => [[...path]]}/page.tsx (70%) create mode 100644 site/middleware.ts create mode 100644 src-tauri/src/cloud_publish.rs create mode 100644 src/lib/publishBundle.ts diff --git a/services/cloud-api/migrations/0003_publish_releases.sql b/services/cloud-api/migrations/0003_publish_releases.sql new file mode 100644 index 0000000..5100cd6 --- /dev/null +++ b/services/cloud-api/migrations/0003_publish_releases.sql @@ -0,0 +1,81 @@ +PRAGMA foreign_keys = ON; + +CREATE TABLE sites ( + id TEXT PRIMARY KEY, + entry_id TEXT NOT NULL, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + slug TEXT NOT NULL UNIQUE, + title TEXT NOT NULL, + current_release_id TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL +) STRICT; + +CREATE UNIQUE INDEX sites_owner_entry ON sites(user_id, entry_id); +CREATE INDEX sites_public_slug ON sites(slug); + +CREATE TABLE publish_sessions ( + id TEXT PRIMARY KEY, + site_id TEXT, + entry_id TEXT NOT NULL, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + title TEXT NOT NULL, + manifest_key TEXT NOT NULL, + manifest_hash TEXT NOT NULL, + expires_at INTEGER NOT NULL, + created_at INTEGER NOT NULL +) STRICT; + +CREATE INDEX publish_sessions_expiry ON publish_sessions(expires_at); + +CREATE TABLE publish_session_objects ( + session_id TEXT NOT NULL REFERENCES publish_sessions(id) ON DELETE CASCADE, + user_id TEXT NOT NULL, + content_hash TEXT NOT NULL, + PRIMARY KEY (session_id, content_hash) +) STRICT; + +CREATE INDEX publish_session_objects_lookup + ON publish_session_objects(user_id, content_hash); + +CREATE TABLE releases ( + id TEXT PRIMARY KEY, + site_id TEXT NOT NULL REFERENCES sites(id) ON DELETE CASCADE, + manifest_key TEXT NOT NULL, + manifest_hash TEXT NOT NULL, + page_count INTEGER NOT NULL, + asset_count INTEGER NOT NULL, + published_at INTEGER NOT NULL +) STRICT; + +CREATE TABLE stored_objects ( + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + content_hash TEXT NOT NULL, + object_key TEXT NOT NULL, + kind TEXT NOT NULL CHECK (kind IN ('page', 'asset')), + content_type TEXT NOT NULL, + byte_size INTEGER NOT NULL, + created_at INTEGER NOT NULL, + PRIMARY KEY (user_id, content_hash) +) STRICT; + +CREATE TABLE release_objects ( + release_id TEXT NOT NULL REFERENCES releases(id) ON DELETE CASCADE, + user_id TEXT NOT NULL, + content_hash TEXT NOT NULL, + PRIMARY KEY (release_id, content_hash), + FOREIGN KEY (user_id, content_hash) + REFERENCES stored_objects(user_id, content_hash) ON DELETE CASCADE +) STRICT; + +CREATE INDEX release_objects_lookup ON release_objects(user_id, content_hash); + +CREATE TABLE legacy_publish_objects ( + object_key TEXT PRIMARY KEY +) STRICT; + +INSERT OR IGNORE INTO legacy_publish_objects (object_key) +SELECT object_key FROM shares; + +DROP TRIGGER IF EXISTS shares_free_limit_before_insert; +DROP TABLE IF EXISTS shares; diff --git a/services/cloud-api/src/crypto.ts b/services/cloud-api/src/crypto.ts index 2c50529..7e28ec0 100644 --- a/services/cloud-api/src/crypto.ts +++ b/services/cloud-api/src/crypto.ts @@ -7,6 +7,27 @@ export async function sha256(value: string): Promise { ).join(""); } +export async function sha256Bytes(value: ArrayBuffer | Uint8Array): Promise { + const bytes = value instanceof Uint8Array ? value : new Uint8Array(value); + const digest = await crypto.subtle.digest("SHA-256", Uint8Array.from(bytes).buffer); + return hex(new Uint8Array(digest)); +} + +export async function hmacBytes( + secret: string | Uint8Array, + value: string, +): Promise { + const raw = typeof secret === "string" ? encoder.encode(secret) : secret; + const key = await crypto.subtle.importKey( + "raw", + Uint8Array.from(raw).buffer, + { name: "HMAC", hash: "SHA-256" }, + false, + ["sign"], + ); + return new Uint8Array(await crypto.subtle.sign("HMAC", key, encoder.encode(value))); +} + export async function hmacSha256(secret: string, value: string): Promise { const key = await crypto.subtle.importKey( "raw", @@ -50,6 +71,12 @@ export function randomSlug(): string { .replace(/=+$/, ""); } -export function newId(prefix: "share" | "otp" | "session" | "user"): string { +export function newId( + prefix: "site" | "release" | "publish" | "otp" | "session" | "user", +): string { return `${prefix}_${crypto.randomUUID().replaceAll("-", "")}`; } + +function hex(bytes: Uint8Array): string { + return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join(""); +} diff --git a/services/cloud-api/src/index.ts b/services/cloud-api/src/index.ts index 30ff9f6..3f9191b 100644 --- a/services/cloud-api/src/index.ts +++ b/services/cloud-api/src/index.ts @@ -1,69 +1,61 @@ -import { error, json, RequestBodyError } from "./http"; -import { - createShare, - getOwnedShare, - getPublicShare, - revokeShare, - updateShare, -} from "./shares"; -import type { Env } from "./types"; -import { ValidationError } from "./validation"; import { authenticatedUser, AuthenticationError, authenticationRequired, revokeSession, } from "./auth"; +import { error, json, RequestBodyError } from "./http"; import { OtpError, requestOtp, verifyOtp } from "./otp"; +import { + beginPublish, + cleanupExpiredPublishSessions, + deleteSite, + finalizePublish, + getOwnedSite, + getPublicAsset, + getPublicPage, + PaidPublishingError, +} from "./publishing"; +import type { Env } from "./types"; +import { ValidationError } from "./validation"; -const SHARE_ID = /^\/v1\/shares\/(share_[a-f0-9]{32})$/; -const PUBLIC_SLUG = /^\/v1\/public\/shares\/([a-zA-Z0-9_-]{20,24})$/; +const SITE_ID = /^\/v1\/sites\/(site_[a-f0-9]{32})$/; +const PUBLISH_SESSION = /^\/v1\/publish-sessions\/(publish_[a-f0-9]{32})\/finalize$/; +const PUBLIC_PAGE = /^\/v1\/public\/sites\/([a-zA-Z0-9_-]{20,24})(?:\/pages\/(.+))?$/; +const PUBLIC_ASSET = /^\/v1\/public\/assets\/([a-zA-Z0-9_-]{20,24})\/([a-f0-9]{64})$/; -async function route( - request: Request, - env: Env, - ctx: ExecutionContext, -): Promise { +async function route(request: Request, env: Env, ctx: ExecutionContext): Promise { const url = new URL(request.url); - - if (request.method === "GET" && url.pathname === "/health") { - return json({ ok: true }); - } - if (request.method === "POST" && url.pathname === "/v1/auth/otp/request") { - return requestOtp(request, env); - } - if (request.method === "POST" && url.pathname === "/v1/auth/otp/verify") { - return verifyOtp(request, env); - } - if (request.method === "DELETE" && url.pathname === "/v1/session") { - return revokeSession(request, env); - } + if (request.method === "GET" && url.pathname === "/health") return json({ ok: true }); + if (request.method === "POST" && url.pathname === "/v1/auth/otp/request") return requestOtp(request, env); + if (request.method === "POST" && url.pathname === "/v1/auth/otp/verify") return verifyOtp(request, env); + if (request.method === "DELETE" && url.pathname === "/v1/session") return revokeSession(request, env); if (request.method === "GET" && url.pathname === "/v1/me") { const user = await authenticatedUser(request, env); return json({ user: { id: user.id, email: user.email, plan: user.plan } }); } - if (request.method === "POST" && url.pathname === "/v1/shares") { - return createShare(request, env); + if (request.method === "POST" && url.pathname === "/v1/publish-sessions") { + return beginPublish(request, env); } - const publicMatch = PUBLIC_SLUG.exec(url.pathname); - if (request.method === "GET" && publicMatch) { - return getPublicShare(env, publicMatch[1]); + const finalize = PUBLISH_SESSION.exec(url.pathname); + if (request.method === "POST" && finalize) return finalizePublish(request, env, ctx, finalize[1]); + + const publicAsset = PUBLIC_ASSET.exec(url.pathname); + if (request.method === "GET" && publicAsset) { + return getPublicAsset(request, env, publicAsset[1], publicAsset[2]); } - const shareMatch = SHARE_ID.exec(url.pathname); - if (shareMatch) { - if (request.method === "GET") { - return getOwnedShare(request, env, shareMatch[1]); - } - if (request.method === "PUT") { - return updateShare(request, env, ctx, shareMatch[1]); - } - if (request.method === "DELETE") { - return revokeShare(request, env, shareMatch[1]); - } + const publicPage = PUBLIC_PAGE.exec(url.pathname); + if (request.method === "GET" && publicPage) { + return getPublicPage(env, publicPage[1], publicPage[2] ? decodeURIComponent(publicPage[2]) : ""); } + const site = SITE_ID.exec(url.pathname); + if (site) { + if (request.method === "GET") return getOwnedSite(request, env, site[1]); + if (request.method === "DELETE") return deleteSite(request, env, ctx, site[1]); + } return error(404, "route_not_found", "The requested endpoint does not exist."); } @@ -73,15 +65,19 @@ export default { return await route(request, env, ctx); } catch (cause) { if (cause instanceof AuthenticationError) return authenticationRequired(); - if (cause instanceof OtpError) return error(cause.status, cause.code, cause.message); - if (cause instanceof ValidationError) { - return error(400, cause.code, cause.message); - } - if (cause instanceof RequestBodyError) { - return error(cause.status, cause.code, cause.message); + if (cause instanceof PaidPublishingError) { + return error(402, "cloud_subscription_required", cause.message, { + upgradeUrl: cause.upgradeUrl, + }); } + if (cause instanceof OtpError) return error(cause.status, cause.code, cause.message); + if (cause instanceof ValidationError) return error(400, cause.code, cause.message); + if (cause instanceof RequestBodyError) return error(cause.status, cause.code, cause.message); console.error("Unhandled publishing API error", cause); return error(500, "internal_error", "The publishing service could not complete the request."); } }, + async scheduled(_controller: ScheduledController, env: Env, ctx: ExecutionContext) { + ctx.waitUntil(cleanupExpiredPublishSessions(env)); + }, } satisfies ExportedHandler; diff --git a/services/cloud-api/src/publishing.ts b/services/cloud-api/src/publishing.ts new file mode 100644 index 0000000..f94c3c6 --- /dev/null +++ b/services/cloud-api/src/publishing.ts @@ -0,0 +1,583 @@ +import { authenticatedUser } from "./auth"; +import { newId, randomSlug, sha256 } from "./crypto"; +import { error, json, notFound, readJson } from "./http"; +import { presignedPutUrl } from "./r2-signing"; +import type { + BeginPublishInput, + Env, + PublishManifest, + PublishObjectInput, + PublishSessionRow, + ReleaseRow, + SiteResponse, + SiteRow, +} from "./types"; +import { beginPublishInput, MAX_REQUEST_BYTES } from "./validation"; + +const SITE_COLUMNS = ` + id, entry_id, user_id, slug, title, current_release_id, created_at, updated_at +`; +const SESSION_TTL_MS = 20 * 60 * 1000; + +export async function beginPublish(request: Request, env: Env): Promise { + const user = await authenticatedUser(request, env); + requirePaid(user.plan, env); + const input = beginPublishInput(await readJson(request, MAX_REQUEST_BYTES)); + const current = await ownedSiteForInput(env, user.id, input); + if (input.siteId && (!current || current.entry_id !== input.entryId)) return notFound(); + const sessionId = newId("publish"); + const now = Date.now(); + const manifestJson = JSON.stringify(input.manifest); + const manifestHash = await sha256(manifestJson); + const manifestKey = `staging/${user.id}/${sessionId}/manifest.json`; + + await env.PUBLISHED_NOTES.put(manifestKey, manifestJson, { + httpMetadata: { contentType: "application/json; charset=utf-8" }, + customMetadata: { userId: user.id, manifestHash }, + }); + + try { + await env.DB.prepare( + `INSERT INTO publish_sessions ( + id, site_id, entry_id, user_id, title, manifest_key, + manifest_hash, expires_at, created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ) + .bind( + sessionId, + current?.id ?? null, + input.entryId, + user.id, + input.title, + manifestKey, + manifestHash, + now + SESSION_TTL_MS, + now, + ) + .run(); + for (let index = 0; index < input.manifest.objects.length; index += 80) { + await env.DB.batch( + input.manifest.objects.slice(index, index + 80).map((object) => + env.DB.prepare( + `INSERT INTO publish_session_objects (session_id, user_id, content_hash) + VALUES (?, ?, ?)`, + ).bind(sessionId, user.id, object.hash), + ), + ); + } + } catch (cause) { + await env.PUBLISHED_NOTES.delete(manifestKey); + await env.DB.prepare("DELETE FROM publish_sessions WHERE id = ?").bind(sessionId).run(); + throw cause; + } + + const pending: Array<{ + hash: string; + url: string; + headers: Record; + } | null> = []; + for (let index = 0; index < input.manifest.objects.length; index += 50) { + pending.push(...await Promise.all( + input.manifest.objects.slice(index, index + 50).map(async (object) => { + const key = objectKey(user.id, object.hash); + const existing = await env.PUBLISHED_NOTES.head(key); + if (objectMatches(existing, object)) return null; + const checksum = hashBase64(object.hash); + return { + hash: object.hash, + url: await presignedPutUrl(env, key, object.contentType, checksum), + headers: { + "content-type": object.contentType, + "x-amz-checksum-sha256": checksum, + }, + }; + }), + )); + } + const uploads = pending.filter((upload) => upload !== null); + + return json( + { + sessionId, + siteId: current?.id ?? null, + expiresAt: now + SESSION_TTL_MS, + uploads, + }, + 201, + ); +} + +export async function finalizePublish( + request: Request, + env: Env, + ctx: ExecutionContext, + sessionId: string, +): Promise { + const user = await authenticatedUser(request, env); + requirePaid(user.plan, env); + const session = await env.DB.prepare( + `SELECT id, site_id, entry_id, user_id, title, manifest_key, + manifest_hash, expires_at, created_at + FROM publish_sessions WHERE id = ? AND user_id = ?`, + ) + .bind(sessionId, user.id) + .first(); + if (!session || session.expires_at <= Date.now()) return notFound(); + + const manifestObject = await env.PUBLISHED_NOTES.get(session.manifest_key); + if (!manifestObject) return error(409, "publish_session_incomplete", "The release manifest is missing."); + const manifestJson = await manifestObject.text(); + if ((await sha256(manifestJson)) !== session.manifest_hash) { + return error(409, "publish_session_invalid", "The release manifest could not be verified."); + } + const manifest = JSON.parse(manifestJson) as PublishManifest; + const missing = await missingObjects(env, user.id, manifest.objects); + if (missing.length) { + return error(409, "publish_upload_incomplete", "Some release objects have not finished uploading.", { + missing, + }); + } + + const now = Date.now(); + const site = session.site_id + ? await findOwnedSite(env, session.site_id, user.id) + : null; + if (session.site_id && !site) return notFound(); + const siteId = site?.id ?? newId("site"); + const releaseId = newId("release"); + const finalManifestKey = `manifests/${siteId}/${releaseId}.json`; + const releaseManifest = await enrichImageMetadata(env, user.id, manifest); + await env.PUBLISHED_NOTES.put(finalManifestKey, JSON.stringify(releaseManifest), { + httpMetadata: { contentType: "application/json; charset=utf-8" }, + customMetadata: { siteId, releaseId, manifestHash: session.manifest_hash }, + }); + + const pageCount = manifest.pages.length; + const assetCount = manifest.objects.filter((object) => object.kind === "asset").length; + const slug = site?.slug ?? randomSlug(); + try { + await env.DB.batch([ + ...(site + ? [] + : [ + env.DB.prepare( + `INSERT INTO sites ( + id, entry_id, user_id, slug, title, current_release_id, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, NULL, ?, ?)`, + ).bind(siteId, session.entry_id, user.id, slug, session.title, now, now), + ]), + env.DB.prepare( + `INSERT INTO releases ( + id, site_id, manifest_key, manifest_hash, page_count, asset_count, published_at + ) VALUES (?, ?, ?, ?, ?, ?, ?)`, + ).bind(releaseId, siteId, finalManifestKey, session.manifest_hash, pageCount, assetCount, now), + ]); + + for (let index = 0; index < manifest.objects.length; index += 40) { + const statements = manifest.objects.slice(index, index + 40).flatMap((object) => [ + env.DB.prepare( + `INSERT OR IGNORE INTO stored_objects ( + user_id, content_hash, object_key, kind, content_type, byte_size, created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?)`, + ).bind( + user.id, + object.hash, + objectKey(user.id, object.hash), + object.kind, + object.contentType, + object.size, + now, + ), + env.DB.prepare( + `INSERT INTO release_objects (release_id, user_id, content_hash) + VALUES (?, ?, ?)`, + ).bind(releaseId, user.id, object.hash), + ]); + await env.DB.batch(statements); + } + + await env.DB.batch([ + env.DB.prepare( + `UPDATE sites SET title = ?, current_release_id = ?, updated_at = ? + WHERE id = ? AND user_id = ?`, + ).bind(session.title, releaseId, now, siteId, user.id), + env.DB.prepare("DELETE FROM publish_sessions WHERE id = ?").bind(session.id), + ]); + } catch (cause) { + await env.PUBLISHED_NOTES.delete(finalManifestKey); + await env.DB.prepare("DELETE FROM releases WHERE id = ?").bind(releaseId).run(); + if (!site) { + await env.DB.prepare("DELETE FROM sites WHERE id = ? AND current_release_id IS NULL") + .bind(siteId) + .run(); + } + throw cause; + } + + ctx.waitUntil(env.PUBLISHED_NOTES.delete(session.manifest_key)); + ctx.waitUntil(pruneReleases(env, siteId, user.id)); + ctx.waitUntil(purgeSiteCache(env, siteId, slug)); + const release: ReleaseRow = { + id: releaseId, + site_id: siteId, + manifest_key: finalManifestKey, + manifest_hash: session.manifest_hash, + page_count: pageCount, + asset_count: assetCount, + published_at: now, + }; + const row: SiteRow = { + id: siteId, + entry_id: session.entry_id, + user_id: user.id, + slug, + title: session.title, + current_release_id: releaseId, + created_at: site?.created_at ?? now, + updated_at: now, + }; + return json({ site: siteResponse(env, row, release) }, 201); +} + +export async function getOwnedSite(request: Request, env: Env, siteId: string): Promise { + const user = await authenticatedUser(request, env); + const site = await findOwnedSite(env, siteId, user.id); + if (!site?.current_release_id) return notFound(); + const release = await findRelease(env, site.current_release_id); + return release ? json({ site: siteResponse(env, site, release) }) : notFound(); +} + +export async function deleteSite( + request: Request, + env: Env, + ctx: ExecutionContext, + siteId: string, +): Promise { + const user = await authenticatedUser(request, env); + const site = await findOwnedSite(env, siteId, user.id); + if (!site) return notFound(); + const releases = await env.DB.prepare( + "SELECT id, manifest_key FROM releases WHERE site_id = ?", + ) + .bind(siteId) + .all<{ id: string; manifest_key: string }>(); + const hashes = await env.DB.prepare( + `SELECT DISTINCT release_objects.content_hash + FROM release_objects JOIN releases ON releases.id = release_objects.release_id + WHERE releases.site_id = ?`, + ) + .bind(siteId) + .all<{ content_hash: string }>(); + await env.DB.prepare("DELETE FROM sites WHERE id = ? AND user_id = ?") + .bind(siteId, user.id) + .run(); + ctx.waitUntil(Promise.all([ + Promise.allSettled( + releases.results.map((release) => env.PUBLISHED_NOTES.delete(release.manifest_key)), + ).then(() => cleanupOrphans(env, user.id, hashes.results.map((row) => row.content_hash))), + purgeSiteCache(env, site.id, site.slug), + ]).then(() => undefined)); + return new Response(null, { status: 204 }); +} + +export async function getPublicPage(env: Env, slug: string, pagePath = ""): Promise { + const resolved = await activeManifest(env, slug); + if (!resolved) return notFound(); + const page = resolved.manifest.pages.find((candidate) => candidate.path === pagePath); + if (!page) return notFound(); + const object = await env.PUBLISHED_NOTES.get(objectKey(resolved.site.user_id, page.objectHash)); + if (!object) return notFound(); + return json( + { + title: page.title, + markdown: await object.text(), + publishedAt: resolved.release.published_at, + updatedAt: resolved.site.updated_at, + assetBaseUrl: `${env.PUBLIC_API_ORIGIN.replace(/\/$/, "")}/v1/public/assets/${slug}`, + assetTypes: Object.fromEntries( + resolved.manifest.objects + .filter((object) => object.kind === "asset") + .map((object) => [object.hash, object.contentType]), + ), + assetDimensions: Object.fromEntries( + resolved.manifest.objects + .filter((object) => object.kind === "asset" && object.width && object.height) + .map((object) => [object.hash, { width: object.width, height: object.height }]), + ), + }, + 200, + { + "cache-control": "public, max-age=60, stale-while-revalidate=300", + "cache-tag": `markd-site-${resolved.site.id}`, + }, + ); +} + +export async function getPublicAsset( + request: Request, + env: Env, + slug: string, + hash: string, +): Promise { + const resolved = await activeManifest(env, slug); + if (!resolved) return notFound(); + const asset = resolved.manifest.objects.find( + (object) => object.hash === hash && object.kind === "asset", + ); + if (!asset) return notFound(); + const object = await env.PUBLISHED_NOTES.get(objectKey(resolved.site.user_id, hash)); + if (!object) return notFound(); + const width = requestedImageWidth(request); + if (width && asset.contentType !== "image/gif") { + const format = requestedImageFormat(request); + const transformed = await env.IMAGES + .input(object.body) + .transform({ width, fit: "scale-down" }) + .output({ format, quality: 82 }); + return assetResponse( + transformed.response().body, + transformed.contentType(), + resolved.site.id, + `"${hash}-${width}-${format.slice(6)}"`, + ); + } + return assetResponse(object.body, asset.contentType, resolved.site.id, object.httpEtag, asset.size); +} + +function assetResponse( + body: ReadableStream | null, + contentType: string, + siteId: string, + etag: string, + size?: number, +) { + return new Response(body, { + headers: { + "content-type": contentType, + ...(size ? { "content-length": String(size) } : {}), + "cache-control": "public, max-age=31536000, immutable", + etag, + "cache-tag": `markd-site-${siteId}`, + "x-content-type-options": "nosniff", + }, + }); +} + +function requestedImageWidth(request: Request) { + const width = Number(new URL(request.url).searchParams.get("w")); + return [320, 640, 960, 1280, 1600].includes(width) ? width : null; +} + +function requestedImageFormat(request: Request): "image/avif" | "image/webp" { + return new URL(request.url).searchParams.get("f") === "avif" ? "image/avif" : "image/webp"; +} + +async function ownedSiteForInput(env: Env, userId: string, input: BeginPublishInput) { + if (input.siteId) { + return findOwnedSite(env, input.siteId, userId); + } + return env.DB.prepare(`SELECT ${SITE_COLUMNS} FROM sites WHERE user_id = ? AND entry_id = ?`) + .bind(userId, input.entryId) + .first(); +} + +async function findOwnedSite(env: Env, siteId: string, userId: string) { + return env.DB.prepare(`SELECT ${SITE_COLUMNS} FROM sites WHERE id = ? AND user_id = ?`) + .bind(siteId, userId) + .first(); +} + +async function findRelease(env: Env, releaseId: string) { + return env.DB.prepare( + `SELECT id, site_id, manifest_key, manifest_hash, page_count, asset_count, published_at + FROM releases WHERE id = ?`, + ) + .bind(releaseId) + .first(); +} + +async function activeManifest(env: Env, slug: string) { + const site = await env.DB.prepare(`SELECT ${SITE_COLUMNS} FROM sites WHERE slug = ?`) + .bind(slug) + .first(); + if (!site?.current_release_id) return null; + const release = await findRelease(env, site.current_release_id); + if (!release) return null; + const object = await env.PUBLISHED_NOTES.get(release.manifest_key); + if (!object) return null; + return { site, release, manifest: JSON.parse(await object.text()) as PublishManifest }; +} + +async function missingObjects(env: Env, userId: string, objects: PublishObjectInput[]) { + const results: Array = []; + for (let index = 0; index < objects.length; index += 50) { + results.push(...await Promise.all( + objects.slice(index, index + 50).map(async (object) => { + const stored = await env.PUBLISHED_NOTES.head(objectKey(userId, object.hash)); + return objectMatches(stored, object) ? null : object.hash; + }), + )); + } + return results.filter((hash): hash is string => Boolean(hash)); +} + +async function enrichImageMetadata( + env: Env, + userId: string, + manifest: PublishManifest, +): Promise { + const objects: PublishObjectInput[] = []; + for (let index = 0; index < manifest.objects.length; index += 20) { + objects.push(...await Promise.all( + manifest.objects.slice(index, index + 20).map(async (object) => { + if (object.kind !== "asset") return object; + const stored = await env.PUBLISHED_NOTES.get(objectKey(userId, object.hash)); + if (!stored) throw new Error(`release image ${object.hash} is missing`); + const info = await env.IMAGES.info(stored.body); + if (!("width" in info) || !("height" in info)) return object; + return { ...object, width: info.width, height: info.height }; + }), + )); + } + return { ...manifest, objects }; +} + +function objectMatches(object: R2Object | null, expected: PublishObjectInput) { + if (!object || object.size !== expected.size) return false; + const digest = object.checksums.sha256; + return digest ? bytesHex(new Uint8Array(digest)) === expected.hash : false; +} + +async function retireRelease(env: Env, releaseId: string, userId: string) { + const release = await findRelease(env, releaseId); + if (!release) return; + const hashes = await env.DB.prepare( + "SELECT content_hash FROM release_objects WHERE release_id = ?", + ) + .bind(releaseId) + .all<{ content_hash: string }>(); + await env.DB.prepare("DELETE FROM releases WHERE id = ?").bind(releaseId).run(); + await env.PUBLISHED_NOTES.delete(release.manifest_key); + await cleanupOrphans(env, userId, hashes.results.map((row) => row.content_hash)); +} + +async function pruneReleases(env: Env, siteId: string, userId: string) { + const releases = await env.DB.prepare( + `SELECT id FROM releases WHERE site_id = ? ORDER BY published_at DESC LIMIT -1 OFFSET 3`, + ) + .bind(siteId) + .all<{ id: string }>(); + for (const release of releases.results) { + await retireRelease(env, release.id, userId); + } +} + +async function cleanupOrphans(env: Env, userId: string, hashes: string[]) { + for (const hash of hashes) { + const referenced = await env.DB.prepare( + "SELECT 1 AS found FROM release_objects WHERE user_id = ? AND content_hash = ? LIMIT 1", + ) + .bind(userId, hash) + .first<{ found: number }>(); + if (referenced) continue; + await env.PUBLISHED_NOTES.delete(objectKey(userId, hash)); + await env.DB.prepare("DELETE FROM stored_objects WHERE user_id = ? AND content_hash = ?") + .bind(userId, hash) + .run(); + } +} + +async function purgeSiteCache(env: Env, siteId: string, slug: string) { + const response = await fetch( + `https://api.cloudflare.com/client/v4/zones/${env.CACHE_ZONE_ID}/purge_cache`, + { + method: "POST", + headers: { + authorization: `Bearer ${env.CACHE_PURGE_TOKEN}`, + "content-type": "application/json", + }, + body: JSON.stringify({ tags: [`markd-site-${siteId}`, `markd-slug-${slug}`] }), + }, + ); + if (!response.ok) console.error("Published site cache purge failed", response.status); +} + +function siteResponse(env: Env, site: SiteRow, release: ReleaseRow): SiteResponse { + return { + id: site.id, + entryId: site.entry_id, + slug: site.slug, + url: `${env.PUBLIC_SITE_ORIGIN.replace(/\/$/, "")}/s/${site.slug}`, + title: site.title, + contentHash: release.manifest_hash, + publishedAt: release.published_at, + updatedAt: site.updated_at, + pageCount: release.page_count, + assetCount: release.asset_count, + }; +} + +function requirePaid(plan: string, env: Env): void { + if (plan === "cloud") return; + throw new PaidPublishingError(`${env.PUBLIC_SITE_ORIGIN.replace(/\/$/, "")}/login?intent=upgrade`); +} + +export class PaidPublishingError extends Error { + constructor(readonly upgradeUrl: string) { + super("Publishing is included with Markd Cloud. Upgrade to publish this site."); + } +} + +export async function cleanupExpiredPublishSessions(env: Env) { + const expired = await env.DB.prepare( + `SELECT id, user_id, manifest_key FROM publish_sessions + WHERE expires_at <= ? ORDER BY expires_at LIMIT 100`, + ) + .bind(Date.now()) + .all<{ id: string; user_id: string; manifest_key: string }>(); + for (const session of expired.results) { + const hashes = await env.DB.prepare( + "SELECT content_hash FROM publish_session_objects WHERE session_id = ?", + ) + .bind(session.id) + .all<{ content_hash: string }>(); + await env.DB.prepare("DELETE FROM publish_sessions WHERE id = ?").bind(session.id).run(); + await env.PUBLISHED_NOTES.delete(session.manifest_key); + for (const { content_hash: hash } of hashes.results) { + const stored = await env.DB.prepare( + `SELECT 1 AS found FROM stored_objects WHERE user_id = ? AND content_hash = ? + UNION ALL + SELECT 1 AS found FROM publish_session_objects WHERE user_id = ? AND content_hash = ? + LIMIT 1`, + ) + .bind(session.user_id, hash, session.user_id, hash) + .first<{ found: number }>(); + if (!stored) await env.PUBLISHED_NOTES.delete(objectKey(session.user_id, hash)); + } + } + const legacy = await env.DB.prepare( + "SELECT object_key FROM legacy_publish_objects LIMIT 100", + ).all<{ object_key: string }>(); + for (const object of legacy.results) { + await env.PUBLISHED_NOTES.delete(object.object_key); + await env.DB.prepare("DELETE FROM legacy_publish_objects WHERE object_key = ?") + .bind(object.object_key) + .run(); + } +} + +function objectKey(userId: string, hash: string) { + return `objects/${userId}/${hash}`; +} + +function hashBase64(hash: string) { + let binary = ""; + for (let index = 0; index < hash.length; index += 2) { + binary += String.fromCharCode(Number.parseInt(hash.slice(index, index + 2), 16)); + } + return btoa(binary); +} + +function bytesHex(bytes: Uint8Array) { + return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join(""); +} diff --git a/services/cloud-api/src/r2-signing.ts b/services/cloud-api/src/r2-signing.ts new file mode 100644 index 0000000..f62a9b4 --- /dev/null +++ b/services/cloud-api/src/r2-signing.ts @@ -0,0 +1,63 @@ +import { hmacBytes, sha256 } from "./crypto"; +import type { Env } from "./types"; + +const REGION = "auto"; +const SERVICE = "s3"; + +export async function presignedPutUrl( + env: Env, + objectKey: string, + contentType: string, + checksum: string, + expiresSeconds = 900, +): Promise { + const now = new Date(); + const amzDate = now.toISOString().replace(/[:-]|\.\d{3}/g, ""); + const date = amzDate.slice(0, 8); + const host = `${env.R2_ACCOUNT_ID}.r2.cloudflarestorage.com`; + const scope = `${date}/${REGION}/${SERVICE}/aws4_request`; + const path = `/${encodeSegment(env.R2_BUCKET_NAME)}/${objectKey.split("/").map(encodeSegment).join("/")}`; + const signedHeaders = "content-type;host;x-amz-checksum-sha256"; + const query = new URLSearchParams({ + "X-Amz-Algorithm": "AWS4-HMAC-SHA256", + "X-Amz-Credential": `${env.R2_ACCESS_KEY_ID}/${scope}`, + "X-Amz-Date": amzDate, + "X-Amz-Expires": String(expiresSeconds), + "X-Amz-SignedHeaders": signedHeaders, + }); + const canonicalQuery = [...query.entries()] + .sort(([a], [b]) => a.localeCompare(b)) + .map(([key, value]) => `${encodeSegment(key)}=${encodeSegment(value)}`) + .join("&"); + const canonicalHeaders = `content-type:${contentType.trim()}\nhost:${host}\nx-amz-checksum-sha256:${checksum}\n`; + const canonicalRequest = [ + "PUT", + path, + canonicalQuery, + canonicalHeaders, + signedHeaders, + "UNSIGNED-PAYLOAD", + ].join("\n"); + const stringToSign = [ + "AWS4-HMAC-SHA256", + amzDate, + scope, + await sha256(canonicalRequest), + ].join("\n"); + const dateKey = await hmacBytes(`AWS4${env.R2_SECRET_ACCESS_KEY}`, date); + const regionKey = await hmacBytes(dateKey, REGION); + const serviceKey = await hmacBytes(regionKey, SERVICE); + const signingKey = await hmacBytes(serviceKey, "aws4_request"); + const signature = toHex(await hmacBytes(signingKey, stringToSign)); + return `https://${host}${path}?${canonicalQuery}&X-Amz-Signature=${signature}`; +} + +function encodeSegment(value: string): string { + return encodeURIComponent(value).replace(/[!'()*]/g, (char) => + `%${char.charCodeAt(0).toString(16).toUpperCase()}`, + ); +} + +function toHex(value: Uint8Array): string { + return Array.from(value, (byte) => byte.toString(16).padStart(2, "0")).join(""); +} diff --git a/services/cloud-api/src/shares.ts b/services/cloud-api/src/shares.ts deleted file mode 100644 index 2e83cd8..0000000 --- a/services/cloud-api/src/shares.ts +++ /dev/null @@ -1,255 +0,0 @@ -import { newId, randomSlug, sha256 } from "./crypto"; -import { authenticatedUser } from "./auth"; -import { error, json, notFound, readJson } from "./http"; -import type { Env, ShareResponse, ShareRow } from "./types"; -import { idempotencyKey, MAX_REQUEST_BYTES, publishInput } from "./validation"; - -const SHARE_COLUMNS = ` - id, entry_id, user_id, slug, title, object_key, - content_hash, status, idempotency_key, published_at, updated_at, revoked_at -`; - -function response(row: ShareRow, siteOrigin: string): ShareResponse { - return { - id: row.id, - entryId: row.entry_id, - slug: row.slug, - url: `${siteOrigin.replace(/\/$/, "")}/s/${row.slug}`, - title: row.title, - contentHash: row.content_hash, - publishedAt: row.published_at, - updatedAt: row.updated_at, - }; -} - -async function findOwnedShare( - env: Env, - shareId: string, - userId: string, -): Promise { - return env.DB.prepare( - `SELECT ${SHARE_COLUMNS} FROM shares - WHERE id = ? AND user_id = ? AND status = 'active'`, - ) - .bind(shareId, userId) - .first(); -} - -export async function createShare(request: Request, env: Env): Promise { - const user = await authenticatedUser(request, env); - const requestKey = idempotencyKey(request); - const input = publishInput(await readJson(request, MAX_REQUEST_BYTES)); - - const replay = await env.DB.prepare( - `SELECT ${SHARE_COLUMNS} FROM shares - WHERE user_id = ? AND idempotency_key = ?`, - ) - .bind(user.id, requestKey) - .first(); - if (replay) return json({ share: response(replay, env.PUBLIC_SITE_ORIGIN) }); - - const currentEntry = await env.DB.prepare( - `SELECT ${SHARE_COLUMNS} FROM shares - WHERE user_id = ? AND entry_id = ? AND status = 'active' LIMIT 1`, - ) - .bind(user.id, input.entryId) - .first(); - if (currentEntry) { - return error(409, "already_published", "This note is already published.", { - share: response(currentEntry, env.PUBLIC_SITE_ORIGIN), - }); - } - - if (user.plan === "free") { - const active = await env.DB.prepare( - "SELECT COUNT(*) AS count FROM shares WHERE user_id = ? AND status = 'active'", - ) - .bind(user.id) - .first<{ count: number }>(); - if ((active?.count ?? 0) >= 1) { - return error( - 402, - "cloud_subscription_required", - "The free plan includes one published note. Upgrade to Markd Cloud to publish more.", - { upgradeUrl: `${env.PUBLIC_SITE_ORIGIN.replace(/\/$/, "")}/login?intent=upgrade` }, - ); - } - } - - const id = newId("share"); - const slug = randomSlug(); - const contentHash = await sha256(input.markdown); - const objectKey = `shares/${id}/${contentHash}.md`; - const now = Date.now(); - - await env.PUBLISHED_NOTES.put(objectKey, input.markdown, { - httpMetadata: { contentType: "text/markdown; charset=utf-8" }, - customMetadata: { shareId: id, contentHash }, - }); - - try { - await env.DB.prepare( - `INSERT INTO shares ( - id, entry_id, user_id, slug, title, object_key, - content_hash, status, idempotency_key, published_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, 'active', ?, ?, ?)`, - ) - .bind( - id, - input.entryId, - user.id, - slug, - input.title, - objectKey, - contentHash, - requestKey, - now, - now, - ) - .run(); - } catch (cause) { - await env.PUBLISHED_NOTES.delete(objectKey); - const message = cause instanceof Error ? cause.message : String(cause); - if (message.includes("shares.user_id, shares.entry_id")) { - return error(409, "already_published", "This note is already published."); - } - if (message.includes("free_share_limit")) { - return error( - 402, - "cloud_subscription_required", - "The free plan includes one published note. Upgrade to Markd Cloud to publish more.", - { upgradeUrl: `${env.PUBLIC_SITE_ORIGIN.replace(/\/$/, "")}/login?intent=upgrade` }, - ); - } - throw cause; - } - - const row: ShareRow = { - id, - entry_id: input.entryId, - user_id: user.id, - slug, - title: input.title, - object_key: objectKey, - content_hash: contentHash, - status: "active", - idempotency_key: requestKey, - published_at: now, - updated_at: now, - revoked_at: null, - }; - return json({ share: response(row, env.PUBLIC_SITE_ORIGIN) }, 201); -} - -export async function getOwnedShare( - request: Request, - env: Env, - shareId: string, -): Promise { - const user = await authenticatedUser(request, env); - const row = await findOwnedShare(env, shareId, user.id); - return row - ? json({ share: response(row, env.PUBLIC_SITE_ORIGIN) }) - : notFound(); -} - -export async function updateShare( - request: Request, - env: Env, - ctx: ExecutionContext, - shareId: string, -): Promise { - const user = await authenticatedUser(request, env); - idempotencyKey(request); - const input = publishInput(await readJson(request, MAX_REQUEST_BYTES)); - const current = await findOwnedShare(env, shareId, user.id); - if (!current || current.entry_id !== input.entryId) return notFound(); - - const contentHash = await sha256(input.markdown); - if (contentHash === current.content_hash && input.title === current.title) { - return json({ share: response(current, env.PUBLIC_SITE_ORIGIN) }); - } - - const objectKey = `shares/${shareId}/${contentHash}.md`; - const now = Date.now(); - await env.PUBLISHED_NOTES.put(objectKey, input.markdown, { - httpMetadata: { contentType: "text/markdown; charset=utf-8" }, - customMetadata: { shareId, contentHash }, - }); - - try { - const result = await env.DB.prepare( - `UPDATE shares - SET title = ?, object_key = ?, content_hash = ?, updated_at = ? - WHERE id = ? AND user_id = ? AND status = 'active'`, - ) - .bind(input.title, objectKey, contentHash, now, shareId, user.id) - .run(); - if (result.meta.changes !== 1) { - await env.PUBLISHED_NOTES.delete(objectKey); - return notFound(); - } - } catch (cause) { - await env.PUBLISHED_NOTES.delete(objectKey); - throw cause; - } - - if (current.object_key !== objectKey) { - ctx.waitUntil(env.PUBLISHED_NOTES.delete(current.object_key)); - } - return json({ - share: response( - { - ...current, - title: input.title, - object_key: objectKey, - content_hash: contentHash, - updated_at: now, - }, - env.PUBLIC_SITE_ORIGIN, - ), - }); -} - -export async function revokeShare( - request: Request, - env: Env, - shareId: string, -): Promise { - const user = await authenticatedUser(request, env); - const current = await findOwnedShare(env, shareId, user.id); - if (!current) return notFound(); - - await env.PUBLISHED_NOTES.delete(current.object_key); - - const result = await env.DB.prepare( - "DELETE FROM shares WHERE id = ? AND user_id = ? AND status = 'active'", - ) - .bind(shareId, user.id) - .run(); - if (result.meta.changes !== 1) return notFound(); - return new Response(null, { status: 204 }); -} - -export async function getPublicShare( - env: Env, - slug: string, -): Promise { - const row = await env.DB.prepare( - `SELECT ${SHARE_COLUMNS} FROM shares - WHERE slug = ? AND status = 'active'`, - ) - .bind(slug) - .first(); - if (!row) return notFound(); - - const object = await env.PUBLISHED_NOTES.get(row.object_key); - if (!object?.body) return notFound(); - const markdown = await object.text(); - return json({ - title: row.title, - markdown, - publishedAt: row.published_at, - updatedAt: row.updated_at, - }); -} diff --git a/services/cloud-api/src/types.ts b/services/cloud-api/src/types.ts index 8dc5327..0c617aa 100644 --- a/services/cloud-api/src/types.ts +++ b/services/cloud-api/src/types.ts @@ -1,15 +1,17 @@ export interface Env { DB: D1Database; PUBLISHED_NOTES: R2Bucket; + IMAGES: ImagesBinding; EMAIL: SendEmail; PUBLIC_SITE_ORIGIN: string; + PUBLIC_API_ORIGIN: string; OTP_PEPPER: string; -} - -export interface PublishInput { - entryId: string; - title: string; - markdown: string; + R2_ACCOUNT_ID: string; + R2_BUCKET_NAME: string; + R2_ACCESS_KEY_ID: string; + R2_SECRET_ACCESS_KEY: string; + CACHE_ZONE_ID: string; + CACHE_PURGE_TOKEN: string; } export type AccountPlan = "free" | "cloud"; @@ -20,22 +22,72 @@ export interface AuthenticatedUser { plan: AccountPlan; } -export interface ShareRow { +export type PublishObjectKind = "page" | "asset"; + +export interface PublishObjectInput { + hash: string; + kind: PublishObjectKind; + contentType: string; + size: number; + width?: number; + height?: number; +} + +export interface PublishPageInput { + entryId: string; + path: string; + title: string; + objectHash: string; +} + +export interface PublishManifest { + version: 1; + rootEntryId: string; + pages: PublishPageInput[]; + objects: PublishObjectInput[]; +} + +export interface BeginPublishInput { + siteId?: string; + entryId: string; + title: string; + manifest: PublishManifest; +} + +export interface SiteRow { id: string; entry_id: string; user_id: string; slug: string; title: string; - object_key: string; - content_hash: string; - status: "active" | "revoked"; - idempotency_key: string; - published_at: number; + current_release_id: string | null; + created_at: number; updated_at: number; - revoked_at: number | null; } -export interface ShareResponse { +export interface PublishSessionRow { + id: string; + site_id: string | null; + entry_id: string; + user_id: string; + title: string; + manifest_key: string; + manifest_hash: string; + expires_at: number; + created_at: number; +} + +export interface ReleaseRow { + id: string; + site_id: string; + manifest_key: string; + manifest_hash: string; + page_count: number; + asset_count: number; + published_at: number; +} + +export interface SiteResponse { id: string; entryId: string; slug: string; @@ -44,4 +96,6 @@ export interface ShareResponse { contentHash: string; publishedAt: number; updatedAt: number; + pageCount: number; + assetCount: number; } diff --git a/services/cloud-api/src/validation.ts b/services/cloud-api/src/validation.ts index 0c2739f..08bd5bc 100644 --- a/services/cloud-api/src/validation.ts +++ b/services/cloud-api/src/validation.ts @@ -1,9 +1,28 @@ -import type { PublishInput } from "./types"; +import type { + BeginPublishInput, + PublishManifest, + PublishObjectInput, + PublishObjectKind, + PublishPageInput, +} from "./types"; -export const MAX_REQUEST_BYTES = 600 * 1024; -export const MAX_MARKDOWN_BYTES = 512 * 1024; +export const MAX_REQUEST_BYTES = 2 * 1024 * 1024; +const MAX_PUBLISHED_PAGES = 2_000; +const MAX_PUBLISHED_OBJECTS = 5_000; +const MAX_PAGE_BYTES = 2 * 1024 * 1024; +const MAX_ASSET_BYTES = 100 * 1024 * 1024; +const MAX_RELEASE_BYTES = 2 * 1024 * 1024 * 1024; const ENTRY_PATTERN = /^entry_[a-zA-Z0-9_-]{16,80}$/; -const IDEMPOTENCY_PATTERN = /^[a-zA-Z0-9_-]{16,100}$/; +const SITE_PATTERN = /^site_[a-f0-9]{32}$/; +const HASH_PATTERN = /^[a-f0-9]{64}$/; +const PAGE_PATH_PATTERN = /^(?:[a-z0-9][a-z0-9-]*(?:\/[a-z0-9][a-z0-9-]*)*)?$/; +const SAFE_ASSET_TYPES = new Set([ + "image/png", + "image/jpeg", + "image/gif", + "image/webp", + "image/avif", +]); export class ValidationError extends Error { constructor( @@ -14,43 +33,87 @@ export class ValidationError extends Error { } } -export function idempotencyKey(request: Request): string { - const key = request.headers.get("idempotency-key") ?? ""; - if (!IDEMPOTENCY_PATTERN.test(key)) { - throw new ValidationError( - "invalid_idempotency_key", - "A valid idempotency key is required.", - ); - } - return key; -} - -export function publishInput(value: unknown): PublishInput { - if (!value || typeof value !== "object") { - throw new ValidationError("invalid_publish_request", "Publish details are required."); - } +export function beginPublishInput(value: unknown): BeginPublishInput { + if (!value || typeof value !== "object") invalid("Publish details are required."); const input = value as Record; + const siteId = typeof input.siteId === "string" ? input.siteId : undefined; const entryId = typeof input.entryId === "string" ? input.entryId : ""; const title = typeof input.title === "string" ? input.title.trim() : ""; - const markdown = typeof input.markdown === "string" ? input.markdown : ""; - if (!ENTRY_PATTERN.test(entryId)) { - throw new ValidationError("invalid_entry_id", "The note identifier is invalid."); + if (siteId && !SITE_PATTERN.test(siteId)) invalid("The published site is invalid."); + if (!ENTRY_PATTERN.test(entryId)) invalid("The note identifier is invalid."); + if (!title || title.length > 200) invalid("The site title must be between 1 and 200 characters."); + + return { siteId, entryId, title, manifest: publishManifest(input.manifest, entryId) }; +} + +export function publishManifest(value: unknown, rootEntryId: string): PublishManifest { + if (!value || typeof value !== "object") invalid("A release manifest is required."); + const raw = value as Record; + const pages = Array.isArray(raw.pages) ? raw.pages.map(pageInput) : []; + const objects = Array.isArray(raw.objects) ? raw.objects.map(objectInput) : []; + if (raw.version !== 1) invalid("The release manifest version is unsupported."); + if (raw.rootEntryId !== rootEntryId) invalid("The release root does not match the site."); + if (!pages.length || pages.length > MAX_PUBLISHED_PAGES) invalid("The site has an invalid number of pages."); + if (!objects.length || objects.length > MAX_PUBLISHED_OBJECTS) invalid("The site has an invalid number of objects."); + + const paths = new Set(); + const entries = new Set(); + const hashes = new Map(); + let totalBytes = 0; + for (const object of objects) { + const existing = hashes.get(object.hash); + if (existing && (existing.kind !== object.kind || existing.size !== object.size || existing.contentType !== object.contentType)) { + invalid("A release object hash has conflicting metadata."); + } + hashes.set(object.hash, object); + totalBytes += existing ? 0 : object.size; } - if (!title || title.length > 200) { - throw new ValidationError( - "invalid_title", - "The published title must be between 1 and 200 characters.", - ); + if (totalBytes > MAX_RELEASE_BYTES) invalid("The published site is too large."); + + for (const page of pages) { + if (paths.has(page.path) || entries.has(page.entryId)) invalid("A page is included more than once."); + paths.add(page.path); + entries.add(page.entryId); + if (hashes.get(page.objectHash)?.kind !== "page") invalid("A page object is missing from the release."); } - if (!markdown.trim()) { - throw new ValidationError("empty_note", "An empty note cannot be published."); + if (!paths.has("") || !entries.has(rootEntryId)) invalid("The release root page is missing."); + return { version: 1, rootEntryId, pages, objects: [...hashes.values()] }; +} + +function pageInput(value: unknown): PublishPageInput { + if (!value || typeof value !== "object") invalid("Page details are invalid."); + const page = value as Record; + const entryId = typeof page.entryId === "string" ? page.entryId : ""; + const path = typeof page.path === "string" ? page.path : ""; + const title = typeof page.title === "string" ? page.title.trim() : ""; + const objectHash = typeof page.objectHash === "string" ? page.objectHash : ""; + if (!ENTRY_PATTERN.test(entryId) || !PAGE_PATH_PATTERN.test(path) || !HASH_PATTERN.test(objectHash)) invalid("Page details are invalid."); + if (!title || title.length > 200) invalid("Every page needs a valid title."); + return { entryId, path, title, objectHash }; +} + +function objectInput(value: unknown): PublishObjectInput { + if (!value || typeof value !== "object") invalid("Release object details are invalid."); + const object = value as Record; + const hash = typeof object.hash === "string" ? object.hash : ""; + const kind = object.kind === "page" || object.kind === "asset" ? object.kind : ""; + const contentType = typeof object.contentType === "string" ? object.contentType.toLowerCase() : ""; + const size = typeof object.size === "number" && Number.isSafeInteger(object.size) ? object.size : -1; + if (!HASH_PATTERN.test(hash) || !kind || size < 1) invalid("Release object details are invalid."); + validateObject(kind, contentType, size); + return { hash, kind: kind as PublishObjectKind, contentType, size }; +} + +function validateObject(kind: PublishObjectKind, contentType: string, size: number) { + if (kind === "page" && (contentType !== "text/markdown; charset=utf-8" || size > MAX_PAGE_BYTES)) { + invalid("Published pages must be Markdown and no larger than 2 MB."); } - if (new TextEncoder().encode(markdown).byteLength > MAX_MARKDOWN_BYTES) { - throw new ValidationError( - "note_too_large", - "Published Markdown must be 512 KB or smaller.", - ); + if (kind === "asset" && (!SAFE_ASSET_TYPES.has(contentType) || size > MAX_ASSET_BYTES)) { + invalid("Published images must be PNG, JPEG, GIF, WebP, or AVIF and no larger than 100 MB."); } - return { entryId, title, markdown }; +} + +function invalid(message: string): never { + throw new ValidationError("invalid_publish_request", message); } diff --git a/services/cloud-api/test/publishing.test.ts b/services/cloud-api/test/publishing.test.ts index dd279f2..b2fcc3d 100644 --- a/services/cloud-api/test/publishing.test.ts +++ b/services/cloud-api/test/publishing.test.ts @@ -2,45 +2,125 @@ import { describe, expect, test } from "bun:test"; import { hmacSha256, randomOtp, randomSlug, randomToken, sha256 } from "../src/crypto"; import { sendOtpEmail } from "../src/email"; import { normalizeEmail, OtpError } from "../src/otp"; +import { presignedPutUrl } from "../src/r2-signing"; +import type { Env } from "../src/types"; import { - idempotencyKey, - publishInput, + beginPublishInput, ValidationError, } from "../src/validation"; +const ROOT_HASH = "a".repeat(64); +const ASSET_HASH = "b".repeat(64); + describe("publishing validation", () => { - test("accepts a valid publish snapshot", () => { + test("accepts a content-addressed release", () => { expect( - publishInput({ + beginPublishInput({ entryId: "entry_1234567890abcdef", title: " Project notes ", - markdown: "# Project\n\nHello.", + manifest: { + version: 1, + rootEntryId: "entry_1234567890abcdef", + pages: [{ + entryId: "entry_1234567890abcdef", + path: "", + title: "Project notes", + objectHash: ROOT_HASH, + }], + objects: [{ + hash: ROOT_HASH, + kind: "page", + contentType: "text/markdown; charset=utf-8", + size: 24, + }], + }, }), ).toEqual({ entryId: "entry_1234567890abcdef", title: "Project notes", - markdown: "# Project\n\nHello.", + manifest: { + version: 1, + rootEntryId: "entry_1234567890abcdef", + pages: [{ + entryId: "entry_1234567890abcdef", + path: "", + title: "Project notes", + objectHash: ROOT_HASH, + }], + objects: [{ + hash: ROOT_HASH, + kind: "page", + contentType: "text/markdown; charset=utf-8", + size: 24, + }], + }, }); }); - test("rejects empty notes", () => { + test("accepts linked pages and hosted images", () => { + const result = beginPublishInput({ + entryId: "entry_1234567890abcdef", + title: "Project notes", + manifest: { + version: 1, + rootEntryId: "entry_1234567890abcdef", + pages: [{ + entryId: "entry_1234567890abcdef", + path: "", + title: "Project notes", + objectHash: ROOT_HASH, + }, { + entryId: "entry_abcdef1234567890", + path: "roadmap", + title: "Roadmap", + objectHash: ROOT_HASH, + }], + objects: [{ + hash: ROOT_HASH, + kind: "page", + contentType: "text/markdown; charset=utf-8", + size: 24, + }, { + hash: ASSET_HASH, + kind: "asset", + contentType: "image/png", + size: 128, + }], + }, + }); + expect(result.manifest.pages).toHaveLength(2); + expect(result.manifest.objects).toHaveLength(2); + }); + + test("rejects executable image formats", () => { expect(() => - publishInput({ + beginPublishInput({ entryId: "entry_1234567890abcdef", - title: "Empty", - markdown: " ", + title: "Unsafe", + manifest: { + version: 1, + rootEntryId: "entry_1234567890abcdef", + pages: [{ + entryId: "entry_1234567890abcdef", + path: "", + title: "Unsafe", + objectHash: ROOT_HASH, + }], + objects: [{ + hash: ROOT_HASH, + kind: "page", + contentType: "text/markdown; charset=utf-8", + size: 12, + }, { + hash: ASSET_HASH, + kind: "asset", + contentType: "image/svg+xml", + size: 128, + }], + }, }), ).toThrow(ValidationError); }); - - test("requires an idempotency credential", () => { - const request = new Request("https://api.example.test/v1/shares", { - headers: { - "idempotency-key": "publish_1234567890abcdef", - }, - }); - expect(idempotencyKey(request)).toBe("publish_1234567890abcdef"); - }); }); describe("publishing identifiers", () => { @@ -54,6 +134,29 @@ describe("publishing identifiers", () => { "5f760a58961babe4c488f61b3457e73fc9d9b78f5727b04ae80a8e470580eb6f", ); }); + + test("scopes direct uploads to one checksum-bound R2 object", async () => { + const env = { + R2_ACCOUNT_ID: "account123", + R2_BUCKET_NAME: "published", + R2_ACCESS_KEY_ID: "access123", + R2_SECRET_ACCESS_KEY: "secret123", + } as Env; + const checksum = "YWJj"; + const signed = new URL( + await presignedPutUrl( + env, + `objects/user_123/${ROOT_HASH}`, + "image/png", + checksum, + ), + ); + expect(signed.hostname).toBe("account123.r2.cloudflarestorage.com"); + expect(signed.pathname).toBe(`/published/objects/user_123/${ROOT_HASH}`); + expect(signed.searchParams.get("X-Amz-Expires")).toBe("900"); + expect(signed.searchParams.get("X-Amz-SignedHeaders")).toContain("x-amz-checksum-sha256"); + expect(signed.searchParams.get("X-Amz-Signature")).toMatch(/^[a-f0-9]{64}$/); + }); }); describe("email OTP authentication", () => { diff --git a/services/cloud-api/wrangler.jsonc b/services/cloud-api/wrangler.jsonc index 5237bc6..280e911 100644 --- a/services/cloud-api/wrangler.jsonc +++ b/services/cloud-api/wrangler.jsonc @@ -7,11 +7,23 @@ "observability": { "enabled": true }, + "triggers": { + "crons": ["0 * * * *"] + }, "vars": { - "PUBLIC_SITE_ORIGIN": "https://usemarkd.app" + "PUBLIC_SITE_ORIGIN": "https://usemarkd.app", + "PUBLIC_API_ORIGIN": "https://api.usemarkd.app", + "R2_BUCKET_NAME": "markd-published-notes" }, "secrets": { - "required": ["OTP_PEPPER"] + "required": [ + "OTP_PEPPER", + "R2_ACCOUNT_ID", + "R2_ACCESS_KEY_ID", + "R2_SECRET_ACCESS_KEY", + "CACHE_ZONE_ID", + "CACHE_PURGE_TOKEN" + ] }, "send_email": [ { @@ -19,6 +31,9 @@ "allowed_sender_addresses": ["no-reply@usemarkd.app"] } ], + "images": { + "binding": "IMAGES" + }, "d1_databases": [ { "binding": "DB", diff --git a/site/app/s/[slug]/page.tsx b/site/app/s/[slug]/[[...path]]/page.tsx similarity index 70% rename from site/app/s/[slug]/page.tsx rename to site/app/s/[slug]/[[...path]]/page.tsx index 884c5e8..ea9ac1a 100644 --- a/site/app/s/[slug]/page.tsx +++ b/site/app/s/[slug]/[[...path]]/page.tsx @@ -7,7 +7,7 @@ import { noteDescription, } from "@/lib/published-note"; -export const dynamic = "force-dynamic"; +export const revalidate = 300; export const viewport: Viewport = { colorScheme: "light dark", @@ -18,12 +18,16 @@ export const viewport: Viewport = { }; interface SharePageProps { - params: Promise<{ slug: string }>; + params: Promise<{ slug: string; path?: string[] }>; +} + +function pagePath(path?: string[]) { + return path?.length ? path.join("/") : undefined; } export async function generateMetadata({ params }: SharePageProps): Promise { - const { slug } = await params; - const note = await getPublishedNote(slug); + const { slug, path } = await params; + const note = await getPublishedNote(slug, pagePath(path)); if (!note) return { title: "Published note · Markd", robots: { index: false } }; const description = noteDescription(note.markdown) || "A note published with Markd."; @@ -42,16 +46,10 @@ export async function generateMetadata({ params }: SharePageProps): Promise
@@ -70,17 +68,25 @@ export default async function SharePage({ params }: SharePageProps) { - + ); diff --git a/site/components/published/PublishedMarkdown.tsx b/site/components/published/PublishedMarkdown.tsx index 1cd889d..4f3c4cb 100644 --- a/site/components/published/PublishedMarkdown.tsx +++ b/site/components/published/PublishedMarkdown.tsx @@ -1,5 +1,5 @@ import type { ComponentPropsWithoutRef } from "react"; -import ReactMarkdown from "react-markdown"; +import ReactMarkdown, { defaultUrlTransform } from "react-markdown"; import remarkGfm from "remark-gfm"; import { publishedProperties, @@ -8,16 +8,30 @@ import { function isSafeLink(href: string | undefined): boolean { if (!href) return false; - return href.startsWith("#") || /^(https?:|mailto:)/i.test(href); + return href.startsWith("#") || href.startsWith("markd-page:") || /^(https?:|mailto:)/i.test(href); } -function PublishedLink({ href, children, ...props }: ComponentPropsWithoutRef<"a">) { +function PublishedLink({ + href, + siteSlug, + children, + ...props +}: ComponentPropsWithoutRef<"a"> & { siteSlug: string }) { if (!isSafeLink(href)) return {children}; + const pagePath = href?.startsWith("markd-page:") + ? href.slice("markd-page:".length) + : null; + const safeHref = + pagePath === null + ? href + : pagePath + ? `/s/${encodeURIComponent(siteSlug)}/${pagePath}` + : `/s/${encodeURIComponent(siteSlug)}`; const external = /^https?:/i.test(href ?? ""); return ( @@ -26,7 +40,19 @@ function PublishedLink({ href, children, ...props }: ComponentPropsWithoutRef<"a ); } -export function PublishedMarkdown({ markdown }: { markdown: string }) { +export function PublishedMarkdown({ + markdown, + siteSlug, + assetBaseUrl, + assetTypes, + assetDimensions, +}: { + markdown: string; + siteSlug: string; + assetBaseUrl: string; + assetTypes: Record; + assetDimensions: Record; +}) { const { body } = splitPublishedFrontmatter(markdown); const properties = publishedProperties(markdown); @@ -36,13 +62,66 @@ export function PublishedMarkdown({ markdown }: { markdown: string }) { + typeof url === "string" && url.startsWith("markd-asset:") + ? url + : defaultUrlTransform(url) + } components={{ - a: PublishedLink, - img: ({ alt }) => ( - - {alt || "Image attachment"} - - ), + a: (props) => , + img: ({ alt, src }) => { + const hash = typeof src === "string" && src.startsWith("markd-asset:") + ? src.slice("markd-asset:".length) + : null; + if (!hash || !/^[a-f0-9]{64}$/.test(hash)) { + return ( + + {alt || "Image attachment"} + + ); + } + const widths = [320, 640, 960, 1280, 1600]; + const sizes = "(max-width: 720px) calc(100vw - 40px), 672px"; + const dimensions = assetDimensions[hash]; + if (assetTypes[hash] === "image/gif") { + return ( + {alt + ); + } + return ( + + `${assetBaseUrl}/${hash}?w=${width}&f=avif ${width}w`) + .join(", ")} + sizes={sizes} + /> + `${assetBaseUrl}/${hash}?w=${width}&f=webp ${width}w`) + .join(", ")} + sizes={sizes} + /> + {alt + + ); + }, }} > {body} diff --git a/site/lib/published-note.ts b/site/lib/published-note.ts index 30ba2ad..a1325ac 100644 --- a/site/lib/published-note.ts +++ b/site/lib/published-note.ts @@ -5,6 +5,9 @@ export interface PublishedNote { markdown: string; publishedAt: number; updatedAt: number; + assetBaseUrl: string; + assetTypes: Record; + assetDimensions: Record; } export interface PublishedProperty { @@ -30,15 +33,36 @@ function isPublishedNote(value: unknown): value is PublishedNote { typeof note.markdown === "string" && typeof note.publishedAt === "number" && typeof note.updatedAt === "number" + && typeof note.assetBaseUrl === "string" && isStringRecord(note.assetTypes) + && isDimensionRecord(note.assetDimensions) ); } +function isStringRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" + && Object.values(value as Record).every((item) => typeof item === "string"); +} + +function isDimensionRecord( + value: unknown, +): value is Record { + return Boolean(value) && typeof value === "object" + && Object.values(value as Record).every((item) => { + if (!item || typeof item !== "object") return false; + const dimension = item as Record; + return typeof dimension.width === "number" && typeof dimension.height === "number"; + }); +} + export const getPublishedNote = cache( - async (slug: string): Promise => { + async (slug: string, path?: string): Promise => { + const pagePath = path + ? `/pages/${path.split("/").map(encodeURIComponent).join("/")}` + : ""; const response = await fetch( - `${apiOrigin()}/v1/public/shares/${encodeURIComponent(slug)}`, + `${apiOrigin()}/v1/public/sites/${encodeURIComponent(slug)}${pagePath}`, { - cache: "no-store", + next: { revalidate: 300, tags: [`markd-slug-${slug}`] }, headers: { accept: "application/json" }, }, ); diff --git a/site/middleware.ts b/site/middleware.ts new file mode 100644 index 0000000..1893ed8 --- /dev/null +++ b/site/middleware.ts @@ -0,0 +1,12 @@ +import { type NextRequest, NextResponse } from "next/server"; + +export function middleware(request: NextRequest) { + const response = NextResponse.next(); + const slug = request.nextUrl.pathname.split("/")[2]; + if (slug) response.headers.set("Cache-Tag", `markd-slug-${slug}`); + return response; +} + +export const config = { + matcher: "/s/:path*", +}; diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 7ef75e9..a590924 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -6,6 +6,9 @@ version = 4 name = "Markd" version = "0.1.5" dependencies = [ + "base64 0.22.1", + "futures-util", + "infer", "regex", "reqwest 0.12.28", "scraper", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index b103a74..2d3c1da 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -30,6 +30,9 @@ trash = "5" regex = "1" tauri-plugin-global-shortcut = "2" sha2 = "0.10" +base64 = "0.22" +futures-util = "0.3" +infer = "0.19" [dev-dependencies] tempfile = "3" diff --git a/src-tauri/src/cloud.rs b/src-tauri/src/cloud.rs index 2fbe66e..2eb343c 100644 --- a/src-tauri/src/cloud.rs +++ b/src-tauri/src/cloud.rs @@ -5,24 +5,15 @@ use std::time::{Duration, SystemTime, UNIX_EPOCH}; use reqwest::{Client, StatusCode}; use serde::{Deserialize, Serialize}; -use sha2::{Digest, Sha256}; use tauri::Manager; -use uuid::Uuid; use crate::cloud_metadata::{self, PublishedShare}; +pub use crate::cloud_publish::PublishPageDraft; use crate::error::{AppError, AppResult}; -const API_BASE: &str = "https://api.usemarkd.app"; +pub(crate) const API_BASE: &str = "https://api.usemarkd.app"; const SESSION_FILE: &str = "cloud-session.json"; -#[derive(Debug, Serialize)] -#[serde(rename_all = "camelCase")] -struct PublishRequest<'a> { - entry_id: &'a str, - title: &'a str, - markdown: &'a str, -} - #[derive(Debug, Serialize)] struct OtpRequest<'a> { email: &'a str, @@ -44,11 +35,6 @@ pub struct OtpChallenge { pub resend_after: u64, } -#[derive(Debug, Deserialize)] -struct ShareEnvelope { - share: PublishedShare, -} - #[derive(Debug, Deserialize)] struct ErrorEnvelope { error: CloudError, @@ -56,10 +42,9 @@ struct ErrorEnvelope { #[derive(Debug, Deserialize)] struct CloudError { + #[allow(dead_code)] code: String, message: String, - #[serde(default)] - share: Option, } #[derive(Debug, Serialize)] @@ -68,7 +53,6 @@ pub struct PublishedNoteStatus { pub account: Option, pub share: Option, pub is_outdated: bool, - pub free_share_limit: u8, } #[derive(Clone, Debug, Deserialize, Serialize)] @@ -164,7 +148,7 @@ pub fn account_status(app: &tauri::AppHandle) -> AppResult { }) } -fn access_token(app: &tauri::AppHandle) -> AppResult { +pub(crate) fn access_token(app: &tauri::AppHandle) -> AppResult { load_session(app)? .map(|session| session.access_token) .ok_or_else(|| { @@ -172,7 +156,7 @@ fn access_token(app: &tauri::AppHandle) -> AppResult { }) } -fn client() -> AppResult { +pub(crate) fn client() -> AppResult { Client::builder() .timeout(Duration::from_secs(20)) .user_agent("Markd/0.1.5") @@ -180,15 +164,7 @@ fn client() -> AppResult { .map_err(|error| AppError::Network(error.to_string())) } -fn content_hash(content: &str) -> String { - format!("{:x}", Sha256::digest(content.as_bytes())) -} - -fn request_key() -> String { - format!("publish_{}", Uuid::new_v4().simple()) -} - -async fn cloud_error(response: reqwest::Response) -> AppError { +pub(crate) async fn cloud_error(response: reqwest::Response) -> AppError { let status = response.status(); let body = response.text().await.unwrap_or_default(); let error = serde_json::from_str::(&body) @@ -196,7 +172,6 @@ async fn cloud_error(response: reqwest::Response) -> AppError { .unwrap_or(CloudError { code: "cloud_request_failed".to_string(), message: format!("Markd Cloud returned {status}"), - share: None, }); if status == StatusCode::UNAUTHORIZED { AppError::CloudLoginRequired(error.message) @@ -207,6 +182,10 @@ async fn cloud_error(response: reqwest::Response) -> AppError { } } +pub(crate) fn stored_account(app: &tauri::AppHandle) -> AppResult> { + Ok(load_session(app)?.map(|session| session.account)) +} + pub async fn request_otp(email: &str) -> AppResult { let response = client()? .post(format!("{API_BASE}/v1/auth/otp/request")) @@ -268,55 +247,15 @@ pub async fn sign_out(app: &tauri::AppHandle) -> AppResult<()> { Ok(()) } -async fn parse_response(response: reqwest::Response) -> AppResult { - let status = response.status(); - let body = response - .text() - .await - .map_err(|error| AppError::Network(error.to_string()))?; - if status.is_success() { - return serde_json::from_str::(&body) - .map(|envelope| envelope.share) - .map_err(|error| AppError::Cloud(format!("invalid publishing response: {error}"))); - } - - let error = serde_json::from_str::(&body) - .map(|envelope| envelope.error) - .unwrap_or(CloudError { - code: "publishing_failed".to_string(), - message: format!("publishing service returned {status}"), - share: None, - }); - if status == StatusCode::CONFLICT && error.code == "already_published" { - return error - .share - .ok_or_else(|| AppError::Cloud("published note metadata is missing".to_string())); - } - if status == StatusCode::PAYMENT_REQUIRED { - return Err(AppError::CloudSubscriptionRequired(error.message)); - } - if status == StatusCode::UNAUTHORIZED { - return Err(AppError::CloudLoginRequired(error.message)); - } - Err(AppError::Cloud(error.message)) -} - pub fn status( app: &tauri::AppHandle, root: &Path, rel: &str, + title: &str, content: &str, + pages: Vec, ) -> AppResult { - let share = cloud_metadata::get(root, rel)?.and_then(|entry| entry.share); - let is_outdated = share - .as_ref() - .is_some_and(|published| published.content_hash != content_hash(content)); - Ok(PublishedNoteStatus { - account: load_session(app)?.map(|session| session.account), - share, - is_outdated, - free_share_limit: 1, - }) + crate::cloud_publish::status(app, root, rel, title, content, pages) } pub async fn publish( @@ -325,23 +264,9 @@ pub async fn publish( rel: &str, title: &str, content: &str, + pages: Vec, ) -> AppResult { - let entry = cloud_metadata::entry(root, rel)?; - let response = client()? - .post(format!("{API_BASE}/v1/shares")) - .bearer_auth(access_token(app)?) - .header("idempotency-key", request_key()) - .json(&PublishRequest { - entry_id: &entry.entry_id, - title, - markdown: content, - }) - .send() - .await - .map_err(|error| AppError::Network(error.to_string()))?; - let share = parse_response(response).await?; - cloud_metadata::set_share(root, rel, share.clone())?; - Ok(share) + crate::cloud_publish::publish(app, root, rel, title, content, pages).await } pub async fn update( @@ -350,27 +275,9 @@ pub async fn update( rel: &str, title: &str, content: &str, + pages: Vec, ) -> AppResult { - let entry = - cloud_metadata::get(root, rel)?.ok_or_else(|| AppError::NotFound(rel.to_string()))?; - let current = entry - .share - .ok_or_else(|| AppError::NotFound("published note".to_string()))?; - let response = client()? - .put(format!("{API_BASE}/v1/shares/{}", current.id)) - .bearer_auth(access_token(app)?) - .header("idempotency-key", request_key()) - .json(&PublishRequest { - entry_id: &entry.entry_id, - title, - markdown: content, - }) - .send() - .await - .map_err(|error| AppError::Network(error.to_string()))?; - let share = parse_response(response).await?; - cloud_metadata::set_share(root, rel, share.clone())?; - Ok(share) + crate::cloud_publish::update(app, root, rel, title, content, pages).await } pub async fn revoke(app: &tauri::AppHandle, root: &Path, rel: &str) -> AppResult<()> { @@ -381,7 +288,7 @@ pub async fn revoke(app: &tauri::AppHandle, root: &Path, rel: &str) -> AppResult return Ok(()); }; let response = client()? - .delete(format!("{API_BASE}/v1/shares/{}", share.id)) + .delete(format!("{API_BASE}/v1/sites/{}", share.id)) .bearer_auth(access_token(app)?) .send() .await @@ -396,16 +303,3 @@ pub async fn revoke(app: &tauri::AppHandle, root: &Path, rel: &str) -> AppResult } cloud_metadata::clear_share(root, rel) } - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn hashes_content_like_the_cloud_api() { - assert_eq!( - content_hash("Markd"), - "5f760a58961babe4c488f61b3457e73fc9d9b78f5727b04ae80a8e470580eb6f" - ); - } -} diff --git a/src-tauri/src/cloud_metadata.rs b/src-tauri/src/cloud_metadata.rs index 2954b27..a1c9626 100644 --- a/src-tauri/src/cloud_metadata.rs +++ b/src-tauri/src/cloud_metadata.rs @@ -19,6 +19,10 @@ pub struct PublishedShare { pub content_hash: String, pub published_at: i64, pub updated_at: i64, + #[serde(default)] + pub page_count: usize, + #[serde(default)] + pub asset_count: usize, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -110,7 +114,11 @@ pub fn clear_share(root: &Path, rel: &str) -> AppResult<()> { pub fn has_published_under(root: &Path, rel: &str) -> AppResult { Ok(read(root)?.entries.iter().any(|(entry_rel, entry)| { - entry.share.is_some() && (entry_rel == rel || entry_rel.starts_with(&format!("{rel}/"))) + entry + .share + .as_ref() + .is_some_and(|share| share.id.starts_with("site_")) + && (entry_rel == rel || entry_rel.starts_with(&format!("{rel}/"))) })) } @@ -178,7 +186,7 @@ mod tests { dir.path(), "projects/Plan.md", PublishedShare { - id: "share_123".into(), + id: "site_123".into(), entry_id: cloud_entry.entry_id, slug: "public-slug".into(), url: "https://usemarkd.app/s/public-slug".into(), @@ -186,6 +194,8 @@ mod tests { content_hash: "abc".into(), published_at: 1, updated_at: 1, + page_count: 0, + asset_count: 0, }, ) .unwrap(); diff --git a/src-tauri/src/cloud_publish.rs b/src-tauri/src/cloud_publish.rs new file mode 100644 index 0000000..92856b8 --- /dev/null +++ b/src-tauri/src/cloud_publish.rs @@ -0,0 +1,464 @@ +use std::collections::BTreeMap; +use std::fs; +use std::path::Path; +use std::time::Duration; + +#[cfg(test)] +use base64::engine::general_purpose::STANDARD; +#[cfg(test)] +use base64::Engine; +use futures_util::stream::{self, StreamExt, TryStreamExt}; +use regex::Regex; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use tauri::AppHandle; + +use crate::cloud::{self, PublishedNoteStatus, API_BASE}; +use crate::cloud_metadata::{self, PublishedShare}; +use crate::error::{AppError, AppResult}; +use crate::vault::ASSETS_DIR; + +const PAGE_CONTENT_TYPE: &str = "text/markdown; charset=utf-8"; +const UPLOAD_CONCURRENCY: usize = 6; + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PublishPageDraft { + pub rel: String, + pub path: String, + pub title: String, + pub markdown: String, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct BeginPublishRequest { + #[serde(skip_serializing_if = "Option::is_none")] + site_id: Option, + entry_id: String, + title: String, + manifest: PublishManifest, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +struct PublishManifest { + version: u8, + root_entry_id: String, + pages: Vec, + objects: Vec, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +struct PublishPage { + entry_id: String, + path: String, + title: String, + object_hash: String, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +struct PublishObject { + hash: String, + kind: &'static str, + content_type: String, + size: usize, +} + +struct PreparedRelease { + manifest: PublishManifest, + objects: BTreeMap>, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct BeginPublishResponse { + session_id: String, + uploads: Vec, +} + +#[derive(Debug, Deserialize)] +struct PendingUpload { + hash: String, + url: String, + headers: BTreeMap, +} + +#[derive(Debug, Deserialize)] +struct SiteEnvelope { + site: PublishedShare, +} + +pub fn status( + app: &AppHandle, + root: &Path, + rel: &str, + title: &str, + content: &str, + pages: Vec, +) -> AppResult { + let mut share = cloud_metadata::get(root, rel)?.and_then(|entry| entry.share); + if share + .as_ref() + .is_some_and(|published| !published.id.starts_with("site_")) + { + cloud_metadata::clear_share(root, rel)?; + share = None; + } + let release_title = share + .as_ref() + .map(|published| published.title.as_str()) + .unwrap_or(title); + let local_hash = prepare_release(root, rel, release_title, content, pages)?.manifest_hash()?; + Ok(PublishedNoteStatus { + account: cloud::stored_account(app)?, + is_outdated: share + .as_ref() + .is_some_and(|published| published.content_hash != local_hash), + share, + }) +} + +pub async fn publish( + app: &AppHandle, + root: &Path, + rel: &str, + title: &str, + content: &str, + pages: Vec, +) -> AppResult { + let entry = cloud_metadata::entry(root, rel)?; + publish_release(app, root, rel, entry.entry_id, None, title, content, pages).await +} + +pub async fn update( + app: &AppHandle, + root: &Path, + rel: &str, + title: &str, + content: &str, + pages: Vec, +) -> AppResult { + let entry = + cloud_metadata::get(root, rel)?.ok_or_else(|| AppError::NotFound(rel.to_string()))?; + let site = entry + .share + .ok_or_else(|| AppError::NotFound("published site".to_string()))?; + publish_release( + app, + root, + rel, + entry.entry_id, + Some(site.id), + title, + content, + pages, + ) + .await +} + +async fn publish_release( + app: &AppHandle, + root: &Path, + rel: &str, + entry_id: String, + site_id: Option, + title: &str, + content: &str, + pages: Vec, +) -> AppResult { + let prepared = prepare_release(root, rel, title, content, pages)?; + let expected_hash = prepared.manifest_hash()?; + let response = cloud::client()? + .post(format!("{API_BASE}/v1/publish-sessions")) + .bearer_auth(cloud::access_token(app)?) + .json(&BeginPublishRequest { + site_id, + entry_id, + title: title.to_string(), + manifest: prepared.manifest.clone(), + }) + .send() + .await + .map_err(network_error)?; + if !response.status().is_success() { + return Err(cloud::cloud_error(response).await); + } + let session = response + .json::() + .await + .map_err(|error| AppError::Cloud(format!("invalid publish session: {error}")))?; + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(5 * 60)) + .user_agent("Markd/0.1.5") + .build() + .map_err(network_error)?; + stream::iter(session.uploads) + .map(|upload| { + let bytes = prepared.objects.get(&upload.hash).cloned(); + let client = client.clone(); + async move { + let bytes = + bytes.ok_or_else(|| AppError::Cloud("upload object is missing".to_string()))?; + let mut request = client.put(upload.url).body(bytes); + for (name, value) in upload.headers { + request = request.header(name, value); + } + let response = request.send().await.map_err(network_error)?; + if !response.status().is_success() { + return Err(AppError::Cloud(format!( + "asset upload failed with {}", + response.status() + ))); + } + Ok(()) + } + }) + .buffer_unordered(UPLOAD_CONCURRENCY) + .try_collect::>() + .await?; + + let response = cloud::client()? + .post(format!( + "{API_BASE}/v1/publish-sessions/{}/finalize", + session.session_id + )) + .bearer_auth(cloud::access_token(app)?) + .send() + .await + .map_err(network_error)?; + if !response.status().is_success() { + return Err(cloud::cloud_error(response).await); + } + let mut site = response + .json::() + .await + .map_err(|error| AppError::Cloud(format!("invalid publishing response: {error}")))? + .site; + site.content_hash = expected_hash; + cloud_metadata::set_share(root, rel, site.clone())?; + Ok(site) +} + +fn prepare_release( + root: &Path, + root_rel: &str, + root_title: &str, + root_markdown: &str, + pages: Vec, +) -> AppResult { + let mut objects = BTreeMap::>::new(); + let mut descriptors = BTreeMap::::new(); + let mut published_pages = Vec::with_capacity(pages.len() + 1); + let mut drafts = Vec::with_capacity(pages.len() + 1); + drafts.push(PublishPageDraft { + rel: root_rel.to_string(), + path: String::new(), + title: root_title.to_string(), + markdown: root_markdown.to_string(), + }); + drafts.extend(pages); + + for draft in drafts { + let entry = cloud_metadata::entry(root, &draft.rel)?; + let markdown = rewrite_assets(root, &draft.markdown, &mut objects, &mut descriptors)?; + let bytes = markdown.into_bytes(); + let hash = hash_bytes(&bytes); + descriptors + .entry(hash.clone()) + .or_insert_with(|| PublishObject { + hash: hash.clone(), + kind: "page", + content_type: PAGE_CONTENT_TYPE.to_string(), + size: bytes.len(), + }); + objects.entry(hash.clone()).or_insert(bytes); + published_pages.push(PublishPage { + entry_id: entry.entry_id, + path: draft.path, + title: draft.title, + object_hash: hash, + }); + } + let root_entry_id = published_pages + .first() + .map(|page| page.entry_id.clone()) + .ok_or_else(|| AppError::InvalidInput("published site has no root page".to_string()))?; + Ok(PreparedRelease { + manifest: PublishManifest { + version: 1, + root_entry_id, + pages: published_pages, + objects: descriptors.into_values().collect(), + }, + objects, + }) +} + +fn rewrite_assets( + root: &Path, + markdown: &str, + objects: &mut BTreeMap>, + descriptors: &mut BTreeMap, +) -> AppResult { + let image = Regex::new(r#"!\[[^\]]*\]\(\s*(?P<[^>]+>|[^)\s]+)"#) + .map_err(|error| AppError::Other(error.to_string()))?; + let mut replacements = Vec::new(); + for captures in image.captures_iter(markdown) { + let Some(found) = captures.name("href") else { + continue; + }; + let href = found.as_str().trim_matches(['<', '>']); + if is_remote(href) || href.starts_with("markd-asset:") { + continue; + } + let normalized = href.trim_start_matches('/'); + if !normalized.starts_with(&format!("{ASSETS_DIR}/")) { + return Err(AppError::InvalidInput(format!( + "image must be stored in {ASSETS_DIR}: {href}" + ))); + } + let asset_root = fs::canonicalize(root.join(ASSETS_DIR))?; + let path = fs::canonicalize(root.join(normalized))?; + if !path.starts_with(&asset_root) || !path.is_file() { + return Err(AppError::InvalidInput( + "image path leaves the vault asset folder".to_string(), + )); + } + let bytes = fs::read(&path)?; + let content_type = image_content_type(&bytes)?; + let hash = hash_bytes(&bytes); + descriptors + .entry(hash.clone()) + .or_insert_with(|| PublishObject { + hash: hash.clone(), + kind: "asset", + content_type, + size: bytes.len(), + }); + objects.entry(hash.clone()).or_insert(bytes); + replacements.push((found.start(), found.end(), format!("markd-asset:{hash}"))); + } + let mut rewritten = markdown.to_string(); + for (start, end, value) in replacements.into_iter().rev() { + rewritten.replace_range(start..end, &value); + } + Ok(rewritten) +} + +fn image_content_type(bytes: &[u8]) -> AppResult { + let mime = infer::get(bytes).map(|kind| kind.mime_type()).unwrap_or(""); + match mime { + "image/png" | "image/jpeg" | "image/gif" | "image/webp" | "image/avif" => { + Ok(mime.to_string()) + } + "image/svg+xml" => Err(AppError::InvalidInput( + "SVG images must be converted before publishing".to_string(), + )), + _ => Err(AppError::InvalidInput( + "unsupported or invalid published image".to_string(), + )), + } +} + +fn is_remote(href: &str) -> bool { + href.starts_with('#') + || href.starts_with("//") + || href.contains("://") + || href.starts_with("data:") +} + +fn hash_bytes(bytes: &[u8]) -> String { + format!("{:x}", Sha256::digest(bytes)) +} + +#[cfg(test)] +fn checksum_base64(bytes: &[u8]) -> String { + STANDARD.encode(Sha256::digest(bytes)) +} + +fn network_error(error: reqwest::Error) -> AppError { + AppError::Network(error.to_string()) +} + +impl PreparedRelease { + fn manifest_hash(&self) -> AppResult { + Ok(hash_bytes( + serde_json::to_string(&self.manifest)?.as_bytes(), + )) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::vault::{ensure_layout, notes_root}; + use tempfile::tempdir; + + #[test] + fn rewrites_and_deduplicates_local_images() { + let dir = tempdir().unwrap(); + ensure_layout(dir.path()).unwrap(); + fs::write(notes_root(dir.path()).join("Home.md"), "# Home").unwrap(); + let asset = dir.path().join(ASSETS_DIR).join("pixel.png"); + fs::write(&asset, b"\x89PNG\r\n\x1a\nrest").unwrap(); + let mut objects = BTreeMap::new(); + let mut descriptors = BTreeMap::new(); + let markdown = rewrite_assets( + dir.path(), + "![one](.markd/assets/pixel.png) ![two](.markd/assets/pixel.png)", + &mut objects, + &mut descriptors, + ) + .unwrap(); + assert_eq!(objects.len(), 1); + assert_eq!(descriptors.len(), 1); + assert_eq!(markdown.matches("markd-asset:").count(), 2); + } + + #[test] + fn checksum_header_matches_content_hash() { + let bytes = b"Markd"; + assert_eq!( + STANDARD.decode(checksum_base64(bytes)).unwrap(), + Sha256::digest(bytes).as_slice() + ); + } + + #[test] + fn release_hash_tracks_linked_page_changes() { + let dir = tempdir().unwrap(); + ensure_layout(dir.path()).unwrap(); + fs::write(notes_root(dir.path()).join("Home.md"), "# Home").unwrap(); + fs::write(notes_root(dir.path()).join("Roadmap.md"), "# Roadmap").unwrap(); + let page = |markdown: &str| PublishPageDraft { + rel: "Roadmap.md".into(), + path: "roadmap".into(), + title: "Roadmap".into(), + markdown: markdown.into(), + }; + let first = prepare_release( + dir.path(), + "Home.md", + "Home", + "# Home", + vec![page("# Roadmap")], + ) + .unwrap() + .manifest_hash() + .unwrap(); + let second = prepare_release( + dir.path(), + "Home.md", + "Home", + "# Home", + vec![page("# Roadmap\n\nUpdated")], + ) + .unwrap() + .manifest_hash() + .unwrap(); + assert_ne!(first, second); + } +} diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 266af55..cc903d8 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -260,9 +260,11 @@ pub fn published_note_status( app: AppHandle, state: State<'_, AppState>, rel: String, + title: String, content: String, + pages: Vec, ) -> AppResult { - cloud::status(&app, &state.root()?, &rel, &content) + cloud::status(&app, &state.root()?, &rel, &title, &content, pages) } #[tauri::command] @@ -277,8 +279,9 @@ pub async fn publish_note( rel: String, title: String, content: String, + pages: Vec, ) -> AppResult { - cloud::publish(&app, &state.root()?, &rel, &title, &content).await + cloud::publish(&app, &state.root()?, &rel, &title, &content, pages).await } #[tauri::command] @@ -288,8 +291,9 @@ pub async fn update_published_note( rel: String, title: String, content: String, + pages: Vec, ) -> AppResult { - cloud::update(&app, &state.root()?, &rel, &title, &content).await + cloud::update(&app, &state.root()?, &rel, &title, &content, pages).await } #[tauri::command] diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 1e10a94..a7f82a1 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -5,6 +5,7 @@ mod backlinks; mod bookmarks; mod cloud; mod cloud_metadata; +mod cloud_publish; mod commands; mod config; mod daily_notes; diff --git a/src/components/editor/PublishNoteModal.tsx b/src/components/editor/PublishNoteModal.tsx index 684ff65..98443df 100644 --- a/src/components/editor/PublishNoteModal.tsx +++ b/src/components/editor/PublishNoteModal.tsx @@ -14,9 +14,11 @@ import { Button } from "@/components/ui/Button"; import { Input } from "@/components/ui/Input"; import { Modal } from "@/components/ui/Modal"; import { IpcError, ipc } from "@/lib/ipc"; +import { collectPublishBundle, type PublishBundle } from "@/lib/publishBundle"; import type { PublishedShare } from "@/lib/types"; import { noteTitle } from "@/lib/utils"; import { useUi } from "@/stores/ui"; +import { useVault } from "@/stores/vault"; type BusyAction = "publish" | "update" | "revoke" | null; @@ -33,23 +35,35 @@ export function PublishNoteModal({ }) { const [title, setTitle] = useState(noteTitle(rel)); const [share, setShare] = useState(null); + const [bundle, setBundle] = useState(null); const [account, setAccount] = useState<{ email: string; plan: "free" | "cloud" } | null>(null); const [outdated, setOutdated] = useState(false); const [loading, setLoading] = useState(true); const [busy, setBusy] = useState(null); const [error, setError] = useState<{ kind: string; message: string } | null>(null); const openSettings = useUi((state) => state.openSettings); + const tree = useVault((state) => state.tree); useEffect(() => { if (!open) return; let disposed = false; setLoading(true); + setBundle(null); setAccount(null); setError(null); - void ipc - .publishedNoteStatus(rel, markdown) - .then((status) => { + void collectPublishBundle(rel, markdown, tree) + .then(async (nextBundle) => { if (disposed) return; + setBundle(nextBundle); + return ipc.publishedNoteStatus( + rel, + noteTitle(rel), + nextBundle.markdown, + nextBundle.pages, + ); + }) + .then((status) => { + if (disposed || !status) return; setShare(status.share); notifyPublishStatus(rel, Boolean(status.share)); setAccount(status.account); @@ -69,7 +83,7 @@ export function PublishNoteModal({ return () => { disposed = true; }; - }, [open, rel, markdown]); + }, [open, rel, markdown, tree]); const run = async (action: Exclude) => { setBusy(action); @@ -83,10 +97,12 @@ export function PublishNoteModal({ toast("Note unpublished"); return; } + const nextBundle = await collectPublishBundle(rel, markdown, tree); + setBundle(nextBundle); const next = action === "publish" - ? await ipc.publishNote(rel, title.trim(), markdown) - : await ipc.updatePublishedNote(rel, title.trim(), markdown); + ? await ipc.publishNote(rel, title.trim(), nextBundle.markdown, nextBundle.pages) + : await ipc.updatePublishedNote(rel, title.trim(), nextBundle.markdown, nextBundle.pages); setShare(next); notifyPublishStatus(rel, true); setTitle(next.title); @@ -112,7 +128,7 @@ export function PublishNoteModal({ !busy && onClose()} - ariaLabel="Publish note on the web" + ariaLabel="Publish note site on the web" className="w-[480px]" >
@@ -154,11 +170,10 @@ export function PublishNoteModal({ Sign in to publish

- Public pages belong to your Markd account, so you can update or remove - them from any signed-in device. + Sign in to publish this note and its linked pages with Markd Cloud.

- Free accounts can publish one active note. + Publishing requires Markd Cloud.

{error && (
@@ -182,6 +197,31 @@ export function PublishNoteModal({
+ ) : account.plan !== "cloud" && !share ? ( +
+
+ +
+

+ Upgrade to publish +

+

+ Public sites, linked pages, and image hosting are included with Markd Cloud. +

+
+ + +
+
) : (
) : (
- Publishing uploads a separate snapshot. Future edits remain private until - you update the published version. + Publishing uploads this note + {bundle?.pages.length ? ` and ${bundle.pages.length} linked page${bundle.pages.length === 1 ? "" : "s"}` : ""} + {" "}as a separate snapshot. Future edits remain private until you + update the published version. {account && (

- {account.plan === "cloud" - ? `Signed in as ${account.email}` - : `Free account · one active public note · ${account.email}`} + {`Markd Cloud · ${account.email}`}

)}
@@ -278,17 +318,31 @@ export function PublishNoteModal({ > {busy === "revoke" ? "Unpublishing…" : "Unpublish"} - + {account.plan === "cloud" ? ( + + ) : ( + + )} ) : ( <> @@ -304,7 +358,7 @@ export function PublishNoteModal({ onClick={() => run("publish")} > {busy !== "publish" && } - {busy === "publish" ? "Publishing…" : "Publish note"} + {busy === "publish" ? "Publishing…" : "Publish site"} )} diff --git a/src/components/settings/SettingsPanels.tsx b/src/components/settings/SettingsPanels.tsx index 3a093ab..60c30dc 100644 --- a/src/components/settings/SettingsPanels.tsx +++ b/src/components/settings/SettingsPanels.tsx @@ -149,21 +149,21 @@ export function CloudSettings() {
diff --git a/src/lib/ipc.ts b/src/lib/ipc.ts index 82d61a6..165c27e 100644 --- a/src/lib/ipc.ts +++ b/src/lib/ipc.ts @@ -5,6 +5,7 @@ import type { CloudAccount, Bookmark, PublishedNoteStatus, + PublishPageDraft, PublishedShare, OtpChallenge, SearchHit, @@ -72,14 +73,26 @@ export const ipc = { cloudVerifyOtp: (challengeId: string, code: string) => call("cloud_verify_otp", { challengeId, code }), cloudSignOut: () => call("cloud_sign_out"), - publishedNoteStatus: (rel: string, content: string) => - call("published_note_status", { rel, content }), + publishedNoteStatus: ( + rel: string, + title: string, + content: string, + pages: PublishPageDraft[], + ) => call("published_note_status", { rel, title, content, pages }), isNotePublished: (rel: string) => call("is_note_published", { rel }), - publishNote: (rel: string, title: string, content: string) => - call("publish_note", { rel, title, content }), - updatePublishedNote: (rel: string, title: string, content: string) => - call("update_published_note", { rel, title, content }), + publishNote: ( + rel: string, + title: string, + content: string, + pages: PublishPageDraft[], + ) => call("publish_note", { rel, title, content, pages }), + updatePublishedNote: ( + rel: string, + title: string, + content: string, + pages: PublishPageDraft[], + ) => call("update_published_note", { rel, title, content, pages }), revokePublishedNote: (rel: string) => call("revoke_published_note", { rel }), pinsList: () => call("pins_list"), diff --git a/src/lib/publishBundle.ts b/src/lib/publishBundle.ts new file mode 100644 index 0000000..231f58f --- /dev/null +++ b/src/lib/publishBundle.ts @@ -0,0 +1,163 @@ +import { ipc } from "@/lib/ipc"; +import { noteTitle } from "@/lib/utils"; +import type { TreeNode } from "@/lib/types"; + +export interface PublishPageDraft { + rel: string; + path: string; + title: string; + markdown: string; +} + +export interface PublishBundle { + markdown: string; + pages: PublishPageDraft[]; +} + +const MARKDOWN_LINK_RE = + /(? { + const noteRels = noteRelMap(tree); + const contents = new Map([[rootRel, rootMarkdown]]); + const publicPaths = new Map([[rootRel, ""]]); + const queued = [rootRel]; + const usedPaths = new Set(); + + for (let index = 0; index < queued.length; index += 1) { + const rel = queued[index]; + const markdown = contents.get(rel) ?? ""; + for (const href of internalHrefs(markdown)) { + const target = resolveHref(href, rel, noteRels); + if (!target || publicPaths.has(target)) continue; + if (publicPaths.size >= MAX_PUBLISHED_PAGES) break; + const title = noteTitle(target); + publicPaths.set(target, uniquePath(slugify(title), usedPaths)); + contents.set(target, await ipc.readNote(target)); + queued.push(target); + } + } + + const pages: PublishPageDraft[] = []; + for (const rel of queued.slice(1)) { + pages.push({ + rel, + path: publicPaths.get(rel) ?? slugify(noteTitle(rel)), + title: noteTitle(rel), + markdown: rewriteLinks(contents.get(rel) ?? "", rel, noteRels, publicPaths), + }); + } + + return { + markdown: rewriteLinks(rootMarkdown, rootRel, noteRels, publicPaths), + pages, + }; +} + +function noteRelMap(tree: TreeNode[]) { + const rels = new Map(); + const walk = (nodes: TreeNode[]) => { + for (const node of nodes) { + if (node.kind === "note") rels.set(node.rel.toLowerCase(), node.rel); + if (node.children) walk(node.children); + } + }; + walk(tree); + return rels; +} + +function internalHrefs(markdown: string): string[] { + return Array.from(markdown.matchAll(MARKDOWN_LINK_RE), (match) => match[2]); +} + +function resolveHref( + href: string, + fromRel: string, + noteRels: Map, +): string | null { + const trimmed = href.trim(); + if ( + !trimmed || + trimmed.startsWith("#") || + trimmed.startsWith("markd-page:") || + /^[a-z][a-z0-9+.-]*:/i.test(trimmed) + ) { + return null; + } + + let path: string; + try { + path = decodeURIComponent(trimmed).split(/[?#]/)[0]; + } catch { + return null; + } + if (!path) return null; + if (path.startsWith("/")) { + path = path.replace(/^\/+/, ""); + } else if (path.startsWith(".")) { + const fromDir = fromRel.split("/").slice(0, -1); + path = [...fromDir, path].join("/"); + } + + const parts: string[] = []; + for (const part of path.split("/")) { + if (!part || part === ".") continue; + if (part === "..") { + if (!parts.length) return null; + parts.pop(); + } else { + parts.push(part); + } + } + const rel = parts.join("/"); + if (!rel) return null; + const withExt = /\.md$/i.test(rel) ? rel : `${rel}.md`; + return noteRels.get(withExt.toLowerCase()) ?? null; +} + +function rewriteLinks( + markdown: string, + fromRel: string, + noteRels: Map, + publicPaths: Map, +) { + return markdown.replace( + MARKDOWN_LINK_RE, + (match, label: string, href: string, title?: string) => { + const target = resolveHref(href, fromRel, noteRels); + if (!target || !publicPaths.has(target)) return match; + const path = publicPaths.get(target) ?? ""; + const publicHref = `markd-page:${path}`; + const suffix = title ? ` ${title}` : ""; + return `[${label}](${publicHref}${suffix})`; + }, + ); +} + +function slugify(value: string) { + return ( + value + .toLowerCase() + .normalize("NFKD") + .replace(/[\u0300-\u036f]/g, "") + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 60) || "page" + ); +} + +function uniquePath(base: string, used: Set) { + let path = base; + let index = 2; + while (used.has(path)) { + path = `${base}-${index}`; + index += 1; + } + used.add(path); + return path; +} diff --git a/src/lib/types.ts b/src/lib/types.ts index ce07dbf..db768ee 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -61,6 +61,15 @@ export interface PublishedShare { contentHash: string; publishedAt: number; updatedAt: number; + pageCount: number; + assetCount: number; +} + +export interface PublishPageDraft { + rel: string; + path: string; + title: string; + markdown: string; } export interface CloudAccount { @@ -83,7 +92,6 @@ export interface PublishedNoteStatus { account: CloudAccount | null; share: PublishedShare | null; isOutdated: boolean; - freeShareLimit: number; } export type View = From caa8cfe8fd2abcdc380143446e5a181a333a5c61 Mon Sep 17 00:00:00 2001 From: Saurabh Date: Sat, 18 Jul 2026 20:25:05 +0530 Subject: [PATCH 02/14] fix: preserve API custom domain --- services/cloud-api/wrangler.jsonc | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/services/cloud-api/wrangler.jsonc b/services/cloud-api/wrangler.jsonc index 280e911..b53ec26 100644 --- a/services/cloud-api/wrangler.jsonc +++ b/services/cloud-api/wrangler.jsonc @@ -4,6 +4,14 @@ "main": "src/index.ts", "compatibility_date": "2026-07-16", "workers_dev": false, + "preview_urls": false, + "routes": [ + { + "pattern": "api.usemarkd.app", + "zone_name": "usemarkd.app", + "custom_domain": true + } + ], "observability": { "enabled": true }, From 79bf9b2cd6524b7d2b6453637dff7fa69613fbe9 Mon Sep 17 00:00:00 2001 From: Saurabh Date: Sat, 18 Jul 2026 20:33:35 +0530 Subject: [PATCH 03/14] fix: refresh Cloud account plan --- src-tauri/src/cloud.rs | 42 +++++++++++++++++++++++++++++----- src-tauri/src/cloud_publish.rs | 4 ++-- src-tauri/src/commands.rs | 8 +++---- 3 files changed, 42 insertions(+), 12 deletions(-) diff --git a/src-tauri/src/cloud.rs b/src-tauri/src/cloud.rs index 2eb343c..e085f13 100644 --- a/src-tauri/src/cloud.rs +++ b/src-tauri/src/cloud.rs @@ -84,6 +84,11 @@ struct SessionResponse { user: CloudAccount, } +#[derive(Debug, Deserialize)] +struct AccountResponse { + user: CloudAccount, +} + fn now_millis() -> u64 { SystemTime::now() .duration_since(UNIX_EPOCH) @@ -142,9 +147,9 @@ fn clear_session(app: &tauri::AppHandle) -> AppResult<()> { } } -pub fn account_status(app: &tauri::AppHandle) -> AppResult { +pub async fn account_status(app: &tauri::AppHandle) -> AppResult { Ok(CloudAccountStatus { - account: load_session(app)?.map(|session| session.account), + account: refreshed_account(app).await?, }) } @@ -182,8 +187,33 @@ pub(crate) async fn cloud_error(response: reqwest::Response) -> AppError { } } -pub(crate) fn stored_account(app: &tauri::AppHandle) -> AppResult> { - Ok(load_session(app)?.map(|session| session.account)) +pub(crate) async fn refreshed_account( + app: &tauri::AppHandle, +) -> AppResult> { + let Some(mut session) = load_session(app)? else { + return Ok(None); + }; + let response = client()? + .get(format!("{API_BASE}/v1/me")) + .bearer_auth(&session.access_token) + .send() + .await + .map_err(|error| AppError::Network(error.to_string()))?; + if response.status() == StatusCode::UNAUTHORIZED { + clear_session(app)?; + return Ok(None); + } + if !response.status().is_success() { + return Err(cloud_error(response).await); + } + let account = response + .json::() + .await + .map_err(|error| AppError::Cloud(format!("invalid account response: {error}")))? + .user; + session.account = account.clone(); + save_session(app, &session)?; + Ok(Some(account)) } pub async fn request_otp(email: &str) -> AppResult { @@ -247,7 +277,7 @@ pub async fn sign_out(app: &tauri::AppHandle) -> AppResult<()> { Ok(()) } -pub fn status( +pub async fn status( app: &tauri::AppHandle, root: &Path, rel: &str, @@ -255,7 +285,7 @@ pub fn status( content: &str, pages: Vec, ) -> AppResult { - crate::cloud_publish::status(app, root, rel, title, content, pages) + crate::cloud_publish::status(app, root, rel, title, content, pages).await } pub async fn publish( diff --git a/src-tauri/src/cloud_publish.rs b/src-tauri/src/cloud_publish.rs index 92856b8..7cde978 100644 --- a/src-tauri/src/cloud_publish.rs +++ b/src-tauri/src/cloud_publish.rs @@ -91,7 +91,7 @@ struct SiteEnvelope { site: PublishedShare, } -pub fn status( +pub async fn status( app: &AppHandle, root: &Path, rel: &str, @@ -113,7 +113,7 @@ pub fn status( .unwrap_or(title); let local_hash = prepare_release(root, rel, release_title, content, pages)?.manifest_hash()?; Ok(PublishedNoteStatus { - account: cloud::stored_account(app)?, + account: cloud::refreshed_account(app).await?, is_outdated: share .as_ref() .is_some_and(|published| published.content_hash != local_hash), diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index cc903d8..6b368e0 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -232,8 +232,8 @@ pub fn backlinks_for(state: State<'_, AppState>, rel: String) -> AppResult AppResult { - cloud::account_status(&app) +pub async fn cloud_account_status(app: AppHandle) -> AppResult { + cloud::account_status(&app).await } #[tauri::command] @@ -256,7 +256,7 @@ pub async fn cloud_sign_out(app: AppHandle) -> AppResult<()> { } #[tauri::command] -pub fn published_note_status( +pub async fn published_note_status( app: AppHandle, state: State<'_, AppState>, rel: String, @@ -264,7 +264,7 @@ pub fn published_note_status( content: String, pages: Vec, ) -> AppResult { - cloud::status(&app, &state.root()?, &rel, &title, &content, pages) + cloud::status(&app, &state.root()?, &rel, &title, &content, pages).await } #[tauri::command] From df1f5081e5b25a799cf3b9549a9c36e44ba3caea Mon Sep 17 00:00:00 2001 From: Saurabh Date: Sat, 18 Jul 2026 20:37:34 +0530 Subject: [PATCH 04/14] fix: render published page links --- site/components/published/PublishedMarkdown.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/site/components/published/PublishedMarkdown.tsx b/site/components/published/PublishedMarkdown.tsx index 4f3c4cb..ca64af2 100644 --- a/site/components/published/PublishedMarkdown.tsx +++ b/site/components/published/PublishedMarkdown.tsx @@ -63,7 +63,8 @@ export function PublishedMarkdown({ remarkPlugins={[remarkGfm]} skipHtml urlTransform={(url) => - typeof url === "string" && url.startsWith("markd-asset:") + typeof url === "string" && + (url.startsWith("markd-asset:") || url.startsWith("markd-page:")) ? url : defaultUrlTransform(url) } From 24880323d440695a5b86472f6342dad2093a76c6 Mon Sep 17 00:00:00 2001 From: Saurabh Date: Sat, 18 Jul 2026 20:41:33 +0530 Subject: [PATCH 05/14] fix: match published Markdown styles --- site/app/globals.css | 71 +++++++++++++++++++++++++++++++++++++------- 1 file changed, 61 insertions(+), 10 deletions(-) diff --git a/site/app/globals.css b/site/app/globals.css index 3c6a90d..637d515 100644 --- a/site/app/globals.css +++ b/site/app/globals.css @@ -200,15 +200,38 @@ margin: 0.45em 0; } +.published-note strong { + font-weight: 650; +} + .published-note ul, .published-note ol { - margin: 0.55em 0; - padding-left: 1.35em; + margin: 0.45em 0; + padding-inline-start: 1.5em; +} + +.published-note ul { + list-style: disc; +} + +.published-note ol { + list-style: decimal; +} + +.published-note ul ul { + list-style: circle; +} + +.published-note ul ul ul { + list-style: square; } .published-note li { - margin: 0.25em 0; - padding-left: 0.1em; + margin: 0.15em 0; +} + +.published-note li > p { + margin: 0; } .published-note li::marker { @@ -232,7 +255,7 @@ border-left: 2px solid var(--fg); color: var(--fg-muted); margin: 0.8em 0; - padding-left: 1em; + padding-inline-start: 1em; } .published-note code { @@ -282,19 +305,47 @@ .published-note th, .published-note td { - border-bottom: 1px solid var(--border); - padding: 0.65rem 0.8rem; - text-align: left; + border: 1px solid var(--border); + min-width: 80px; + padding: 6px 10px; + text-align: start; } .published-note th { + background: var(--panel); color: var(--fg); - font-weight: 650; + font-weight: 600; +} + +.published-note ul.contains-task-list { + list-style: none; + padding-inline-start: 0; +} + +.published-note li.task-list-item { + padding-inline-start: 24px; + position: relative; +} + +.published-note li.task-list-item > input[type="checkbox"] { + height: 16px; + inset-inline-start: 0; + margin: 0; + position: absolute; + top: 5px; + width: 16px; } .published-note input[type="checkbox"] { accent-color: var(--fg); - margin-right: 0.5em; +} + +.published-note img { + border-radius: 8px; + display: block; + height: auto; + margin: 1em 0; + max-width: 100%; } .published-note__asset-placeholder { From 05503b56e4e0fd839d27205c8515a8edb8b77882 Mon Sep 17 00:00:00 2001 From: Saurabh Date: Sat, 18 Jul 2026 20:48:08 +0530 Subject: [PATCH 06/14] fix: purge expired auth records --- .../migrations/0004_auth_retention.sql | 1 + services/cloud-api/src/index.ts | 8 ++++++-- services/cloud-api/src/otp.ts | 20 +++++++++++++++++++ 3 files changed, 27 insertions(+), 2 deletions(-) create mode 100644 services/cloud-api/migrations/0004_auth_retention.sql diff --git a/services/cloud-api/migrations/0004_auth_retention.sql b/services/cloud-api/migrations/0004_auth_retention.sql new file mode 100644 index 0000000..7bdad64 --- /dev/null +++ b/services/cloud-api/migrations/0004_auth_retention.sql @@ -0,0 +1 @@ +CREATE INDEX sessions_expiry ON sessions(expires_at); diff --git a/services/cloud-api/src/index.ts b/services/cloud-api/src/index.ts index 3f9191b..b87c509 100644 --- a/services/cloud-api/src/index.ts +++ b/services/cloud-api/src/index.ts @@ -5,7 +5,7 @@ import { revokeSession, } from "./auth"; import { error, json, RequestBodyError } from "./http"; -import { OtpError, requestOtp, verifyOtp } from "./otp"; +import { cleanupExpiredAuthRecords, OtpError, requestOtp, verifyOtp } from "./otp"; import { beginPublish, cleanupExpiredPublishSessions, @@ -78,6 +78,10 @@ export default { } }, async scheduled(_controller: ScheduledController, env: Env, ctx: ExecutionContext) { - ctx.waitUntil(cleanupExpiredPublishSessions(env)); + ctx.waitUntil( + Promise.all([cleanupExpiredAuthRecords(env), cleanupExpiredPublishSessions(env)]).then( + () => undefined, + ), + ); }, } satisfies ExportedHandler; diff --git a/services/cloud-api/src/otp.ts b/services/cloud-api/src/otp.ts index 1923d1f..a0572e3 100644 --- a/services/cloud-api/src/otp.ts +++ b/services/cloud-api/src/otp.ts @@ -11,6 +11,7 @@ const MAX_EMAIL_REQUESTS_PER_HOUR = 5; const MAX_FINGERPRINT_REQUESTS_PER_HOUR = 20; const MAX_ATTEMPTS = 5; const MAX_AUTH_BODY_BYTES = 16 * 1_024; +const CLEANUP_BATCH_SIZE = 5_000; const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; const CHALLENGE_PATTERN = /^otp_[a-f0-9]{32}$/; @@ -230,3 +231,22 @@ export async function verifyOtp(request: Request, env: Env): Promise { user: { id: user.id, email: user.email, plan: user.plan ?? "free" }, }); } + +export async function cleanupExpiredAuthRecords(env: Env): Promise { + const now = Date.now(); + const challengeCutoff = now - RATE_WINDOW_MS; + await env.DB.batch([ + env.DB.prepare( + `DELETE FROM otp_challenges WHERE id IN ( + SELECT id FROM otp_challenges + WHERE expires_at < ? ORDER BY expires_at LIMIT ? + )`, + ).bind(challengeCutoff, CLEANUP_BATCH_SIZE), + env.DB.prepare( + `DELETE FROM sessions WHERE id IN ( + SELECT id FROM sessions + WHERE expires_at <= ? ORDER BY expires_at LIMIT ? + )`, + ).bind(now, CLEANUP_BATCH_SIZE), + ]); +} From 82dd0202c0c15881e2e301bcc086d5115394ec58 Mon Sep 17 00:00:00 2001 From: Saurabh Date: Sat, 18 Jul 2026 21:01:10 +0530 Subject: [PATCH 07/14] feat: separate OTP rate limiting --- .../migrations/0005_otp_rate_events.sql | 37 +++++++++ services/cloud-api/src/crypto.ts | 2 +- services/cloud-api/src/otp.ts | 83 ++++++++++++++----- services/cloud-api/src/types.ts | 2 + services/cloud-api/wrangler.jsonc | 12 +++ 5 files changed, 113 insertions(+), 23 deletions(-) create mode 100644 services/cloud-api/migrations/0005_otp_rate_events.sql diff --git a/services/cloud-api/migrations/0005_otp_rate_events.sql b/services/cloud-api/migrations/0005_otp_rate_events.sql new file mode 100644 index 0000000..b4e5391 --- /dev/null +++ b/services/cloud-api/migrations/0005_otp_rate_events.sql @@ -0,0 +1,37 @@ +CREATE TABLE otp_rate_events ( + id TEXT PRIMARY KEY, + email_fingerprint TEXT NOT NULL, + request_fingerprint TEXT NOT NULL, + created_at INTEGER NOT NULL +) STRICT; + +CREATE INDEX otp_rate_events_email_created + ON otp_rate_events(email_fingerprint, created_at DESC); + +CREATE INDEX otp_rate_events_request_created + ON otp_rate_events(request_fingerprint, created_at DESC); + +CREATE INDEX otp_rate_events_created + ON otp_rate_events(created_at); + +CREATE TRIGGER otp_rate_events_email_limit +BEFORE INSERT ON otp_rate_events +WHEN ( + SELECT COUNT(*) FROM otp_rate_events + WHERE email_fingerprint = NEW.email_fingerprint + AND created_at >= NEW.created_at - 3600000 +) >= 5 +BEGIN + SELECT RAISE(ABORT, 'otp_email_rate_limit'); +END; + +CREATE TRIGGER otp_rate_events_request_limit +BEFORE INSERT ON otp_rate_events +WHEN ( + SELECT COUNT(*) FROM otp_rate_events + WHERE request_fingerprint = NEW.request_fingerprint + AND created_at >= NEW.created_at - 3600000 +) >= 20 +BEGIN + SELECT RAISE(ABORT, 'otp_request_rate_limit'); +END; diff --git a/services/cloud-api/src/crypto.ts b/services/cloud-api/src/crypto.ts index 7e28ec0..6c5a506 100644 --- a/services/cloud-api/src/crypto.ts +++ b/services/cloud-api/src/crypto.ts @@ -72,7 +72,7 @@ export function randomSlug(): string { } export function newId( - prefix: "site" | "release" | "publish" | "otp" | "session" | "user", + prefix: "site" | "release" | "publish" | "otp" | "rate" | "session" | "user", ): string { return `${prefix}_${crypto.randomUUID().replaceAll("-", "")}`; } diff --git a/services/cloud-api/src/otp.ts b/services/cloud-api/src/otp.ts index a0572e3..5b5748b 100644 --- a/services/cloud-api/src/otp.ts +++ b/services/cloud-api/src/otp.ts @@ -64,10 +64,22 @@ function requestFingerprint(request: Request): string { } async function otpHash(env: Env, challengeId: string, code: string): Promise { + return hmacSha256(otpPepper(env), `${challengeId}:${code}`); +} + +function otpPepper(env: Env): string { if (!env.OTP_PEPPER || env.OTP_PEPPER.length < 32) { throw new Error("OTP_PEPPER must be configured as a Worker secret"); } - return hmacSha256(env.OTP_PEPPER, `${challengeId}:${code}`); + return env.OTP_PEPPER; +} + +function rateLimited(): OtpError { + return new OtpError( + 429, + "otp_rate_limited", + "Too many sign-in codes were requested. Try again later.", + ); } function challengeResponse(challenge: ChallengeRow, now: number): Response { @@ -86,7 +98,13 @@ export async function requestOtp(request: Request, env: Env): Promise const body = (await readJson(request, MAX_AUTH_BODY_BYTES)) as Record; const email = normalizeEmail(body?.email); const now = Date.now(); - const fingerprint = await hmacSha256(env.OTP_PEPPER, requestFingerprint(request)); + const pepper = otpPepper(env); + const [emailFingerprint, fingerprint] = await Promise.all([ + hmacSha256(pepper, `email:${email}`), + hmacSha256(pepper, `ip:${requestFingerprint(request)}`), + ]); + const ipLimit = await env.OTP_IP_RATE_LIMITER.limit({ key: fingerprint }); + if (!ipLimit.success) throw rateLimited(); const latest = await env.DB.prepare( `SELECT id, email, code_hash, attempts, expires_at, consumed_at, created_at @@ -100,15 +118,20 @@ export async function requestOtp(request: Request, env: Env): Promise return challengeResponse(latest, now); } + const emailLimit = await env.OTP_EMAIL_RATE_LIMITER.limit({ key: emailFingerprint }); + if (!emailLimit.success) throw rateLimited(); + const windowStart = now - RATE_WINDOW_MS; const [emailCount, fingerprintCount] = await Promise.all([ env.DB.prepare( - "SELECT COUNT(*) AS count FROM otp_challenges WHERE email = ? AND created_at >= ?", + `SELECT COUNT(*) AS count FROM otp_rate_events + WHERE email_fingerprint = ? AND created_at >= ?`, ) - .bind(email, windowStart) + .bind(emailFingerprint, windowStart) .first(), env.DB.prepare( - "SELECT COUNT(*) AS count FROM otp_challenges WHERE request_fingerprint = ? AND created_at >= ?", + `SELECT COUNT(*) AS count FROM otp_rate_events + WHERE request_fingerprint = ? AND created_at >= ?`, ) .bind(fingerprint, windowStart) .first(), @@ -117,14 +140,11 @@ export async function requestOtp(request: Request, env: Env): Promise (emailCount?.count ?? 0) >= MAX_EMAIL_REQUESTS_PER_HOUR || (fingerprintCount?.count ?? 0) >= MAX_FINGERPRINT_REQUESTS_PER_HOUR ) { - throw new OtpError( - 429, - "otp_rate_limited", - "Too many sign-in codes were requested. Try again later.", - ); + throw rateLimited(); } const id = newId("otp"); + const rateEventId = newId("rate"); const code = randomOtp(); const challenge: ChallengeRow = { id, @@ -135,13 +155,26 @@ export async function requestOtp(request: Request, env: Env): Promise consumed_at: null, created_at: now, }; - await env.DB.prepare( - `INSERT INTO otp_challenges ( - id, email, code_hash, request_fingerprint, attempts, expires_at, created_at - ) VALUES (?, ?, ?, ?, 0, ?, ?)`, - ) - .bind(id, email, challenge.code_hash, fingerprint, challenge.expires_at, now) - .run(); + try { + await env.DB.batch([ + env.DB.prepare( + `INSERT INTO otp_challenges ( + id, email, code_hash, request_fingerprint, attempts, expires_at, created_at + ) VALUES (?, ?, ?, ?, 0, ?, ?)`, + ).bind(id, email, challenge.code_hash, fingerprint, challenge.expires_at, now), + env.DB.prepare( + `INSERT INTO otp_rate_events ( + id, email_fingerprint, request_fingerprint, created_at + ) VALUES (?, ?, ?, ?)`, + ).bind(rateEventId, emailFingerprint, fingerprint, now), + ]); + } catch (cause) { + const message = cause instanceof Error ? cause.message : String(cause); + if (message.includes("otp_email_rate_limit") || message.includes("otp_request_rate_limit")) { + throw rateLimited(); + } + throw cause; + } try { await sendOtpEmail(env, email, code); @@ -212,10 +245,10 @@ export async function verifyOtp(request: Request, env: Env): Promise { const sessionId = newId("session"); const expiresAt = now + SESSION_TTL_MS; const consumed = await env.DB.prepare( - `UPDATE otp_challenges SET consumed_at = ? + `DELETE FROM otp_challenges WHERE id = ? AND consumed_at IS NULL AND expires_at > ? AND attempts < ?`, ) - .bind(now, challengeId, now, MAX_ATTEMPTS) + .bind(challengeId, now, MAX_ATTEMPTS) .run(); if (consumed.meta.changes !== 1) throw invalidOtp(); await env.DB.prepare( @@ -234,14 +267,20 @@ export async function verifyOtp(request: Request, env: Env): Promise { export async function cleanupExpiredAuthRecords(env: Env): Promise { const now = Date.now(); - const challengeCutoff = now - RATE_WINDOW_MS; + const rateCutoff = now - RATE_WINDOW_MS; await env.DB.batch([ env.DB.prepare( `DELETE FROM otp_challenges WHERE id IN ( SELECT id FROM otp_challenges - WHERE expires_at < ? ORDER BY expires_at LIMIT ? + WHERE expires_at <= ? ORDER BY expires_at LIMIT ? + )`, + ).bind(now, CLEANUP_BATCH_SIZE), + env.DB.prepare( + `DELETE FROM otp_rate_events WHERE id IN ( + SELECT id FROM otp_rate_events + WHERE created_at < ? ORDER BY created_at LIMIT ? )`, - ).bind(challengeCutoff, CLEANUP_BATCH_SIZE), + ).bind(rateCutoff, CLEANUP_BATCH_SIZE), env.DB.prepare( `DELETE FROM sessions WHERE id IN ( SELECT id FROM sessions diff --git a/services/cloud-api/src/types.ts b/services/cloud-api/src/types.ts index 0c617aa..e9db8a3 100644 --- a/services/cloud-api/src/types.ts +++ b/services/cloud-api/src/types.ts @@ -3,6 +3,8 @@ export interface Env { PUBLISHED_NOTES: R2Bucket; IMAGES: ImagesBinding; EMAIL: SendEmail; + OTP_IP_RATE_LIMITER: RateLimit; + OTP_EMAIL_RATE_LIMITER: RateLimit; PUBLIC_SITE_ORIGIN: string; PUBLIC_API_ORIGIN: string; OTP_PEPPER: string; diff --git a/services/cloud-api/wrangler.jsonc b/services/cloud-api/wrangler.jsonc index b53ec26..606f34c 100644 --- a/services/cloud-api/wrangler.jsonc +++ b/services/cloud-api/wrangler.jsonc @@ -18,6 +18,18 @@ "triggers": { "crons": ["0 * * * *"] }, + "ratelimits": [ + { + "name": "OTP_IP_RATE_LIMITER", + "namespace_id": "41001", + "simple": { "limit": 20, "period": 60 } + }, + { + "name": "OTP_EMAIL_RATE_LIMITER", + "namespace_id": "41002", + "simple": { "limit": 2, "period": 60 } + } + ], "vars": { "PUBLIC_SITE_ORIGIN": "https://usemarkd.app", "PUBLIC_API_ORIGIN": "https://api.usemarkd.app", From b7363a0c0347ed6294a8dab8470ef920878edd92 Mon Sep 17 00:00:00 2001 From: Saurabh Date: Sat, 18 Jul 2026 21:28:33 +0530 Subject: [PATCH 08/14] feat: simplify Cloud subscription UI --- site/app/login/page.tsx | 33 +++++-- src/components/editor/PublishNoteModal.tsx | 15 +-- src/components/settings/CloudAccountCard.tsx | 4 +- src/components/settings/SettingsPanels.tsx | 98 +++++++------------- src/lib/cloud.ts | 1 + 5 files changed, 70 insertions(+), 81 deletions(-) create mode 100644 src/lib/cloud.ts diff --git a/site/app/login/page.tsx b/site/app/login/page.tsx index 7c9b4ae..4fdd26d 100644 --- a/site/app/login/page.tsx +++ b/site/app/login/page.tsx @@ -3,22 +3,37 @@ import Link from "next/link"; export default function LoginPage() { return (
-
+

- Markd account + Markd Cloud

- Cloud publishing is coming next + Publish connected notes on the web

- Email sign-in and publishing are not available in the current Markd - release. They’ll be included in the next app version. + One subscription includes linked pages, hosted images, and future + cross-device sync. Choose your billing interval during checkout.

-
- Once the update is live, open Markd Settings and choose Markd Cloud to - sign in with a six-digit email code. Free accounts will include one active - public note. +
+
+
+

Yearly

+ + Save 25% + +
+

$72/year

+

$6 per month, billed yearly

+
+
+

Monthly

+

$8/month

+

Flexible monthly billing

+
+

+ Checkout is not open yet. Until billing launches, Cloud access is managed manually. +

- Upgrade to publish + Markd Cloud required

Public sites, linked pages, and image hosting are included with Markd Cloud. @@ -215,10 +216,10 @@ export function PublishNoteModal({ size="sm" onClick={() => { onClose(); - openSettings("cloud"); + openUrl(MARKD_CLOUD_PLANS_URL); }} > - Upgrade + View plans @@ -296,10 +297,10 @@ export function PublishNoteModal({ size="sm" onClick={() => { onClose(); - openSettings("cloud"); + openUrl(MARKD_CLOUD_PLANS_URL); }} > - Upgrade + View plans )} @@ -337,10 +338,10 @@ export function PublishNoteModal({ className="ml-auto" onClick={() => { onClose(); - openSettings("cloud"); + openUrl(MARKD_CLOUD_PLANS_URL); }} > - Upgrade to update + View plans )} diff --git a/src/components/settings/CloudAccountCard.tsx b/src/components/settings/CloudAccountCard.tsx index 625b1a9..507ae9f 100644 --- a/src/components/settings/CloudAccountCard.tsx +++ b/src/components/settings/CloudAccountCard.tsx @@ -142,9 +142,9 @@ export function CloudAccountCard({

{loading ? "Checking account…" : (account?.email ?? "Not signed in")}

- {account && ( + {account?.plan === "cloud" && ( - {account.plan === "cloud" ? "Cloud" : "Free"} + Cloud )} diff --git a/src/components/settings/SettingsPanels.tsx b/src/components/settings/SettingsPanels.tsx index 60c30dc..0415709 100644 --- a/src/components/settings/SettingsPanels.tsx +++ b/src/components/settings/SettingsPanels.tsx @@ -1,5 +1,5 @@ +import { openUrl } from "@tauri-apps/plugin-opener"; import { - Check, FolderOpen, Globe2, Monitor, @@ -22,6 +22,7 @@ import { import { cx, isMac } from "@/lib/utils"; import { Button } from "@/components/ui/Button"; import { CloudAccountCard } from "@/components/settings/CloudAccountCard"; +import { MARKD_CLOUD_PLANS_URL } from "@/lib/cloud"; import { useShortcuts } from "@/stores/shortcuts"; import { useUpdater } from "@/stores/updater"; import { useVault } from "@/stores/vault"; @@ -148,28 +149,41 @@ export function CloudSettings() { -
- - +
+ + + +
+
+

Markd Cloud

+ {account?.plan === "cloud" && ( + + Active + + )} +
+

+ {account?.plan === "cloud" + ? "Publishing is active for this account." + : "Publish connected notes and hosted images on the web."} +

+
+ {account?.plan !== "cloud" && ( + + )}
-

- - Publishing is being built first. Sync will follow. +

+ Billing and subscription changes are managed on the web.

@@ -380,48 +394,6 @@ function SettingsGroup({ ); } -function PlanCard({ - name, - price, - description, - features, - active, -}: { - name: string; - price: string; - description: string; - features: string[]; - active: boolean; -}) { - return ( -
- {active && ( - - Current - - )} -

{name}

-

- {price} -

-

{description}

-
    - {features.map((feature) => ( -
  • - - {feature} -
  • - ))} -
-
- ); -} - function Kbd({ children }: { children: React.ReactNode }) { return ( diff --git a/src/lib/cloud.ts b/src/lib/cloud.ts new file mode 100644 index 0000000..f9ed0c4 --- /dev/null +++ b/src/lib/cloud.ts @@ -0,0 +1 @@ +export const MARKD_CLOUD_PLANS_URL = "https://usemarkd.app/login?intent=upgrade"; From ba97f6350c5f0e2d5fbb869382d05c23ab346de2 Mon Sep 17 00:00:00 2001 From: Saurabh Date: Sat, 18 Jul 2026 21:36:32 +0530 Subject: [PATCH 09/14] feat: add colorful Cloud pricing UI --- services/cloud-api/src/publishing.ts | 2 +- site/app/login/page.tsx | 46 ---- site/app/pricing/page.tsx | 20 ++ site/components/Nav.tsx | 1 + site/components/pricing/PricingExperience.tsx | 212 ++++++++++++++++++ src/components/editor/PublishNoteModal.tsx | 11 +- src/components/settings/CloudAccountCard.tsx | 5 +- src/components/settings/SettingsPanels.tsx | 5 +- src/components/ui/StatusBadge.tsx | 35 +++ src/lib/cloud.ts | 2 +- src/styles.css | 43 +++- 11 files changed, 313 insertions(+), 69 deletions(-) delete mode 100644 site/app/login/page.tsx create mode 100644 site/app/pricing/page.tsx create mode 100644 site/components/pricing/PricingExperience.tsx create mode 100644 src/components/ui/StatusBadge.tsx diff --git a/services/cloud-api/src/publishing.ts b/services/cloud-api/src/publishing.ts index f94c3c6..4bf3021 100644 --- a/services/cloud-api/src/publishing.ts +++ b/services/cloud-api/src/publishing.ts @@ -519,7 +519,7 @@ function siteResponse(env: Env, site: SiteRow, release: ReleaseRow): SiteRespons function requirePaid(plan: string, env: Env): void { if (plan === "cloud") return; - throw new PaidPublishingError(`${env.PUBLIC_SITE_ORIGIN.replace(/\/$/, "")}/login?intent=upgrade`); + throw new PaidPublishingError(`${env.PUBLIC_SITE_ORIGIN.replace(/\/$/, "")}/pricing`); } export class PaidPublishingError extends Error { diff --git a/site/app/login/page.tsx b/site/app/login/page.tsx deleted file mode 100644 index 4fdd26d..0000000 --- a/site/app/login/page.tsx +++ /dev/null @@ -1,46 +0,0 @@ -import Link from "next/link"; - -export default function LoginPage() { - return ( -
-
-

- Markd Cloud -

-

- Publish connected notes on the web -

-

- One subscription includes linked pages, hosted images, and future - cross-device sync. Choose your billing interval during checkout. -

-
-
-
-

Yearly

- - Save 25% - -
-

$72/year

-

$6 per month, billed yearly

-
-
-

Monthly

-

$8/month

-

Flexible monthly billing

-
-
-

- Checkout is not open yet. Until billing launches, Cloud access is managed manually. -

- - Back to Markd - -
-
- ); -} diff --git a/site/app/pricing/page.tsx b/site/app/pricing/page.tsx new file mode 100644 index 0000000..51f9036 --- /dev/null +++ b/site/app/pricing/page.tsx @@ -0,0 +1,20 @@ +import type { Metadata } from "next"; +import { Footer } from "@/components/Footer"; +import { Nav } from "@/components/Nav"; +import { PricingExperience } from "@/components/pricing/PricingExperience"; + +export const metadata: Metadata = { + title: "Markd Cloud pricing — connected notes on the web", + description: + "Publish linked notes and hosted images with Markd Cloud. Choose yearly or monthly billing.", +}; + +export default function PricingPage() { + return ( + <> +