192 lines
6.2 KiB
TypeScript
192 lines
6.2 KiB
TypeScript
/**
|
|
* Request-model helpers that validate paste envelopes and bridge modern and legacy payloads.
|
|
*/
|
|
import type { ExpireKey } from './config';
|
|
import type { CipherEnvelope, CipherSpec, CreateCommentInput, CreatePasteInput, EnvelopeAdata, PasteEnvelopeAdata } from './types';
|
|
|
|
export const GENERIC_MISSING_MESSAGE = 'Document does not exist, has expired or has been deleted.';
|
|
const INVALID_DATA_MESSAGE = 'Invalid data.';
|
|
|
|
type PasteInputEnvelope = CipherEnvelope & {
|
|
meta?: { expire?: string };
|
|
turnstileToken?: string;
|
|
};
|
|
|
|
type CommentInputEnvelope = CipherEnvelope & {
|
|
parentId?: string;
|
|
parentid?: string;
|
|
pasteid?: string;
|
|
turnstileToken?: string;
|
|
};
|
|
|
|
export class HttpError extends Error {
|
|
constructor(
|
|
public readonly status: number,
|
|
message: string,
|
|
) {
|
|
super(message);
|
|
}
|
|
}
|
|
|
|
export function isValidId(value: string): boolean {
|
|
return /^[a-f0-9]{16}$/i.test(value);
|
|
}
|
|
|
|
export function assertValidId(value: string, label = 'identifier'): void {
|
|
if (!isValidId(value)) {
|
|
throw new HttpError(400, `Invalid ${label}.`);
|
|
}
|
|
}
|
|
|
|
export function parseLegacyPasteId(url: URL): string | null {
|
|
if (url.searchParams.has('pasteid')) {
|
|
return url.searchParams.get('pasteid');
|
|
}
|
|
if (url.search.length > 1 && !url.search.includes('=')) {
|
|
return url.search.slice(1);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
export function isLegacyJsonApiCall(request: Request): boolean {
|
|
return request.headers.get('X-Requested-With') === 'JSONHttpRequest';
|
|
}
|
|
|
|
export function isCipherSpec(value: unknown): value is CipherSpec {
|
|
return (
|
|
Array.isArray(value) &&
|
|
value.length >= 8 &&
|
|
typeof value[0] === 'string' &&
|
|
typeof value[1] === 'string' &&
|
|
typeof value[2] === 'number' &&
|
|
typeof value[3] === 'number' &&
|
|
typeof value[4] === 'number' &&
|
|
typeof value[5] === 'string' &&
|
|
typeof value[6] === 'string' &&
|
|
typeof value[7] === 'string'
|
|
);
|
|
}
|
|
|
|
export function isPasteEnvelopeAdata(value: unknown): value is PasteEnvelopeAdata {
|
|
return Array.isArray(value) && value.length >= 1 && (value[0] === null || isCipherSpec(value[0]));
|
|
}
|
|
|
|
export function isLegacyCommentAdata(value: unknown): value is CipherSpec {
|
|
return isCipherSpec(value);
|
|
}
|
|
|
|
export function isLegacyCommentEnvelope(envelope: Pick<CipherEnvelope, 'adata'>): boolean {
|
|
return isLegacyCommentAdata(envelope.adata);
|
|
}
|
|
|
|
export function getCipherSpecFromAdata(adata: EnvelopeAdata): CipherSpec | null {
|
|
if (isPasteEnvelopeAdata(adata)) {
|
|
return adata[0];
|
|
}
|
|
if (isLegacyCommentAdata(adata)) {
|
|
return adata;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
export function getEnvelopeFlags(envelope: CipherEnvelope): {
|
|
formatter: string;
|
|
discussionOpen: boolean;
|
|
burnAfterReading: boolean;
|
|
} {
|
|
const adata = isPasteEnvelopeAdata(envelope.adata) ? envelope.adata : null;
|
|
return {
|
|
formatter: typeof adata?.[1] === 'string' && adata[1].length > 0 ? adata[1] : 'plaintext',
|
|
discussionOpen: Number(adata?.[2] ?? 0) === 1,
|
|
burnAfterReading: Number(adata?.[3] ?? 0) === 1,
|
|
};
|
|
}
|
|
|
|
// Internal shape guards keep the public assert helpers readable and consistent.
|
|
function assertEnvelopeBase(envelope: unknown): asserts envelope is CipherEnvelope {
|
|
if (!envelope || typeof envelope !== 'object') {
|
|
throw new HttpError(400, INVALID_DATA_MESSAGE);
|
|
}
|
|
const candidate = envelope as Partial<CipherEnvelope>;
|
|
if (candidate.v !== 2 || typeof candidate.ct !== 'string' || !Array.isArray(candidate.adata)) {
|
|
throw new HttpError(400, INVALID_DATA_MESSAGE);
|
|
}
|
|
}
|
|
|
|
function assertPasteEnvelopeShape(envelope: unknown): asserts envelope is CipherEnvelope {
|
|
assertEnvelopeBase(envelope);
|
|
const candidate = envelope as CipherEnvelope;
|
|
if (!isPasteEnvelopeAdata(candidate.adata) || !candidate.adata[0]) {
|
|
throw new HttpError(400, INVALID_DATA_MESSAGE);
|
|
}
|
|
}
|
|
|
|
function assertCommentEnvelopeShape(envelope: unknown): asserts envelope is CipherEnvelope {
|
|
assertEnvelopeBase(envelope);
|
|
const candidate = envelope as CipherEnvelope;
|
|
const spec = getCipherSpecFromAdata(candidate.adata);
|
|
if (!spec) {
|
|
throw new HttpError(400, INVALID_DATA_MESSAGE);
|
|
}
|
|
}
|
|
|
|
function encryptedPayloadLength(envelope: CipherEnvelope): number {
|
|
let total = envelope.ct.length;
|
|
if (envelope.attachment) {
|
|
total += encryptedPayloadLength(envelope.attachment);
|
|
}
|
|
if (envelope.attachment_name) {
|
|
total += encryptedPayloadLength(envelope.attachment_name);
|
|
}
|
|
return total;
|
|
}
|
|
|
|
function getTurnstileToken(candidate: { turnstileToken?: string }): string | null {
|
|
return typeof candidate.turnstileToken === 'string' ? candidate.turnstileToken : null;
|
|
}
|
|
|
|
function resolveExpireKey(requestedExpire: unknown, supportedExpirations: readonly ExpireKey[]): ExpireKey {
|
|
return typeof requestedExpire === 'string' && supportedExpirations.includes(requestedExpire as ExpireKey)
|
|
? (requestedExpire as ExpireKey)
|
|
: supportedExpirations[0];
|
|
}
|
|
|
|
// Public assertions turn untyped request bodies into normalized repository inputs.
|
|
export function assertPasteInput(payload: unknown, maxPasteBytes: number, supportedExpirations: readonly ExpireKey[]): CreatePasteInput {
|
|
assertPasteEnvelopeShape(payload);
|
|
const envelope = payload as PasteInputEnvelope;
|
|
|
|
if (encryptedPayloadLength(envelope) > maxPasteBytes) {
|
|
throw new HttpError(413, 'Document exceeds the maximum encrypted size.');
|
|
}
|
|
|
|
const { burnAfterReading, discussionOpen } = getEnvelopeFlags(envelope);
|
|
if (burnAfterReading && discussionOpen) {
|
|
throw new HttpError(400, 'Burn-after-reading pastes cannot have discussions enabled.');
|
|
}
|
|
|
|
return {
|
|
envelope,
|
|
expireKey: resolveExpireKey(envelope.meta?.expire, supportedExpirations),
|
|
turnstileToken: getTurnstileToken(envelope),
|
|
};
|
|
}
|
|
|
|
export function assertCommentInput(payload: unknown, maxPasteBytes: number, pasteId: string): CreateCommentInput {
|
|
assertCommentEnvelopeShape(payload);
|
|
const candidate = payload as CommentInputEnvelope;
|
|
if (encryptedPayloadLength(candidate) > maxPasteBytes) {
|
|
throw new HttpError(413, 'Comment exceeds the maximum encrypted size.');
|
|
}
|
|
if (typeof candidate.pasteid === 'string' && candidate.pasteid !== pasteId) {
|
|
throw new HttpError(400, 'Comment paste identifier does not match the route.');
|
|
}
|
|
const parentIdValue = candidate.parentId ?? candidate.parentid ?? pasteId;
|
|
assertValidId(parentIdValue, 'parent identifier');
|
|
return {
|
|
envelope: candidate,
|
|
parentId: parentIdValue,
|
|
turnstileToken: getTurnstileToken(candidate),
|
|
};
|
|
}
|