From 33ebe0707dcee5c15af349e712cf3b3eba1b0943 Mon Sep 17 00:00:00 2001 From: restuta Date: Fri, 20 Mar 2026 18:18:37 +0700 Subject: [PATCH 1/2] feat: add anti-abuse guards for hosted usage --- docs/progress.md | 3 + docs/project-plan.md | 99 +++++++++++- src/core/blob-store.ts | 135 ++++++++-------- src/core/file-store.ts | 108 ++++++++++--- src/core/publish-service.ts | 216 ++++++++++++++++++++++++-- src/core/repository.ts | 22 +++ src/server/app.ts | 35 ++++- tests/integration/server.test.ts | 257 ++++++++++++++++++++++++++++++- tests/integration/test-server.ts | 5 +- tests/unit/blob-store.test.ts | 12 ++ 10 files changed, 791 insertions(+), 101 deletions(-) diff --git a/docs/progress.md b/docs/progress.md index 6a825a6..b11654f 100644 --- a/docs/progress.md +++ b/docs/progress.md @@ -15,3 +15,6 @@ - 2026-03-19: Deployed production successfully and verified the live domain with a real smoke test: claim -> publish -> HTML read -> raw read -> list -> delete on `https://bul.sh`. - 2026-03-19: Investigated true custom-domain external rewrites on Vercel. Redirects propagate to `bul.sh`, but rewrite routes did not behave as required on the custom domain. - 2026-03-19: Adopted the pragmatic Vercel production read path: serve pre-rendered HTML through Hono with aggressive edge-cache headers so subsequent reads are CDN hits while content remains stored in Blob. +- 2026-03-20: Added a concrete cost-control and anti-abuse implementation plan to the project plan so hosted usage can be hardened later without redesigning the product. +- 2026-03-20: Implemented first-pass hosted abuse controls in the service layer: reserved namespaces, markdown size caps, claim and publish rate limits, and lazy reclaim of empty stale namespaces. +- 2026-03-20: Added automated coverage for the abuse controls through integration tests on the HTTP app. diff --git a/docs/project-plan.md b/docs/project-plan.md index 1356f04..40bd467 100644 --- a/docs/project-plan.md +++ b/docs/project-plan.md @@ -132,6 +132,100 @@ Local .pub mapping: - Revisions: `rev:{page_id}:v{n} → { blob_key, published_at }` + `current_version` field - Graduate to Postgres when KV queries become painful (listing, search, etc.) +## Cost Model & Abuse Control + +The biggest risk on Vercel is not steady-state storage. It is abuse: +- too many namespace claims +- too many write operations +- too many large pages +- too many cache misses from spam content + +Storage itself should stay cheap for a long time. The app is mostly text, pages are small, and read traffic is edge-cached. The practical cost center to control is **writes and churn**, not simply page count. + +### Design Principle + +Keep the hosted version easy to use for legitimate humans and AI agents, but make abuse expensive or slow. + +### Phase 1 Controls (implement first) + +**1. Claim rate limiting** +- Limit namespace claims per IP +- Suggested starting point: + - 3 claims per hour per IP + - 10 claims per day per IP +- Goal: stop namespace-squatting scripts and low-effort spam + +**2. Publish rate limiting** +- Limit publishes by both IP and namespace +- Suggested starting point: + - 30 publishes per 10 minutes per namespace + - 100 publishes per hour per IP +- Goal: stop automated flooding while allowing normal iterative editing + +**3. Markdown size limits** +- Hard cap on request body / markdown size +- Suggested starting point: + - 256 KB per page for v1 +- Goal: prevent Blob from becoming arbitrary cheap object storage + +**4. Reserved namespaces** +- Block obvious or sensitive names +- Initial reserved set: + - `admin` + - `api` + - `www` + - `support` + - `help` + - `install` + - `bul` + - `pubmd` + - `root` +- Goal: avoid confusion, collisions, and support burden + +**5. Empty-namespace reclaim policy** +- If a namespace is claimed but no page is published within 7 days, reclaim it +- Goal: reduce squatting without adding a full identity system + +### Phase 2 Controls (only if needed) + +**6. Token rotation** +- Add `pubmd token rotate` +- Invalidate old namespace token on rotation +- Useful if a token leaks or a namespace is shared accidentally + +**7. Lightweight audit visibility** +- Track: + - last claim time + - last publish time + - publish count over recent windows +- Goal: make abuse visible before building a moderation dashboard + +**8. Optional friction for suspicious traffic** +- Only if needed later: + - proof-of-work + - challenge pages + - manual review queue +- Not a v1 priority + +### Implementation Notes + +- Enforcement should happen in the service layer, not just at the CDN edge +- Limits should be configurable via environment variables +- The hosted instance and self-hosted instances should be able to use different defaults +- Abuse controls should fail with clear machine-readable errors so AI agents can recover gracefully + +### Metrics To Watch + +- namespaces claimed / day +- namespaces reclaimed without publish +- publishes / namespace / day +- median markdown size +- 95th percentile markdown size +- cache hit ratio on page reads +- total Blob writes vs. reads + +If those numbers stay low, keep the system simple. If they climb unnaturally, harden the hosted instance before scaling usage. + ## Milestones ### M0: Spike (1 day) @@ -163,6 +257,9 @@ Local .pub mapping: - [ ] Math/KaTeX + Mermaid rendering (add when requested) - [ ] Page versioning (keep history, show diffs) — data model already supports this - [ ] Page renames with redirects — data model already supports this +- [x] Lightweight anti-abuse controls (claim/publish rate limits, reserved namespaces, max page size) +- [x] Namespace reclaim policy for empty claims +- [ ] Token rotation - [ ] View count analytics - [ ] Page collections with auto-generated index - [ ] Expiring pages (TTL) @@ -190,7 +287,7 @@ Local .pub mapping: ## Things to Decide - [ ] **Name**: `pub`? `md.pub`? `mdpost`? `pushmd`? Need a good domain. -- [ ] **Free tier limits**: unlimited pages? Rate limit only? Storage cap? +- [ ] **Hosted free tier**: what claim/publish/size limits are acceptable before introducing stronger friction? - [ ] **Subdomain vs path**: `namespace.domain` vs `domain/namespace` — start with path, add subdomain later? - [ ] **Markdown flavor**: strict GFM or also support Obsidian-flavored ([[wikilinks]], ==highlights==, callouts)? - [ ] **Default visibility**: unlisted (noindex) or public? diff --git a/src/core/blob-store.ts b/src/core/blob-store.ts index 798b02b..d39c965 100644 --- a/src/core/blob-store.ts +++ b/src/core/blob-store.ts @@ -1,4 +1,4 @@ -import { del, get, put } from "@vercel/blob"; +import { del, get, list, put } from "@vercel/blob"; import { z } from "zod"; import { @@ -11,14 +11,16 @@ import { type FilePayload, NamespaceNotFoundError, type PublishRepository, + type RateLimitRecord, } from "./repository.js"; const LookupRecordSchema = z.object({ pageId: z.string().uuid(), }); -const NamespacePageIndexSchema = z.object({ - pages: z.array(StoredPageSchema), +const RateLimitRecordSchema = z.object({ + count: z.number(), + windowStartedAt: z.string(), }); export function createBlobStore( @@ -29,13 +31,25 @@ export function createBlobStore( namespace: string, tokenHash: string, ): Promise { - const record: NamespaceRecord = { - namespace, - tokenHash, - createdAt: new Date().toISOString(), - }; + await saveNamespace( + { + namespace, + tokenHash, + createdAt: new Date().toISOString(), + }, + false, + ); + } - await writeJsonBlob(namespacePath(namespace), record, false); + async function saveNamespace( + record: NamespaceRecord, + allowOverwrite = true, + ): Promise { + await writeJsonBlob( + namespacePath(record.namespace), + record, + allowOverwrite, + ); } async function getNamespace( @@ -54,22 +68,50 @@ export function createBlobStore( throw new NamespaceNotFoundError(namespace); } - await writeJsonBlob(namespacePath(namespace), { + await saveNamespace({ ...current, lastPublishAt, }); } + async function getRateLimitRecord( + bucket: string, + ): Promise { + return readJsonBlob(rateLimitPath(bucket), RateLimitRecordSchema); + } + + async function setRateLimitRecord( + bucket: string, + record: RateLimitRecord, + ): Promise { + await writeJsonBlob(rateLimitPath(bucket), record); + } + async function listPages(namespace: string): Promise { - const index = await readJsonBlob( - namespaceIndexPath(namespace), - NamespacePageIndexSchema, - ); - const pages = index?.pages ?? []; + const lookupResults = await list({ + limit: 1000, + prefix: `${lookupPrefix(namespace)}/`, + token: metadataToken, + }); - return pages.sort((left, right) => - right.updatedAt.localeCompare(left.updatedAt), + const pages = await Promise.all( + lookupResults.blobs.map(async (lookupBlob) => { + const lookup = await readJsonBlob( + lookupBlob.pathname, + LookupRecordSchema, + ); + + if (lookup === null) { + return null; + } + + return findPageById(lookup.pageId); + }), ); + + return pages + .filter((page): page is StoredPage => page !== null) + .sort((left, right) => right.updatedAt.localeCompare(left.updatedAt)); } async function findPageById(pageId: string): Promise { @@ -106,7 +148,6 @@ export function createBlobStore( writeJsonBlob(lookupPath(page.namespace, page.slug), { pageId: page.pageId, }), - writeNamespaceIndex(page.namespace, page), ]); if (previousPage !== null && previousPage.slug !== page.slug) { @@ -122,7 +163,6 @@ export function createBlobStore( token: metadataToken, }), del([page.markdownBlobKey, page.htmlBlobKey], { token: contentToken }), - removeFromNamespaceIndex(page.namespace, page.pageId), ]); } @@ -192,58 +232,20 @@ export function createBlobStore( return `namespaces/${namespace}.json`; } - function namespaceIndexPath(namespace: string): string { - return `indexes/${namespace}.json`; - } - function pagePath(pageId: string): string { return `pages/${pageId}.json`; } - function lookupPath(namespace: string, slug: string): string { - return `lookups/${namespace}/${slug}.json`; + function lookupPrefix(namespace: string): string { + return `lookups/${namespace}`; } - async function writeNamespaceIndex( - namespace: string, - page: StoredPage, - ): Promise { - const current = await readJsonBlob( - namespaceIndexPath(namespace), - NamespacePageIndexSchema, - ); - const nextPages = [...(current?.pages ?? [])]; - const existingIndex = nextPages.findIndex( - (currentPage) => currentPage.pageId === page.pageId, - ); - - if (existingIndex === -1) { - nextPages.push(page); - } else { - nextPages[existingIndex] = page; - } - - await writeJsonBlob(namespaceIndexPath(namespace), { - pages: nextPages, - }); + function lookupPath(namespace: string, slug: string): string { + return `lookups/${namespace}/${slug}.json`; } - async function removeFromNamespaceIndex( - namespace: string, - pageId: string, - ): Promise { - const current = await readJsonBlob( - namespaceIndexPath(namespace), - NamespacePageIndexSchema, - ); - - if (current === null) { - return; - } - - await writeJsonBlob(namespaceIndexPath(namespace), { - pages: current.pages.filter((page) => page.pageId !== pageId), - }); + function rateLimitPath(bucket: string): string { + return `rate-limits/${sanitizeBucket(bucket)}.json`; } return { @@ -252,10 +254,13 @@ export function createBlobStore( findPageById, findPageBySlug, getNamespace, + getRateLimitRecord, listPages, readHtml, readMarkdown, + saveNamespace, savePage, + setRateLimitRecord, touchNamespace, }; } @@ -269,3 +274,7 @@ async function streamToString( function stringifyJson(value: unknown): string { return `${JSON.stringify(value, null, 2)}\n`; } + +function sanitizeBucket(bucket: string): string { + return bucket.replaceAll(/[^a-zA-Z0-9/_-]+/g, "_"); +} diff --git a/src/core/file-store.ts b/src/core/file-store.ts index af0e690..9effd72 100644 --- a/src/core/file-store.ts +++ b/src/core/file-store.ts @@ -16,34 +16,65 @@ import { } from "./contract.js"; import { type FilePayload, - PageNotFoundError, type PublishRepository, + type RateLimitRecord, } from "./repository.js"; +const RateLimitRecordSchema = { + parse(value: unknown): RateLimitRecord { + if ( + typeof value !== "object" || + value === null || + !("count" in value) || + !("windowStartedAt" in value) + ) { + throw new Error("Invalid rate limit record."); + } + + const record = value as { + count: unknown; + windowStartedAt: unknown; + }; + + if ( + typeof record.count !== "number" || + typeof record.windowStartedAt !== "string" + ) { + throw new Error("Invalid rate limit record."); + } + + return { + count: record.count, + windowStartedAt: record.windowStartedAt, + }; + }, +}; + export function createFileStore(rootDir: string): PublishRepository { const namespacesDir = path.join(rootDir, "namespaces"); const pagesDir = path.join(rootDir, "pages"); const blobsDir = path.join(rootDir, "blobs"); + const rateLimitsDir = path.join(rootDir, "rate-limits"); async function claimNamespace( namespace: string, tokenHash: string, ): Promise { await ensureDirectories(); + await writeJson( + namespacePath(namespace), + { + namespace, + tokenHash, + createdAt: new Date().toISOString(), + }, + "wx", + ); + } - const targetPath = namespacePath(namespace); - - if (await pathExists(targetPath)) { - throw new Error("NAMESPACE_EXISTS"); - } - - const record: NamespaceRecord = { - namespace, - tokenHash, - createdAt: new Date().toISOString(), - }; - - await writeJson(targetPath, record); + async function saveNamespace(record: NamespaceRecord): Promise { + await ensureDirectories(); + await writeJson(namespacePath(record.namespace), record); } async function getNamespace( @@ -76,6 +107,28 @@ export function createFileStore(rootDir: string): PublishRepository { }); } + async function getRateLimitRecord( + bucket: string, + ): Promise { + await ensureDirectories(); + + const targetPath = rateLimitPath(bucket); + + if (!(await pathExists(targetPath))) { + return null; + } + + return RateLimitRecordSchema.parse(await readJson(targetPath)); + } + + async function setRateLimitRecord( + bucket: string, + record: RateLimitRecord, + ): Promise { + await ensureDirectories(); + await writeJson(rateLimitPath(bucket), record); + } + async function listPages(namespace: string): Promise { await ensureDirectories(); @@ -135,10 +188,6 @@ export function createFileStore(rootDir: string): PublishRepository { async function deletePage(page: StoredPage): Promise { await ensureDirectories(); - if ((await findPageById(page.pageId)) === null) { - throw new PageNotFoundError(page.namespace, page.slug); - } - await unlinkIfExists(path.join(blobsDir, page.markdownBlobKey)); await unlinkIfExists(path.join(blobsDir, page.htmlBlobKey)); await unlinkIfExists(pagePath(page.pageId)); @@ -157,6 +206,7 @@ export function createFileStore(rootDir: string): PublishRepository { await mkdir(namespacesDir, { recursive: true }); await mkdir(pagesDir, { recursive: true }); await mkdir(blobsDir, { recursive: true }); + await mkdir(rateLimitsDir, { recursive: true }); } function namespacePath(namespace: string): string { @@ -167,16 +217,23 @@ export function createFileStore(rootDir: string): PublishRepository { return path.join(pagesDir, `${pageId}.json`); } + function rateLimitPath(bucket: string): string { + return path.join(rateLimitsDir, `${sanitizeBucket(bucket)}.json`); + } + return { claimNamespace, deletePage, findPageById, findPageBySlug, getNamespace, + getRateLimitRecord, listPages, readHtml, readMarkdown, + saveNamespace, savePage, + setRateLimitRecord, touchNamespace, }; } @@ -194,8 +251,15 @@ async function readJson(filePath: string): Promise { return JSON.parse(await readFile(filePath, "utf8")) as unknown; } -async function writeJson(filePath: string, value: unknown): Promise { - await writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`, "utf8"); +async function writeJson( + filePath: string, + value: unknown, + flag?: "wx", +): Promise { + await writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`, { + encoding: "utf8", + ...(flag === undefined ? {} : { flag }), + }); } async function unlinkIfExists(filePath: string): Promise { @@ -209,3 +273,7 @@ async function unlinkIfExists(filePath: string): Promise { throw error; } } + +function sanitizeBucket(bucket: string): string { + return bucket.replaceAll(/[^a-zA-Z0-9_-]+/g, "_"); +} diff --git a/src/core/publish-service.ts b/src/core/publish-service.ts index 8cdcf8e..a68c252 100644 --- a/src/core/publish-service.ts +++ b/src/core/publish-service.ts @@ -2,6 +2,7 @@ import { randomUUID } from "node:crypto"; import type { ClaimNamespaceResponse, + NamespaceRecord, PublishedPage, StoredPage, } from "./contract.js"; @@ -13,15 +14,32 @@ import { } from "./markdown.js"; import { AuthenticationError, + ContentTooLargeError, NamespaceExistsError, NamespaceNotFoundError, PageNotFoundError, type PublishRepository, + RateLimitExceededError, + type RateLimitRecord, + ReservedNamespaceError, SlugConflictError, } from "./repository.js"; import { ensureName, slugify } from "./slug.js"; +const DEFAULT_RESERVED_NAMESPACES = new Set([ + "admin", + "api", + "www", + "support", + "help", + "install", + "bul", + "pubmd", + "root", +]); + export interface PublishPageInput { + ipAddress?: string; markdown: string; namespace: string; pageId?: string; @@ -30,6 +48,11 @@ export interface PublishPageInput { origin: string; } +export interface ClaimNamespaceInput { + ipAddress?: string; + namespace: string; +} + export interface ListPagesInput { namespace: string; origin: string; @@ -42,8 +65,18 @@ export interface RemovePageInput { token: string; } +export interface PublishServiceOptions { + claimDailyLimit: number; + claimHourlyLimit: number; + maxMarkdownBytes: number; + publishIpHourlyLimit: number; + publishNamespaceTenMinuteLimit: number; + reclaimUnpublishedNamespaceAfterMs: number; + reservedNamespaces: Set; +} + export interface PublishService { - claimNamespace(namespace: string): Promise; + claimNamespace(input: ClaimNamespaceInput): Promise; publishPage(input: PublishPageInput): Promise; listPages(input: ListPagesInput): Promise< Array<{ @@ -64,19 +97,54 @@ export interface PublishService { export function createPublishService( repository: PublishRepository, + options: Partial = {}, ): PublishService { + const resolvedOptions: PublishServiceOptions = { + claimDailyLimit: options.claimDailyLimit ?? 10, + claimHourlyLimit: options.claimHourlyLimit ?? 3, + maxMarkdownBytes: options.maxMarkdownBytes ?? 256 * 1024, + publishIpHourlyLimit: options.publishIpHourlyLimit ?? 100, + publishNamespaceTenMinuteLimit: + options.publishNamespaceTenMinuteLimit ?? 30, + reclaimUnpublishedNamespaceAfterMs: + options.reclaimUnpublishedNamespaceAfterMs ?? 7 * 24 * 60 * 60 * 1000, + reservedNamespaces: + options.reservedNamespaces ?? DEFAULT_RESERVED_NAMESPACES, + }; + async function claimNamespace( - namespace: string, + input: ClaimNamespaceInput, ): Promise { - const safeNamespace = ensureName(namespace); + const safeNamespace = ensureName(input.namespace); + ensureNamespaceAllowed(safeNamespace); + await enforceClaimLimits(input.ipAddress); + const existing = await repository.getNamespace(safeNamespace); + const now = new Date(); + const token = createToken(); - if (existing !== null) { + if ( + existing !== null && + !isReclaimableNamespace( + existing, + now, + resolvedOptions.reclaimUnpublishedNamespaceAfterMs, + ) + ) { throw new NamespaceExistsError(safeNamespace); } - const token = createToken(); - await repository.claimNamespace(safeNamespace, sha256(token)); + const namespaceRecord: NamespaceRecord = { + namespace: safeNamespace, + tokenHash: sha256(token), + createdAt: now.toISOString(), + }; + + if (existing === null) { + await repository.claimNamespace(safeNamespace, namespaceRecord.tokenHash); + } else { + await repository.saveNamespace(namespaceRecord); + } return { namespace: safeNamespace, @@ -87,6 +155,8 @@ export function createPublishService( async function publishPage(input: PublishPageInput): Promise { const safeNamespace = ensureName(input.namespace); await authenticate(safeNamespace, input.token); + await enforcePublishLimits(safeNamespace, input.ipAddress); + ensureMarkdownSize(input.markdown); const parsed = parseMarkdownDocument(input.markdown); const requestedSlug = @@ -237,12 +307,81 @@ export function createPublishService( } } - function buildPageUrl( - origin: string, + function ensureNamespaceAllowed(namespace: string): void { + if (resolvedOptions.reservedNamespaces.has(namespace)) { + throw new ReservedNamespaceError(namespace); + } + } + + function ensureMarkdownSize(markdown: string): void { + if ( + Buffer.byteLength(markdown, "utf8") > resolvedOptions.maxMarkdownBytes + ) { + throw new ContentTooLargeError(resolvedOptions.maxMarkdownBytes); + } + } + + async function enforceClaimLimits(ipAddress?: string): Promise { + const ipKey = normalizeIpAddress(ipAddress); + + if (ipKey === null) { + return; + } + + await incrementRateLimit( + `claim:hour:${hashIdentity(ipKey)}`, + resolvedOptions.claimHourlyLimit, + 60 * 60 * 1000, + `Too many namespace claims from this IP. Try again later.`, + ); + await incrementRateLimit( + `claim:day:${hashIdentity(ipKey)}`, + resolvedOptions.claimDailyLimit, + 24 * 60 * 60 * 1000, + `Too many namespace claims from this IP today.`, + ); + } + + async function enforcePublishLimits( namespace: string, - slug: string, - ): string { - return new URL(`/${namespace}/${slug}`, origin).toString(); + ipAddress?: string, + ): Promise { + await incrementRateLimit( + `publish:namespace:${namespace}`, + resolvedOptions.publishNamespaceTenMinuteLimit, + 10 * 60 * 1000, + `Too many publishes for namespace "${namespace}".`, + ); + + const ipKey = normalizeIpAddress(ipAddress); + + if (ipKey === null) { + return; + } + + await incrementRateLimit( + `publish:ip:${hashIdentity(ipKey)}`, + resolvedOptions.publishIpHourlyLimit, + 60 * 60 * 1000, + `Too many publishes from this IP. Try again later.`, + ); + } + + async function incrementRateLimit( + bucket: string, + maxCount: number, + windowMs: number, + message: string, + ): Promise { + const now = new Date(); + const current = await repository.getRateLimitRecord(bucket); + const nextRecord = computeNextRateLimitRecord(current, now, windowMs); + + if (nextRecord.count > maxCount) { + throw new RateLimitExceededError(message); + } + + await repository.setRateLimitRecord(bucket, nextRecord); } async function resolveExistingPage( @@ -283,3 +422,58 @@ export function createPublishService( removePage, }; } + +function buildPageUrl(origin: string, namespace: string, slug: string): string { + return new URL(`/${namespace}/${slug}`, origin).toString(); +} + +function normalizeIpAddress(ipAddress: string | undefined): string | null { + if (ipAddress === undefined) { + return null; + } + + const trimmed = ipAddress.trim(); + return trimmed.length > 0 ? trimmed : null; +} + +function hashIdentity(value: string): string { + return sha256(value).slice(0, 16); +} + +function isReclaimableNamespace( + namespace: NamespaceRecord, + now: Date, + reclaimAfterMs: number, +): boolean { + return ( + namespace.lastPublishAt === undefined && + now.getTime() - new Date(namespace.createdAt).getTime() > reclaimAfterMs + ); +} + +function computeNextRateLimitRecord( + current: RateLimitRecord | null, + now: Date, + windowMs: number, +): RateLimitRecord { + if (current === null) { + return { + count: 1, + windowStartedAt: now.toISOString(), + }; + } + + const windowStart = new Date(current.windowStartedAt).getTime(); + + if (now.getTime() - windowStart >= windowMs) { + return { + count: 1, + windowStartedAt: now.toISOString(), + }; + } + + return { + count: current.count + 1, + windowStartedAt: current.windowStartedAt, + }; +} diff --git a/src/core/repository.ts b/src/core/repository.ts index 264bf62..8c6b81b 100644 --- a/src/core/repository.ts +++ b/src/core/repository.ts @@ -30,6 +30,25 @@ export class SlugConflictError extends Error { } } +export class ReservedNamespaceError extends Error { + constructor(namespace: string) { + super(`Namespace "${namespace}" is reserved.`); + } +} + +export class RateLimitExceededError extends Error {} + +export class ContentTooLargeError extends Error { + constructor(maxBytes: number) { + super(`Markdown exceeds the maximum allowed size of ${maxBytes} bytes.`); + } +} + +export interface RateLimitRecord { + count: number; + windowStartedAt: string; +} + export interface FilePayload { content: string; key: string; @@ -37,8 +56,11 @@ export interface FilePayload { export interface PublishRepository { claimNamespace(namespace: string, tokenHash: string): Promise; + saveNamespace(record: NamespaceRecord): Promise; getNamespace(namespace: string): Promise; touchNamespace(namespace: string, lastPublishAt: string): Promise; + getRateLimitRecord(bucket: string): Promise; + setRateLimitRecord(bucket: string, record: RateLimitRecord): Promise; listPages(namespace: string): Promise; findPageById(pageId: string): Promise; findPageBySlug(namespace: string, slug: string): Promise; diff --git a/src/server/app.ts b/src/server/app.ts index 6ef3a5c..ce352e2 100644 --- a/src/server/app.ts +++ b/src/server/app.ts @@ -1,4 +1,4 @@ -import { Hono } from "hono"; +import { type Context, Hono } from "hono"; import { HTTPException } from "hono/http-exception"; import { @@ -9,9 +9,12 @@ import { buildHtmlDocument, renderMarkdownToHtml } from "../core/markdown.js"; import type { PublishService } from "../core/publish-service.js"; import { AuthenticationError, + ContentTooLargeError, NamespaceExistsError, NamespaceNotFoundError, PageNotFoundError, + RateLimitExceededError, + ReservedNamespaceError, SlugConflictError, } from "../core/repository.js"; @@ -76,9 +79,11 @@ Open source — [github.com/Restuta/pubmd](https://github.com/Restuta/pubmd)`; app.post("/api/namespaces/:namespace/claim", async (context) => { try { - const claimed = await service.claimNamespace( - context.req.param("namespace"), - ); + const ipAddress = requestIp(context); + const claimed = await service.claimNamespace({ + namespace: context.req.param("namespace"), + ...(ipAddress === undefined ? {} : { ipAddress }), + }); return context.json(claimed, 201); } catch (error) { throw toHttpException(error); @@ -94,6 +99,7 @@ Open source — [github.com/Restuta/pubmd](https://github.com/Restuta/pubmd)`; let markdown: string; let slug: string | undefined; let pageId: string | undefined; + const ipAddress = requestIp(context); if (isJson) { const body = PublishPageRequestSchema.parse(await context.req.json()); @@ -111,6 +117,7 @@ Open source — [github.com/Restuta/pubmd](https://github.com/Restuta/pubmd)`; token, markdown, origin: requestOrigin(context.req.url), + ...(ipAddress === undefined ? {} : { ipAddress }), ...(pageId === undefined ? {} : { pageId }), ...(slug === undefined ? {} : { requestedSlug: slug }), }; @@ -207,6 +214,14 @@ function requestOrigin(url: string): string { return new URL(url).origin; } +function requestIp(context: Context): string | undefined { + return ( + context.req.header("x-real-ip") ?? + context.req.header("cf-connecting-ip") ?? + undefined + ); +} + function toHttpException(error: unknown): HTTPException { if (error instanceof HTTPException) { return error; @@ -228,6 +243,18 @@ function toHttpException(error: unknown): HTTPException { return new HTTPException(409, { message: error.message }); } + if (error instanceof ReservedNamespaceError) { + return new HTTPException(409, { message: error.message }); + } + + if (error instanceof RateLimitExceededError) { + return new HTTPException(429, { message: error.message }); + } + + if (error instanceof ContentTooLargeError) { + return new HTTPException(413, { message: error.message }); + } + if (error instanceof PageNotFoundError) { return new HTTPException(404, { message: error.message }); } diff --git a/tests/integration/server.test.ts b/tests/integration/server.test.ts index 51039a5..629dddd 100644 --- a/tests/integration/server.test.ts +++ b/tests/integration/server.test.ts @@ -1,4 +1,4 @@ -import { mkdtemp } from "node:fs/promises"; +import { mkdtemp, readFile, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; @@ -126,4 +126,259 @@ This is the body.`, expect(listed.pages).toHaveLength(1); expect(listed.pages[0]?.slug).toBe("launch-post"); }); + + it("rejects reserved namespaces", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "pubmd-server-")); + server = await startTestServer(root); + + const response = await fetch(`${server.origin}/api/namespaces/api/claim`, { + method: "POST", + }); + + expect(response.status).toBe(409); + expect(await response.text()).toContain("reserved"); + }); + + it("rate limits repeated namespace claims from the same ip", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "pubmd-server-")); + server = await startTestServer(root); + + for (const namespace of ["one", "two", "three"]) { + const response = await fetch( + `${server.origin}/api/namespaces/${namespace}/claim`, + { + method: "POST", + headers: { + "x-real-ip": "203.0.113.10", + }, + }, + ); + expect(response.status).toBe(201); + } + + const limited = await fetch(`${server.origin}/api/namespaces/four/claim`, { + method: "POST", + headers: { + "x-real-ip": "203.0.113.10", + }, + }); + + expect(limited.status).toBe(429); + }); + + it("reclaims an old empty namespace when it is claimed again", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "pubmd-server-")); + server = await startTestServer(root); + + const firstClaim = await fetch( + `${server.origin}/api/namespaces/reclaim-me/claim`, + { + method: "POST", + }, + ); + expect(firstClaim.status).toBe(201); + + const namespacePath = path.join( + server.dataDir, + "namespaces", + "reclaim-me.json", + ); + const currentRecord = JSON.parse(await readFile(namespacePath, "utf8")) as { + createdAt: string; + namespace: string; + tokenHash: string; + }; + currentRecord.createdAt = new Date( + Date.now() - 8 * 24 * 60 * 60 * 1000, + ).toISOString(); + await writeFile( + namespacePath, + `${JSON.stringify(currentRecord, null, 2)}\n`, + ); + + const secondClaim = await fetch( + `${server.origin}/api/namespaces/reclaim-me/claim`, + { + method: "POST", + headers: { + "x-real-ip": "198.51.100.5", + }, + }, + ); + + expect(secondClaim.status).toBe(201); + }); + + it("rejects markdown bodies above the size cap", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "pubmd-server-")); + server = await startTestServer(root); + + const claimResponse = await fetch( + `${server.origin}/api/namespaces/size-test/claim`, + { + method: "POST", + }, + ); + const claimed = (await claimResponse.json()) as { token: string }; + const oversizedMarkdown = `# big\n\n${"a".repeat(300 * 1024)}`; + + const publishResponse = await fetch( + `${server.origin}/api/namespaces/size-test/pages/publish`, + { + method: "POST", + headers: { + authorization: `Bearer ${claimed.token}`, + "content-type": "application/json", + }, + body: JSON.stringify({ + markdown: oversizedMarkdown, + }), + }, + ); + + expect(publishResponse.status).toBe(413); + }); + + it("rate limits excessive publishes on the same namespace", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "pubmd-server-")); + server = await startTestServer(root); + + const claimResponse = await fetch( + `${server.origin}/api/namespaces/publish-limit/claim`, + { + method: "POST", + }, + ); + const claimed = (await claimResponse.json()) as { token: string }; + + for (let index = 0; index < 30; index += 1) { + const response = await fetch( + `${server.origin}/api/namespaces/publish-limit/pages/publish`, + { + method: "POST", + headers: { + authorization: `Bearer ${claimed.token}`, + "content-type": "application/json", + "x-real-ip": "203.0.113.10", + }, + body: JSON.stringify({ + markdown: "# Publish limit\n\nsame body", + slug: "same-page", + }), + }, + ); + expect(response.status).toBeGreaterThanOrEqual(200); + expect(response.status).toBeLessThan(300); + } + + const limited = await fetch( + `${server.origin}/api/namespaces/publish-limit/pages/publish`, + { + method: "POST", + headers: { + authorization: `Bearer ${claimed.token}`, + "content-type": "application/json", + "x-real-ip": "203.0.113.10", + }, + body: JSON.stringify({ + markdown: "# Publish limit\n\nsame body", + slug: "same-page", + }), + }, + ); + + expect(limited.status).toBe(429); + }); + + it("rejects publish attempts with the wrong token", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "pubmd-server-")); + server = await startTestServer(root); + + const claimResponse = await fetch( + `${server.origin}/api/namespaces/auth-test/claim`, + { + method: "POST", + }, + ); + expect(claimResponse.status).toBe(201); + + const response = await fetch( + `${server.origin}/api/namespaces/auth-test/pages/publish`, + { + method: "POST", + headers: { + authorization: "Bearer wrong-token", + "content-type": "application/json", + }, + body: JSON.stringify({ + markdown: "# nope", + }), + }, + ); + + expect(response.status).toBe(401); + }); + + it("rejects slug conflicts when republishing a different page into an occupied slug", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "pubmd-server-")); + server = await startTestServer(root); + + const claimResponse = await fetch( + `${server.origin}/api/namespaces/conflict-test/claim`, + { + method: "POST", + }, + ); + const claimed = (await claimResponse.json()) as { token: string }; + + const pageOneResponse = await fetch( + `${server.origin}/api/namespaces/conflict-test/pages/publish`, + { + method: "POST", + headers: { + authorization: `Bearer ${claimed.token}`, + "content-type": "application/json", + }, + body: JSON.stringify({ + markdown: "# page one", + slug: "page-one", + }), + }, + ); + const pageOne = (await pageOneResponse.json()) as { pageId: string }; + + const pageTwoResponse = await fetch( + `${server.origin}/api/namespaces/conflict-test/pages/publish`, + { + method: "POST", + headers: { + authorization: `Bearer ${claimed.token}`, + "content-type": "application/json", + }, + body: JSON.stringify({ + markdown: "# page two", + slug: "page-two", + }), + }, + ); + expect(pageTwoResponse.status).toBe(201); + + const conflictingUpdate = await fetch( + `${server.origin}/api/namespaces/conflict-test/pages/publish`, + { + method: "POST", + headers: { + authorization: `Bearer ${claimed.token}`, + "content-type": "application/json", + }, + body: JSON.stringify({ + markdown: "# page one updated", + pageId: pageOne.pageId, + slug: "page-two", + }), + }, + ); + + expect(conflictingUpdate.status).toBe(409); + }); }); diff --git a/tests/integration/test-server.ts b/tests/integration/test-server.ts index 0db21e4..cd03be5 100644 --- a/tests/integration/test-server.ts +++ b/tests/integration/test-server.ts @@ -8,13 +8,15 @@ import { createApp } from "../../src/server/app.js"; export interface StartedTestServer { close(): Promise; + dataDir: string; origin: string; } export async function startTestServer( rootDir: string, ): Promise { - const repository = createFileStore(path.join(rootDir, "data")); + const dataDir = path.join(rootDir, "data"); + const repository = createFileStore(dataDir); const service = createPublishService(repository); const app = createApp(service); const server = createServer(async (request, response) => { @@ -83,6 +85,7 @@ export async function startTestServer( } return { + dataDir, origin: `http://127.0.0.1:${address.port}`, async close() { await closeServer(server); diff --git a/tests/unit/blob-store.test.ts b/tests/unit/blob-store.test.ts index bd92b4e..3503d51 100644 --- a/tests/unit/blob-store.test.ts +++ b/tests/unit/blob-store.test.ts @@ -48,6 +48,18 @@ vi.mock("@vercel/blob", () => { stream: body, }; }), + list: vi.fn(async (options: { prefix?: string; token: string }) => { + const store = getStore(options.token); + const prefix = options.prefix ?? ""; + const blobs = [...store.keys()] + .filter((pathname) => pathname.startsWith(prefix)) + .map((pathname) => ({ pathname })); + + return { + blobs, + hasMore: false, + }; + }), put: vi.fn( async ( pathname: string, From 01dbe79c1e404c3079a2b67daec34db2ae23f99e Mon Sep 17 00:00:00 2001 From: restuta Date: Sun, 22 Mar 2026 10:50:56 +0700 Subject: [PATCH 2/2] fix: blockquote bold inherits muted color instead of forcing dark --- src/core/markdown.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/core/markdown.ts b/src/core/markdown.ts index 4f85667..725c846 100644 --- a/src/core/markdown.ts +++ b/src/core/markdown.ts @@ -233,7 +233,7 @@ export function buildHtmlDocument(input: { blockquote p { font-style: italic; } blockquote p:first-child { margin-top: 0; } blockquote p:last-child { margin-bottom: 0; } - blockquote strong { color: var(--fg); font-style: normal; } + blockquote strong { color: inherit; font-style: normal; } hr { border: none;