Files
xbin/src/index.ts
T

726 lines
24 KiB
TypeScript

/**
* Cloudflare Worker entrypoint for paste APIs, asset delivery, and background cleanup.
*/
import { DurableObject } from 'cloudflare:workers';
import { resolveConfig } from './lib/config';
import {
assertCommentInput,
assertPasteInput,
assertValidId,
GENERIC_MISSING_MESSAGE,
HttpError,
isLegacyJsonApiCall,
parseLegacyPasteId,
} from './lib/model';
import {
claimBurnAfterReading,
consumeBurnAfterReading,
coordinatorClaim,
coordinatorConsume,
createComment,
createPaste,
deletePasteByUser,
ensurePasteReadable,
enqueueGcMessages,
findExpiredPasteIds,
getPasteRow,
importPrivateBinBundle,
loadPasteResponse,
purgePasteStorage,
releaseExpiredClaims,
} from './lib/repository';
import type { GcMessage } from './lib/types';
const CREATE_PASTE_BODY_SLACK_BYTES = 512_000;
const IMPORT_BODY_SLACK_BYTES = 2_000_000;
const DELETE_PASTE_BODY_MAX_BYTES = 16_384;
const CONSUME_PASTE_BODY_MAX_BYTES = 8192;
const PASTE_ROUTE_PATTERN = /^\/api\/v1\/pastes\/([a-f0-9]{16})(?:\/(comments|consume))?$/i;
type DeletePastePayload = {
deleteToken?: string;
deletetoken?: string;
};
type ConsumePastePayload = {
claimToken?: string;
};
type LegacyCommentPayload = {
pasteid?: string;
parentid?: string;
};
const JSON_HEADERS = {
'content-type': 'application/json; charset=utf-8',
'cache-control': 'no-store',
pragma: 'no-cache',
expires: '0',
'access-control-allow-origin': '*',
'access-control-allow-methods': 'GET, POST, DELETE, OPTIONS',
'access-control-allow-headers': 'Authorization, Content-Type, X-Import-Token, X-Requested-With',
'access-control-expose-headers': 'X-Uncompressed-Content-Length',
} as const satisfies Record<string, string>;
const SECURITY_HEADERS = {
'cross-origin-resource-policy': 'same-origin',
'permissions-policy': 'browsing-topics=()',
'referrer-policy': 'no-referrer',
'x-content-type-options': 'nosniff',
'x-frame-options': 'DENY',
} as const satisfies Record<string, string>;
type SeoDocument = {
title: string;
description: string;
robots: string;
canonicalUrl: string;
ogTitle: string;
ogDescription: string;
twitterTitle: string;
twitterDescription: string;
};
const HOMEPAGE_ONE_LINER = 'Private, end-to-end encrypted pastebin for secure notes, code, and files.';
const HOMEPAGE_TITLE_SUFFIX = 'Private, end-to-end encrypted pastebin';
// Response helpers shared by the modern API and the legacy PrivateBin-compatible surface.
function json(data: unknown, init: ResponseInit = {}): Response {
const body = JSON.stringify(data);
const headers = new Headers(init.headers);
for (const [key, value] of Object.entries(JSON_HEADERS)) {
headers.set(key, value);
}
headers.set('x-uncompressed-content-length', String(body.length));
return new Response(body, { ...init, headers });
}
function privateBinSuccess(data: Record<string, unknown>, init: ResponseInit = {}): Response {
return json({ status: 0, ...data }, init);
}
function privateBinError(message: string, status = 400): Response {
return json({ status: 1, message }, { status });
}
function apiError(error: unknown): Response {
if (error instanceof HttpError) {
return json({ error: error.message }, { status: error.status });
}
console.error(error);
return json({ error: 'Internal server error.' }, { status: 500 });
}
// Request parsing and request-level validation keep the route handlers focused on behavior.
function normalizeBasePath(basePath: string): string {
if (!basePath || basePath === '/') {
return '/';
}
let start = 0;
let end = basePath.length;
while (start < end && basePath.charCodeAt(start) === 47) {
start += 1;
}
while (end > start && basePath.charCodeAt(end - 1) === 47) {
end -= 1;
}
return start === end ? '/' : `/${basePath.slice(start, end)}`;
}
function currentShareBase(url: URL, env: Env): string {
return `${url.origin}${normalizeBasePath(resolveConfig(env).basePath)}`;
}
function currentShareUrl(url: URL, env: Env, pasteId: string): string {
return `${currentShareBase(url, env)}?${pasteId}`;
}
function isBareShareQuery(url: URL): boolean {
return url.search.length > 1 && !url.search.includes('=');
}
function isShareLikeNavigation(url: URL): boolean {
const searchParams = url.searchParams;
return isBareShareQuery(url) || searchParams.has('pasteid') || searchParams.has('deletetoken') || url.pathname === '/api/v1/pastes';
}
function buildSeoDocument(url: URL, env: Env): SeoDocument {
const { appName } = resolveConfig(env);
const canonicalUrl = currentShareBase(url, env);
if (isShareLikeNavigation(url)) {
return {
title: `Encrypted note on ${appName}`,
description:
'Open a secure Xbin share link to decrypt and view a private note in your browser. Shared notes stay out of search results.',
robots: 'noindex, noarchive',
canonicalUrl,
ogTitle: `Encrypted note on ${appName}`,
ogDescription: 'Visit this link to see the note. Giving the URL to anyone allows them to access the note, too.',
twitterTitle: `Encrypted note on ${appName}`,
twitterDescription: 'Visit this link to see the note. Giving the URL to anyone allows them to access the note, too.',
};
}
return {
title: `${appName} - ${HOMEPAGE_TITLE_SUFFIX}`,
description: `${appName} is a private, end-to-end encrypted pastebin for secure notes, code, and files, with passwords, expiration, and burn-after-reading links.`,
robots: 'index,follow',
canonicalUrl,
ogTitle: `${appName} - ${HOMEPAGE_TITLE_SUFFIX}`,
ogDescription: `${HOMEPAGE_ONE_LINER} Add passwords, expiration, and burn-after-reading links when you share.`,
twitterTitle: `${appName} - ${HOMEPAGE_TITLE_SUFFIX}`,
twitterDescription: `${HOMEPAGE_ONE_LINER} Add passwords, expiration, and burn-after-reading links when you share.`,
};
}
function buildJsonRequest(request: Request, payload: unknown): Request {
return new Request(request.url, {
method: request.method,
headers: request.headers,
body: JSON.stringify(payload),
});
}
function getDeleteToken(payload: unknown): string | null {
const candidate = payload as DeletePastePayload;
if (typeof candidate.deleteToken === 'string') {
return candidate.deleteToken;
}
return typeof candidate.deletetoken === 'string' ? candidate.deletetoken : null;
}
function getClaimToken(payload: unknown): string | null {
const candidate = payload as ConsumePastePayload;
return typeof candidate.claimToken === 'string' ? candidate.claimToken : null;
}
function shouldServeAppShellForShareNavigation(request: Request, url: URL): boolean {
if (request.method !== 'GET' || url.pathname !== '/api/v1/pastes') {
return false;
}
if (url.search.length <= 1 || url.search.includes('=')) {
return false;
}
const accept = request.headers.get('accept') ?? '';
return request.headers.get('sec-fetch-mode') === 'navigate' || accept.includes('text/html');
}
async function parseJsonBody(request: Request, maxBytes: number): Promise<unknown> {
const contentLength = request.headers.get('content-length');
if (contentLength && Number(contentLength) > maxBytes) {
throw new HttpError(413, 'Request exceeds the maximum body size.');
}
const text = await request.text();
if (text.length > maxBytes) {
throw new HttpError(413, 'Request exceeds the maximum body size.');
}
if (!text) {
throw new HttpError(400, 'Request body is required.');
}
try {
return JSON.parse(text);
} catch {
throw new HttpError(400, 'Request body must be valid JSON.');
}
}
async function verifyTurnstileIfRequired(request: Request, env: Env, token: string | null): Promise<void> {
const config = resolveConfig(env);
if (!config.requireTurnstile) {
return;
}
if (!env.TURNSTILE_SECRET_KEY) {
throw new HttpError(500, 'Turnstile is required but TURNSTILE_SECRET_KEY is missing.');
}
if (!token) {
throw new HttpError(400, 'Turnstile token is required.');
}
const form = new FormData();
form.set('secret', env.TURNSTILE_SECRET_KEY);
form.set('response', token);
const remoteIp = request.headers.get('CF-Connecting-IP');
if (remoteIp) {
form.set('remoteip', remoteIp);
}
const response = await fetch('https://challenges.cloudflare.com/turnstile/v0/siteverify', {
method: 'POST',
body: form,
});
const result = (await response.json()) as { success?: boolean; ['error-codes']?: string[] };
if (!result.success) {
throw new HttpError(
400,
`Turnstile verification failed${result['error-codes']?.length ? `: ${result['error-codes'].join(', ')}` : '.'}`,
);
}
}
function withSecurityHeaders(response: Response, robotsTag = 'noindex, noarchive', disableCaching = false): Response {
const headers = new Headers(response.headers);
for (const [key, value] of Object.entries(SECURITY_HEADERS)) {
headers.set(key, value);
}
headers.set(
'content-security-policy',
"default-src 'none'; base-uri 'none'; connect-src 'self'; form-action 'self'; img-src 'self' data: blob:; media-src 'self' blob:; script-src 'self'; style-src 'self'; font-src 'self'; object-src 'none'; frame-ancestors 'none'",
);
headers.set('x-robots-tag', robotsTag);
if (disableCaching) {
headers.set('cache-control', 'no-store');
headers.set('pragma', 'no-cache');
headers.set('expires', '0');
}
return new Response(response.body, {
status: response.status,
statusText: response.statusText,
headers,
});
}
// API handlers translate validated requests into repository operations and response envelopes.
async function handleCreatePaste(request: Request, env: Env, legacy = false): Promise<Response> {
const config = resolveConfig(env);
const payload = await parseJsonBody(request, config.maxPasteBytes + CREATE_PASTE_BODY_SLACK_BYTES);
const input = assertPasteInput(payload, config.maxPasteBytes, config.supportedExpirations);
await verifyTurnstileIfRequired(request, env, input.turnstileToken);
const created = await createPaste(env, input, Date.now());
const url = new URL(request.url);
const shareUrl = currentShareUrl(url, env, created.id);
if (legacy) {
return privateBinSuccess(
{
id: created.id,
url: shareUrl,
deletetoken: created.deleteToken,
},
{ status: 201 },
);
}
return json(
{
id: created.id,
shareUrl,
deleteToken: created.deleteToken,
},
{ status: 201 },
);
}
async function handleGetPaste(request: Request, env: Env, pasteId: string, legacy = false): Promise<Response> {
assertValidId(pasteId, 'paste identifier');
const now = Date.now();
const row = ensurePasteReadable(await getPasteRow(env, pasteId), now);
let claimToken: string | undefined;
if (row.burn_after_reading === 1) {
const claim = await claimBurnAfterReading(env, pasteId);
if (!claim.ok || !claim.claimToken) {
throw new HttpError(409, claim.message ?? 'Document is already being viewed.');
}
claimToken = claim.claimToken;
}
const responsePayload = await loadPasteResponse(env, row, now);
if (!responsePayload) {
throw new HttpError(404, GENERIC_MISSING_MESSAGE);
}
if (claimToken) {
responsePayload.claimToken = claimToken;
}
if (legacy) {
return privateBinSuccess(
{
url: currentShareUrl(new URL(request.url), env, pasteId),
...responsePayload,
},
{ status: 200 },
);
}
return json(responsePayload, { status: 200 });
}
async function handleDeletePaste(request: Request, env: Env, ctx: ExecutionContext, pasteId: string, legacy = false): Promise<Response> {
assertValidId(pasteId, 'paste identifier');
const payload = await parseJsonBody(request, DELETE_PASTE_BODY_MAX_BYTES);
const deleteToken = getDeleteToken(payload);
if (!deleteToken) {
throw new HttpError(400, 'Delete token is required.');
}
const deleted = await deletePasteByUser(env, pasteId, deleteToken);
if (!deleted) {
if (legacy) {
return privateBinError('Wrong deletion token. Document was not deleted.', 403);
}
throw new HttpError(403, 'Delete token is invalid.');
}
ctx.waitUntil(purgePasteStorage(env, pasteId, 'deleted'));
if (legacy) {
return privateBinSuccess({ id: pasteId, url: currentShareUrl(new URL(request.url), env, pasteId) });
}
return json({ id: pasteId, deleted: true });
}
async function handleCreateComment(request: Request, env: Env, pasteId: string, legacy = false): Promise<Response> {
assertValidId(pasteId, 'paste identifier');
const config = resolveConfig(env);
const payload = await parseJsonBody(request, config.maxPasteBytes);
const input = assertCommentInput(payload, config.maxPasteBytes, pasteId);
await verifyTurnstileIfRequired(request, env, input.turnstileToken);
const row = ensurePasteReadable(await getPasteRow(env, pasteId), Date.now());
if (row.burn_after_reading === 1 || row.discussion_open !== 1) {
throw new HttpError(400, 'Discussion is disabled for this paste.');
}
const commentId = await createComment(env, pasteId, input, Date.now());
if (legacy) {
return privateBinSuccess(
{
id: commentId,
url: currentShareUrl(new URL(request.url), env, pasteId),
},
{ status: 201 },
);
}
return json({ id: commentId }, { status: 201 });
}
async function handleConsumePaste(request: Request, env: Env, pasteId: string): Promise<Response> {
assertValidId(pasteId, 'paste identifier');
const payload = await parseJsonBody(request, CONSUME_PASTE_BODY_MAX_BYTES);
const claimToken = getClaimToken(payload);
if (!claimToken) {
throw new HttpError(400, 'Claim token is required.');
}
const result = await consumeBurnAfterReading(env, pasteId, claimToken);
if (!result.ok) {
throw new HttpError(400, result.message ?? 'Document could not be consumed.');
}
return json({ consumed: true });
}
async function handleConfig(env: Env): Promise<Response> {
const config = resolveConfig(env);
return json({
appName: config.appName,
appVersion: config.appVersion,
projectPageUrl: config.projectPageUrl,
basePath: config.basePath,
maxPasteBytes: config.maxPasteBytes,
defaultExpiration: config.defaultExpiration,
supportedExpirations: config.supportedExpirations,
requireTurnstile: config.requireTurnstile,
turnstileSiteKey: config.turnstileSiteKey,
uiLanguageLabel: config.uiLanguageLabel,
uiLanguageCode: config.uiLanguageCode,
uiLanguageName: config.uiLanguageName,
uiLanguageRtl: config.uiLanguageRtl,
uiLanguages: config.uiLanguages,
uiThemeLabel: config.uiThemeLabel,
uiThemes: config.uiThemes,
});
}
function getImportTokenFromRequest(request: Request): string | null {
const authorization = request.headers.get('authorization');
if (authorization?.startsWith('Bearer ')) {
return authorization.slice('Bearer '.length).trim();
}
return request.headers.get('x-import-token');
}
function assertImportAuthorized(request: Request, env: Env): void {
if (!env.IMPORT_TOKEN) {
throw new HttpError(404, 'Import endpoint is not enabled.');
}
const provided = getImportTokenFromRequest(request);
if (!provided || provided !== env.IMPORT_TOKEN) {
throw new HttpError(401, 'Import token is invalid.');
}
}
async function handlePrivateBinImport(request: Request, env: Env): Promise<Response> {
assertImportAuthorized(request, env);
const payload = await parseJsonBody(request, resolveConfig(env).maxPasteBytes + IMPORT_BODY_SLACK_BYTES);
const result = await importPrivateBinBundle(env, payload as Parameters<typeof importPrivateBinBundle>[1], Date.now());
return json(result, { status: result.skipped ? 200 : 201 });
}
// Routing keeps the modern REST API and the legacy PrivateBin surface in one worker.
async function handleLegacyApi(request: Request, env: Env, ctx: ExecutionContext, url: URL): Promise<Response> {
const pasteId = parseLegacyPasteId(url);
switch (request.method) {
case 'GET':
if (!pasteId) {
throw new HttpError(400, 'Paste identifier is required.');
}
return handleGetPaste(request, env, pasteId, true);
case 'POST': {
const body = await parseJsonBody(request, resolveConfig(env).maxPasteBytes + CREATE_PASTE_BODY_SLACK_BYTES);
const candidate = body as LegacyCommentPayload;
if (typeof candidate.pasteid === 'string') {
return handleCreateComment(
buildJsonRequest(request, {
...(body as Record<string, unknown>),
parentId: candidate.parentid ?? candidate.pasteid,
}),
env,
candidate.pasteid,
true,
);
}
return handleCreatePaste(buildJsonRequest(request, body), env, true);
}
case 'DELETE':
if (!pasteId) {
throw new HttpError(400, 'Paste identifier is required.');
}
return handleDeletePaste(request, env, ctx, pasteId, true);
default:
throw new HttpError(405, 'Method not allowed.');
}
}
async function routeApi(request: Request, env: Env, ctx: ExecutionContext, url: URL): Promise<Response> {
if (request.method === 'OPTIONS') {
return new Response(null, { status: 204, headers: JSON_HEADERS });
}
if (url.pathname === '/api/v1/config' && request.method === 'GET') {
return handleConfig(env);
}
if (url.pathname === '/api/v1/pastes' && request.method === 'POST') {
return handleCreatePaste(request, env);
}
if (url.pathname === '/api/v1/admin/import/privatebin' && request.method === 'POST') {
return handlePrivateBinImport(request, env);
}
const match = url.pathname.match(PASTE_ROUTE_PATTERN);
if (!match) {
throw new HttpError(404, 'API route not found.');
}
const [, pasteId, action] = match;
if (!action && request.method === 'GET') {
return handleGetPaste(request, env, pasteId);
}
if (!action && request.method === 'DELETE') {
return handleDeletePaste(request, env, ctx, pasteId);
}
if (action === 'comments' && request.method === 'POST') {
return handleCreateComment(request, env, pasteId);
}
if (action === 'consume' && request.method === 'POST') {
return handleConsumePaste(request, env, pasteId);
}
throw new HttpError(405, 'Method not allowed.');
}
// Asset responses are normalized because the static binding may fall back to text/plain.
function correctedAssetContentType(request: Request, assetResponse: Response): string {
const currentType = assetResponse.headers.get('content-type') ?? '';
if (currentType && !currentType.startsWith('text/plain')) {
return currentType;
}
const pathname = new URL(request.url).pathname.toLowerCase();
if (pathname.endsWith('.html') || pathname.endsWith('.htm')) {
return 'text/html; charset=utf-8';
}
if (pathname.endsWith('.css')) {
return 'text/css; charset=utf-8';
}
if (pathname.endsWith('.js') || pathname.endsWith('.mjs')) {
return 'text/javascript; charset=utf-8';
}
if (pathname.endsWith('.json') || pathname.endsWith('.map')) {
return 'application/json; charset=utf-8';
}
if (pathname === '/' || !pathname.includes('.')) {
return 'text/html; charset=utf-8';
}
return currentType || 'application/octet-stream';
}
class AttributeRewriter {
constructor(
private readonly attributeName: string,
private readonly value: string,
) {}
element(element: Element): void {
element.setAttribute(this.attributeName, this.value);
}
}
class TextRewriter {
constructor(private readonly value: string) {}
element(element: Element): void {
element.setInnerContent(this.value);
}
}
function rewriteSeoMarkup(response: Response, seo: SeoDocument): Response {
return new HTMLRewriter()
.on('title', new TextRewriter(seo.title))
.on('meta#meta-robots', new AttributeRewriter('content', seo.robots))
.on('meta#meta-description', new AttributeRewriter('content', seo.description))
.on('link#link-canonical', new AttributeRewriter('href', seo.canonicalUrl))
.on('meta#meta-og-url', new AttributeRewriter('content', seo.canonicalUrl))
.on('meta#meta-og-title', new AttributeRewriter('content', seo.ogTitle))
.on('meta#meta-og-description', new AttributeRewriter('content', seo.ogDescription))
.on('meta#meta-twitter-title', new AttributeRewriter('content', seo.twitterTitle))
.on('meta#meta-twitter-description', new AttributeRewriter('content', seo.twitterDescription))
.transform(response);
}
function robotsTxt(url: URL, env: Env): string {
const sitemapUrl = new URL('sitemap.xml', currentShareBase(url, env));
return `User-agent: *\nAllow: /\nDisallow: /api/\nSitemap: ${sitemapUrl.toString()}\n`;
}
function sitemapXml(url: URL, env: Env): string {
const homepageUrl = currentShareBase(url, env);
return [
'<?xml version="1.0" encoding="UTF-8"?>',
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">',
' <url>',
` <loc>${homepageUrl}</loc>`,
' <changefreq>weekly</changefreq>',
' <priority>1.0</priority>',
' </url>',
'</urlset>',
].join('\n');
}
async function handleAssetRequest(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url);
if (request.method === 'GET' && url.pathname === '/robots.txt') {
return withSecurityHeaders(
new Response(robotsTxt(url, env), {
headers: { 'content-type': 'text/plain; charset=utf-8' },
}),
'index,follow',
);
}
if (request.method === 'GET' && url.pathname === '/sitemap.xml') {
return withSecurityHeaders(
new Response(sitemapXml(url, env), {
headers: { 'content-type': 'application/xml; charset=utf-8' },
}),
'index,follow',
);
}
const assetResponse = await env.ASSETS.fetch(request);
const contentType = correctedAssetContentType(request, assetResponse);
const headers = new Headers(assetResponse.headers);
headers.set('content-type', contentType);
let response = new Response(assetResponse.body, {
status: assetResponse.status,
statusText: assetResponse.statusText,
headers,
});
const seo = buildSeoDocument(url, env);
if (contentType.includes('text/html')) {
response = rewriteSeoMarkup(response, seo);
}
return withSecurityHeaders(response, seo.robots, contentType.includes('text/html'));
}
// The coordinator serializes burn-after-reading claims so only one view can consume a paste.
export class PasteCoordinator extends DurableObject<Env> {
async fetch(request: Request): Promise<Response> {
const url = new URL(request.url);
const payload = (await request.json()) as { pasteId?: string; claimToken?: string };
if (!payload.pasteId) {
return json({ ok: false, message: 'Paste identifier is required.' }, { status: 400 });
}
const config = resolveConfig(this.env);
if (url.pathname === '/claim') {
return json(await coordinatorClaim(this.env, payload.pasteId, config.burnClaimTtlSeconds * 1000));
}
if (url.pathname === '/consume') {
if (!payload.claimToken) {
return json({ ok: false, message: 'Claim token is required.' }, { status: 400 });
}
return json(await coordinatorConsume(this.env, payload.pasteId, payload.claimToken));
}
return json({ ok: false, message: 'Route not found.' }, { status: 404 });
}
}
// Worker entrypoints handle HTTP traffic, scheduled cleanup, and queue-driven garbage collection.
export default {
async fetch(request, env, ctx): Promise<Response> {
const url = new URL(request.url);
const config = resolveConfig(env);
try {
// Some older share links still target the API route, so browser navigations should receive
// the SPA shell and let the client recover the query or hash locally.
if (shouldServeAppShellForShareNavigation(request, url)) {
return await handleAssetRequest(request, env);
}
if (url.pathname.startsWith('/api/')) {
return await routeApi(request, env, ctx, url);
}
if (config.enableLegacyApi && isLegacyJsonApiCall(request)) {
return await handleLegacyApi(request, env, ctx, url);
}
return await handleAssetRequest(request, env);
} catch (error) {
if (config.enableLegacyApi && isLegacyJsonApiCall(request)) {
if (error instanceof HttpError) {
return privateBinError(error.message, error.status);
}
console.error(error);
return privateBinError('Internal server error.', 500);
}
return apiError(error);
}
},
async scheduled(_event, env, ctx): Promise<void> {
const now = Date.now();
await releaseExpiredClaims(env, now);
const expiredIds = await findExpiredPasteIds(env, now);
if (expiredIds.length === 0) {
return;
}
const messages: GcMessage[] = expiredIds.map((pasteId) => ({
pasteId,
finalStatus: 'expired',
}));
ctx.waitUntil(enqueueGcMessages(env, messages));
},
async queue(batch, env): Promise<void> {
for (const message of batch.messages) {
try {
const body = message.body as GcMessage;
await purgePasteStorage(env, body.pasteId, body.finalStatus);
message.ack();
} catch (error) {
console.error('Queue processing failed', error);
message.retry();
}
}
},
} satisfies ExportedHandler<Env>;