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/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/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/migrations/0006_dodo_billing.sql b/services/cloud-api/migrations/0006_dodo_billing.sql new file mode 100644 index 0000000..a1f7824 --- /dev/null +++ b/services/cloud-api/migrations/0006_dodo_billing.sql @@ -0,0 +1,33 @@ +PRAGMA foreign_keys = ON; + +ALTER TABLE entitlements ADD COLUMN provider TEXT; +ALTER TABLE entitlements ADD COLUMN provider_customer_id TEXT; +ALTER TABLE entitlements ADD COLUMN provider_subscription_id TEXT; +ALTER TABLE entitlements ADD COLUMN billing_interval TEXT; +ALTER TABLE entitlements ADD COLUMN provider_status TEXT; +ALTER TABLE entitlements ADD COLUMN provider_updated_at INTEGER NOT NULL DEFAULT 0; + +CREATE UNIQUE INDEX entitlements_provider_customer + ON entitlements(provider, provider_customer_id) + WHERE provider_customer_id IS NOT NULL; + +CREATE UNIQUE INDEX entitlements_provider_subscription + ON entitlements(provider, provider_subscription_id) + WHERE provider_subscription_id IS NOT NULL; + +CREATE TABLE billing_handoffs ( + token_hash TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + expires_at INTEGER NOT NULL, + used_at INTEGER, + created_at INTEGER NOT NULL +) STRICT; + +CREATE INDEX billing_handoffs_expiry ON billing_handoffs(expires_at); + +CREATE TABLE billing_webhooks ( + id TEXT PRIMARY KEY, + event_type TEXT NOT NULL, + processed_at INTEGER NOT NULL +) STRICT; + diff --git a/services/cloud-api/src/auth.ts b/services/cloud-api/src/auth.ts index 06d3409..6d55908 100644 --- a/services/cloud-api/src/auth.ts +++ b/services/cloud-api/src/auth.ts @@ -29,9 +29,10 @@ export async function authenticatedUser( JOIN users ON users.id = sessions.user_id LEFT JOIN entitlements ON entitlements.user_id = users.id AND entitlements.status IN ('active', 'trialing') + AND (entitlements.current_period_end IS NULL OR entitlements.current_period_end > ?) WHERE sessions.token_hash = ? AND sessions.expires_at > ?`, ) - .bind(await sha256(token), Date.now()) + .bind(Date.now(), await sha256(token), Date.now()) .first(); if (!row) throw new AuthenticationError(); diff --git a/services/cloud-api/src/billing.ts b/services/cloud-api/src/billing.ts new file mode 100644 index 0000000..3c29511 --- /dev/null +++ b/services/cloud-api/src/billing.ts @@ -0,0 +1,226 @@ +import { authenticatedUser } from "./auth"; +import { randomToken, sha256 } from "./crypto"; +import { + createCheckoutSession, + createPortalSession, + DodoApiError, + DodoWebhookError, + type DodoSubscription, + verifyDodoWebhook, +} from "./dodo"; +import { json, readJson } from "./http"; +import type { AuthenticatedUser, BillingInterval, Env } from "./types"; + +const HANDOFF_TTL_MS = 15 * 60 * 1000; +const SUBSCRIPTION_EVENTS = new Set([ + "subscription.active", + "subscription.updated", + "subscription.on_hold", + "subscription.renewed", + "subscription.plan_changed", + "subscription.cancelled", + "subscription.failed", + "subscription.expired", +]); + +interface HandoffRow { + user_id: string; + email: string; +} + +interface EntitlementLookup { + user_id: string; +} + +export class BillingError extends Error { + constructor( + readonly status: number, + readonly code: string, + message: string, + ) { + super(message); + } +} + +export async function createBillingHandoff(request: Request, env: Env): Promise { + const user = await authenticatedUser(request, env); + const token = randomToken(); + const now = Date.now(); + const expiresAt = now + HANDOFF_TTL_MS; + await env.DB.prepare( + `INSERT INTO billing_handoffs (token_hash, user_id, expires_at, created_at) + VALUES (?, ?, ?, ?)`, + ).bind(await sha256(token), user.id, expiresAt, now).run(); + const pricingUrl = new URL("/pricing", env.PUBLIC_SITE_ORIGIN); + pricingUrl.searchParams.set("billing_token", token); + return json({ url: pricingUrl.toString(), expiresAt }); +} + +export async function beginCheckout(request: Request, env: Env): Promise { + const input = checkoutInput(await readJson(request, 4_096)); + const tokenHash = await sha256(input.token); + const usedAt = Date.now(); + const handoff = await env.DB.prepare( + `UPDATE billing_handoffs SET used_at = ? + WHERE token_hash = ? AND used_at IS NULL AND expires_at > ? + RETURNING user_id, (SELECT email FROM users WHERE id = user_id) AS email`, + ).bind(usedAt, tokenHash, usedAt).first(); + if (!handoff) { + throw new BillingError(401, "billing_link_expired", "Open pricing from Markd again to continue."); + } + + const user: AuthenticatedUser = { id: handoff.user_id, email: handoff.email, plan: "free" }; + try { + return json({ checkoutUrl: await createCheckoutSession(env, user, input.interval) }); + } catch (cause) { + await env.DB.prepare( + "UPDATE billing_handoffs SET used_at = NULL WHERE token_hash = ? AND used_at = ?", + ).bind(tokenHash, usedAt).run(); + if (cause instanceof DodoApiError) throw new BillingError(502, "checkout_unavailable", cause.message); + throw cause; + } +} + +export async function billingPortal(request: Request, env: Env): Promise { + const user = await authenticatedUser(request, env); + const row = await env.DB.prepare( + "SELECT provider_customer_id FROM entitlements WHERE user_id = ? AND provider = 'dodo'", + ).bind(user.id).first<{ provider_customer_id: string | null }>(); + if (!row?.provider_customer_id) { + throw new BillingError(404, "billing_customer_missing", "No billing account exists for this user."); + } + try { + return json({ url: await createPortalSession(env, row.provider_customer_id) }); + } catch (cause) { + if (cause instanceof DodoApiError) throw new BillingError(502, "portal_unavailable", cause.message); + throw cause; + } +} + +export async function dodoWebhook(request: Request, env: Env): Promise { + const rawBody = await request.text(); + let verified; + try { + verified = await verifyDodoWebhook(rawBody, request.headers, env.DODO_PAYMENTS_WEBHOOK_KEY); + } catch (cause) { + if (cause instanceof DodoWebhookError) throw new BillingError(400, "invalid_webhook", cause.message); + throw cause; + } + if (verified.payload.business_id !== env.DODO_BUSINESS_ID) { + throw new BillingError(400, "invalid_webhook", "The webhook business does not match Markd."); + } + + const duplicate = await env.DB.prepare("SELECT 1 FROM billing_webhooks WHERE id = ?") + .bind(verified.id).first(); + if (duplicate) return json({ received: true, duplicate: true }); + + if (SUBSCRIPTION_EVENTS.has(verified.payload.type)) { + await syncSubscription(env, verified.payload.type, verified.payload.timestamp, verified.payload.data); + } + await env.DB.prepare( + "INSERT OR IGNORE INTO billing_webhooks (id, event_type, processed_at) VALUES (?, ?, ?)", + ).bind(verified.id, verified.payload.type, Date.now()).run(); + return json({ received: true }); +} + +export async function cleanupBillingRecords(env: Env): Promise { + const now = Date.now(); + await env.DB.batch([ + env.DB.prepare("DELETE FROM billing_handoffs WHERE expires_at <= ?").bind(now), + env.DB.prepare("DELETE FROM billing_webhooks WHERE processed_at <= ?") + .bind(now - 90 * 24 * 60 * 60 * 1000), + ]); +} + +async function syncSubscription( + env: Env, + eventType: string, + eventTimestamp: string, + subscription: DodoSubscription, +): Promise { + if (!subscription.subscription_id || !subscription.product_id) return; + const interval = productInterval(env, subscription.product_id); + if (!interval) return; + const userId = await subscriptionUserId(env, subscription); + if (!userId) throw new BillingError(409, "billing_user_missing", "The subscription has no Markd user."); + + const providerUpdatedAt = Date.parse(eventTimestamp); + const periodEnd = subscription.next_billing_date + ? Date.parse(subscription.next_billing_date) + : null; + const status = entitlementStatus(eventType, subscription.status, periodEnd); + await env.DB.prepare( + `INSERT INTO entitlements ( + user_id, plan, status, current_period_end, provider, provider_customer_id, + provider_subscription_id, billing_interval, provider_status, provider_updated_at + ) VALUES (?, 'cloud', ?, ?, 'dodo', ?, ?, ?, ?, ?) + ON CONFLICT(user_id) DO UPDATE SET + plan = 'cloud', status = excluded.status, + current_period_end = excluded.current_period_end, provider = 'dodo', + provider_customer_id = excluded.provider_customer_id, + provider_subscription_id = excluded.provider_subscription_id, + billing_interval = excluded.billing_interval, + provider_status = excluded.provider_status, + provider_updated_at = excluded.provider_updated_at + WHERE excluded.provider_updated_at >= entitlements.provider_updated_at`, + ).bind( + userId, + status, + Number.isFinite(periodEnd) ? periodEnd : null, + subscription.customer?.customer_id ?? null, + subscription.subscription_id, + interval, + subscription.status ?? eventType.replace("subscription.", ""), + Number.isFinite(providerUpdatedAt) ? providerUpdatedAt : Date.now(), + ).run(); +} + +async function subscriptionUserId(env: Env, subscription: DodoSubscription): Promise { + const metadataUser = subscription.metadata?.markd_user_id; + if (metadataUser) { + const user = await env.DB.prepare("SELECT id FROM users WHERE id = ?") + .bind(metadataUser).first<{ id: string }>(); + if (user) return user.id; + } + const customerId = subscription.customer?.customer_id; + if (customerId) { + const entitlement = await env.DB.prepare( + "SELECT user_id FROM entitlements WHERE provider = 'dodo' AND provider_customer_id = ?", + ).bind(customerId).first(); + if (entitlement) return entitlement.user_id; + } + const email = subscription.customer?.email?.trim().toLowerCase(); + if (!email) return null; + const user = await env.DB.prepare("SELECT id AS user_id FROM users WHERE email = ?") + .bind(email).first(); + return user?.user_id ?? null; +} + +function checkoutInput(value: unknown): { token: string; interval: BillingInterval } { + if (!value || typeof value !== "object") throw new BillingError(400, "invalid_checkout", "Checkout details are required."); + const input = value as Record; + if (typeof input.token !== "string" || !/^[A-Za-z0-9_-]{43}$/.test(input.token)) { + throw new BillingError(400, "invalid_checkout", "The billing link is invalid."); + } + if (input.interval !== "monthly" && input.interval !== "yearly") { + throw new BillingError(400, "invalid_checkout", "Choose monthly or yearly billing."); + } + return { token: input.token, interval: input.interval }; +} + +function productInterval(env: Env, productId: string): BillingInterval | null { + if (productId === env.DODO_MONTHLY_PRODUCT_ID) return "monthly"; + if (productId === env.DODO_YEARLY_PRODUCT_ID) return "yearly"; + return null; +} + +export function entitlementStatus( + eventType: string, + providerStatus: string | undefined, + periodEnd: number | null, +): "active" | "past_due" | "canceled" { + if (providerStatus === "active" || eventType === "subscription.active" || eventType === "subscription.renewed") return "active"; + if (providerStatus === "on_hold" || eventType === "subscription.on_hold") return "past_due"; + if (providerStatus === "cancelled" && periodEnd && periodEnd > Date.now()) return "active"; + return "canceled"; +} diff --git a/services/cloud-api/src/crypto.ts b/services/cloud-api/src/crypto.ts index 2c50529..6c5a506 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" | "rate" | "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/dodo.ts b/services/cloud-api/src/dodo.ts new file mode 100644 index 0000000..8e90ed1 --- /dev/null +++ b/services/cloud-api/src/dodo.ts @@ -0,0 +1,154 @@ +import { hmacBytes } from "./crypto"; +import type { AuthenticatedUser, BillingInterval, Env } from "./types"; + +const WEBHOOK_TOLERANCE_SECONDS = 5 * 60; + +export class DodoApiError extends Error {} +export class DodoWebhookError extends Error {} + +export interface DodoWebhookPayload { + business_id: string; + type: string; + timestamp: string; + data: DodoSubscription; +} + +export interface DodoSubscription { + payload_type?: string; + subscription_id?: string; + product_id?: string; + status?: string; + next_billing_date?: string | null; + cancel_at_next_billing_date?: boolean; + metadata?: Record; + customer?: { + customer_id?: string; + email?: string; + }; +} + +function apiBase(env: Env): string { + return env.DODO_PAYMENTS_ENVIRONMENT === "test_mode" + ? "https://test.dodopayments.com" + : "https://live.dodopayments.com"; +} + +async function dodoRequest( + env: Env, + path: string, + init: RequestInit, +): Promise { + const response = await fetch(`${apiBase(env)}${path}`, { + ...init, + headers: { + authorization: `Bearer ${env.DODO_PAYMENTS_API_KEY}`, + "content-type": "application/json", + ...init.headers, + }, + }); + if (!response.ok) { + console.error("Dodo API request failed", response.status); + throw new DodoApiError("The billing provider could not complete the request."); + } + return response.json(); +} + +export async function createCheckoutSession( + env: Env, + user: AuthenticatedUser, + interval: BillingInterval, +): Promise { + const productId = interval === "yearly" + ? env.DODO_YEARLY_PRODUCT_ID + : env.DODO_MONTHLY_PRODUCT_ID; + if (!productId) throw new DodoApiError("The selected billing plan is not configured."); + + const origin = env.PUBLIC_SITE_ORIGIN.replace(/\/$/, ""); + const result = await dodoRequest<{ checkout_url: string | null }>(env, "/checkouts", { + method: "POST", + body: JSON.stringify({ + product_cart: [{ product_id: productId, quantity: 1 }], + customer: { email: user.email, name: user.email.split("@")[0] }, + metadata: { + markd_user_id: user.id, + markd_billing_interval: interval, + }, + return_url: `${origin}/pricing?checkout=success`, + cancel_url: `${origin}/pricing?checkout=cancelled`, + }), + }); + if (!result.checkout_url) throw new DodoApiError("Dodo did not return a checkout URL."); + return result.checkout_url; +} + +export async function createPortalSession( + env: Env, + customerId: string, +): Promise { + const path = `/customers/${encodeURIComponent(customerId)}/customer-portal/session`; + const query = new URLSearchParams({ + return_url: `${env.PUBLIC_SITE_ORIGIN.replace(/\/$/, "")}/pricing`, + }); + const result = await dodoRequest<{ link: string }>(env, `${path}?${query}`, { + method: "POST", + }); + return result.link; +} + +export async function verifyDodoWebhook( + rawBody: string, + headers: Headers, + secret: string, + nowSeconds = Math.floor(Date.now() / 1000), +): Promise<{ id: string; payload: DodoWebhookPayload }> { + const id = headers.get("webhook-id") ?? ""; + const timestamp = headers.get("webhook-timestamp") ?? ""; + const signatures = headers.get("webhook-signature") ?? ""; + const timestampNumber = Number(timestamp); + if (!id || !Number.isInteger(timestampNumber)) throw new DodoWebhookError("Missing webhook headers."); + if (Math.abs(nowSeconds - timestampNumber) > WEBHOOK_TOLERANCE_SECONDS) { + throw new DodoWebhookError("Webhook timestamp is outside the accepted window."); + } + + const secretBytes = decodeWebhookSecret(secret); + const expected = await hmacBytes(secretBytes, `${id}.${timestamp}.${rawBody}`); + const valid = signatures + .split(" ") + .map((signature) => signature.split(",", 2)) + .some(([version, encoded]) => version === "v1" && matchesBase64(expected, encoded)); + if (!valid) throw new DodoWebhookError("Webhook signature is invalid."); + + let payload: DodoWebhookPayload; + try { + payload = JSON.parse(rawBody) as DodoWebhookPayload; + } catch { + throw new DodoWebhookError("Webhook payload is not valid JSON."); + } + if (!payload.type || !payload.data) throw new DodoWebhookError("Webhook payload is incomplete."); + return { id, payload }; +} + +function decodeWebhookSecret(secret: string): Uint8Array { + const encoded = secret.startsWith("whsec_") ? secret.slice(6) : secret; + try { + return Uint8Array.from(atob(encoded), (character) => character.charCodeAt(0)); + } catch { + throw new DodoWebhookError("Webhook secret is malformed."); + } +} + +function matchesBase64(expected: Uint8Array, encoded: string | undefined): boolean { + if (!encoded) return false; + let actual: Uint8Array; + try { + actual = Uint8Array.from(atob(encoded), (character) => character.charCodeAt(0)); + } catch { + return false; + } + if (actual.length !== expected.length) return false; + let difference = 0; + for (let index = 0; index < actual.length; index += 1) { + difference |= actual[index] ^ expected[index]; + } + return difference === 0; +} diff --git a/services/cloud-api/src/index.ts b/services/cloud-api/src/index.ts index 30ff9f6..362a503 100644 --- a/services/cloud-api/src/index.ts +++ b/services/cloud-api/src/index.ts @@ -1,69 +1,81 @@ -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 { OtpError, requestOtp, verifyOtp } from "./otp"; +import { + beginCheckout, + billingPortal, + BillingError, + cleanupBillingRecords, + createBillingHandoff, + dodoWebhook, +} from "./billing"; +import { error, json, RequestBodyError } from "./http"; +import { cleanupExpiredAuthRecords, 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 === "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 === "POST" && url.pathname === "/v1/billing/handoffs") { + return createBillingHandoff(request, env); } - if (request.method === "POST" && url.pathname === "/v1/auth/otp/request") { - return requestOtp(request, env); + if (request.method === "POST" && url.pathname === "/v1/billing/checkout") { + return beginCheckout(request, env); } - if (request.method === "POST" && url.pathname === "/v1/auth/otp/verify") { - return verifyOtp(request, env); + if (request.method === "POST" && url.pathname === "/v1/billing/portal") { + return billingPortal(request, env); } - if (request.method === "DELETE" && url.pathname === "/v1/session") { - return revokeSession(request, env); + if (request.method === "POST" && url.pathname === "/v1/webhooks/dodo") { + return dodoWebhook(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 +85,28 @@ 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 BillingError) 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( + Promise.all([ + cleanupExpiredAuthRecords(env), + cleanupExpiredPublishSessions(env), + cleanupBillingRecords(env), + ]).then( + () => undefined, + ), + ); + }, } satisfies ExportedHandler; diff --git a/services/cloud-api/src/otp.ts b/services/cloud-api/src/otp.ts index 1923d1f..5b5748b 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}$/; @@ -63,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 { @@ -85,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 @@ -99,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(), @@ -116,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, @@ -134,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); @@ -211,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( @@ -230,3 +264,28 @@ 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 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 ? + )`, + ).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(rateCutoff, 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), + ]); +} diff --git a/services/cloud-api/src/publishing.ts b/services/cloud-api/src/publishing.ts new file mode 100644 index 0000000..4bf3021 --- /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(/\/$/, "")}/pricing`); +} + +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..a08af74 100644 --- a/services/cloud-api/src/types.ts +++ b/services/cloud-api/src/types.ts @@ -1,18 +1,29 @@ export interface Env { DB: D1Database; 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; -} - -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; + DODO_PAYMENTS_API_KEY: string; + DODO_PAYMENTS_WEBHOOK_KEY: string; + DODO_PAYMENTS_ENVIRONMENT: "test_mode" | "live_mode"; + DODO_BUSINESS_ID: string; + DODO_MONTHLY_PRODUCT_ID: string; + DODO_YEARLY_PRODUCT_ID: string; } export type AccountPlan = "free" | "cloud"; +export type BillingInterval = "monthly" | "yearly"; export interface AuthenticatedUser { id: string; @@ -20,22 +31,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 +105,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/billing.test.ts b/services/cloud-api/test/billing.test.ts new file mode 100644 index 0000000..c4dd8ee --- /dev/null +++ b/services/cloud-api/test/billing.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, test } from "bun:test"; +import { entitlementStatus } from "../src/billing"; +import { hmacBytes } from "../src/crypto"; +import { DodoWebhookError, verifyDodoWebhook } from "../src/dodo"; + +function base64(bytes: Uint8Array): string { + let binary = ""; + for (const byte of bytes) binary += String.fromCharCode(byte); + return btoa(binary); +} + +describe("Dodo webhook verification", () => { + test("accepts a correctly signed Standard Webhooks payload", async () => { + const rawSecret = new TextEncoder().encode("markd-webhook-test-secret"); + const secret = `whsec_${base64(rawSecret)}`; + const id = "msg_markd_test"; + const timestamp = 1_750_000_000; + const body = JSON.stringify({ + business_id: "bus_test", + type: "subscription.active", + timestamp: "2025-06-15T15:06:40.000Z", + data: { subscription_id: "sub_test", product_id: "prod_test" }, + }); + const signature = base64(await hmacBytes(rawSecret, `${id}.${timestamp}.${body}`)); + const headers = new Headers({ + "webhook-id": id, + "webhook-timestamp": String(timestamp), + "webhook-signature": `v1,${signature}`, + }); + + const verified = await verifyDodoWebhook(body, headers, secret, timestamp); + expect(verified.id).toBe(id); + expect(verified.payload.type).toBe("subscription.active"); + }); + + test("rejects payload tampering", async () => { + const rawSecret = new TextEncoder().encode("markd-webhook-test-secret"); + const timestamp = 1_750_000_000; + const headers = new Headers({ + "webhook-id": "msg_test", + "webhook-timestamp": String(timestamp), + "webhook-signature": `v1,${base64(await hmacBytes(rawSecret, "wrong"))}`, + }); + await expect( + verifyDodoWebhook("{}", headers, `whsec_${base64(rawSecret)}`, timestamp), + ).rejects.toBeInstanceOf(DodoWebhookError); + }); +}); + +describe("Dodo entitlement mapping", () => { + test("keeps a cancelled subscription active through its paid period", () => { + expect( + entitlementStatus("subscription.cancelled", "cancelled", Date.now() + 60_000), + ).toBe("active"); + }); + + test("removes access for expired and on-hold subscriptions", () => { + expect(entitlementStatus("subscription.expired", "expired", null)).toBe("canceled"); + expect(entitlementStatus("subscription.on_hold", "on_hold", null)).toBe("past_due"); + }); +}); 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..13efc9d 100644 --- a/services/cloud-api/wrangler.jsonc +++ b/services/cloud-api/wrangler.jsonc @@ -4,14 +4,52 @@ "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 }, + "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_SITE_ORIGIN": "https://usemarkd.app", + "PUBLIC_API_ORIGIN": "https://api.usemarkd.app", + "R2_BUCKET_NAME": "markd-published-notes", + "DODO_PAYMENTS_ENVIRONMENT": "test_mode", + "DODO_BUSINESS_ID": "bus_0NhKUHtWjZyM9DzXLgbBq", + "DODO_MONTHLY_PRODUCT_ID": "pdt_0NjSw2rt9DvwCBnTd7y51", + "DODO_YEARLY_PRODUCT_ID": "pdt_0NjSvvCkI1Kc5HwMMjNz1" }, "secrets": { - "required": ["OTP_PEPPER"] + "required": [ + "OTP_PEPPER", + "R2_ACCOUNT_ID", + "R2_ACCESS_KEY_ID", + "R2_SECRET_ACCESS_KEY", + "CACHE_ZONE_ID", + "CACHE_PURGE_TOKEN", + "DODO_PAYMENTS_API_KEY", + "DODO_PAYMENTS_WEBHOOK_KEY" + ] }, "send_email": [ { @@ -19,6 +57,9 @@ "allowed_sender_addresses": ["no-reply@usemarkd.app"] } ], + "images": { + "binding": "IMAGES" + }, "d1_databases": [ { "binding": "DB", diff --git a/site/app/api/billing/checkout/route.ts b/site/app/api/billing/checkout/route.ts new file mode 100644 index 0000000..e48c184 --- /dev/null +++ b/site/app/api/billing/checkout/route.ts @@ -0,0 +1,30 @@ +const CLOUD_API = process.env.MARKD_CLOUD_API_URL ?? "https://api.usemarkd.app"; + +export async function POST(request: Request): Promise { + const contentLength = Number(request.headers.get("content-length") ?? "0"); + if (contentLength > 4_096) { + return Response.json( + { error: { code: "payload_too_large", message: "The checkout request is too large." } }, + { status: 413 }, + ); + } + const body = await request.text(); + if (new TextEncoder().encode(body).byteLength > 4_096) { + return Response.json( + { error: { code: "payload_too_large", message: "The checkout request is too large." } }, + { status: 413 }, + ); + } + const response = await fetch(`${CLOUD_API.replace(/\/$/, "")}/v1/billing/checkout`, { + method: "POST", + headers: { "content-type": "application/json" }, + body, + }); + return new Response(await response.text(), { + status: response.status, + headers: { + "content-type": "application/json; charset=utf-8", + "cache-control": "no-store", + }, + }); +} 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 { diff --git a/site/app/login/page.tsx b/site/app/login/page.tsx deleted file mode 100644 index 7c9b4ae..0000000 --- a/site/app/login/page.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import Link from "next/link"; - -export default function LoginPage() { - return ( -
-
-

- Markd account -

-

- Cloud publishing is coming next -

-

- Email sign-in and publishing are not available in the current Markd - release. They’ll be included in the next app version. -

-
- 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. -
- - Back to Markd - -
-
- ); -} diff --git a/site/app/pricing/page.tsx b/site/app/pricing/page.tsx new file mode 100644 index 0000000..528047b --- /dev/null +++ b/site/app/pricing/page.tsx @@ -0,0 +1,25 @@ +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 async function PricingPage({ + searchParams, +}: { + searchParams: Promise<{ billing_token?: string; checkout?: string }>; +}) { + const query = await searchParams; + return ( + <> +