chatd/src/personal/parse.ts
Rama main · réplica de lectura del repositorio original.
import type { ActorCredential, NewPersonalChannel, PersonalInboxWindow, PersonalMentions } from "./gateway.ts";
import { canonicalChannelName } from "../channels.ts";
import type { AttachmentAlreadyKept } from "../outgoing-attachment.ts";
export class MalformedRequest extends Error {
constructor(message: string) {
super(message);
this.name = "MalformedRequest";
}
}
function missing(field: string): MalformedRequest {
return new MalformedRequest(`missing required field ${field}`);
}
export type PersonRequest = {
actor: ActorCredential;
conversationID?: string;
messageID?: string;
body?: string;
parentID?: string;
emoji?: string;
before?: string;
since?: string;
until?: string;
heldIDs: string[];
query?: string;
name?: string;
externalID?: string;
largestBytes?: number;
arrivalsURL?: string;
typingURL?: string;
withdrawalsURL?: string;
readAt?: string;
mediaURL?: string;
range?: string;
sourceURL?: string;
contentType?: string;
counterpartExternalIDs: string[];
attachments: AttachmentAlreadyKept[];
mentions?: PersonalMentions;
timeZone?: string;
};
export function parseNewChannel(value: unknown): NewPersonalChannel {
const record = asRecord(value);
const visibility = record.visibility;
if (visibility !== "open" && visibility !== "private") {
throw new MalformedRequest("visibility must be open or private");
}
const name = requireText(record, "name");
if (!canonicalChannelName(name)) throw new MalformedRequest("a channel name needs more than a # prefix");
return {
name,
description: optionalText(record, "description"),
visibility,
memberExternalIDs: parseMemberExternalIDs(record),
};
}
export function parseMemberExternalIDs(value: unknown): string[] {
const members = asRecord(value).memberExternalIDs ?? [];
if (!Array.isArray(members) || members.some((entry) => typeof entry !== "string")) {
throw new MalformedRequest("memberExternalIDs must be a list of ids");
}
return members as string[];
}
const legacyBuzzSecretField = "userSecretHex";
const legacyCounterpartField = "counterpartPubkeyHex";
export function parsePersonRequest(value: unknown): PersonRequest {
const record = asRecord(value);
return {
actor: parseActor(record),
conversationID: optionalText(record, "conversationID"),
messageID: optionalText(record, "messageID"),
body: optionalText(record, "body"),
parentID: optionalText(record, "parentID"),
emoji: optionalText(record, "emoji"),
before: optionalText(record, "before"),
since: optionalText(record, "since"),
until: optionalText(record, "until"),
heldIDs: parseHeldIDs(record),
query: optionalText(record, "query"),
name: optionalText(record, "name"),
externalID: optionalText(record, "externalID"),
largestBytes: optionalCount(record, "largestBytes"),
arrivalsURL: optionalText(record, "arrivalsURL"),
typingURL: optionalText(record, "typingURL"),
withdrawalsURL: optionalText(record, "withdrawalsURL"),
readAt: optionalText(record, "readAt"),
mediaURL: optionalText(record, "mediaURL"),
range: optionalText(record, "range"),
sourceURL: optionalText(record, "sourceURL"),
contentType: optionalText(record, "contentType"),
counterpartExternalIDs: parseCounterparts(record),
attachments: parseAttachments(record),
mentions: parseMentions(record),
timeZone: parseTimeZone(record),
};
}
function parseTimeZone(record: Record<string, unknown>): string | undefined {
const timeZone = optionalText(record, "timeZone");
if (timeZone === undefined) return undefined;
try {
return new Intl.DateTimeFormat("en", { timeZone }).resolvedOptions().timeZone;
} catch {
throw new MalformedRequest(`timeZone ${JSON.stringify(timeZone)} is not an IANA time zone`);
}
}
function parseHeldIDs(record: Record<string, unknown>): string[] {
const given = record.heldIDs ?? [];
if (!Array.isArray(given) || given.some((entry) => typeof entry !== "string")) {
throw new MalformedRequest("heldIDs must be a list of message ids");
}
return given as string[];
}
function parseMentions(record: Record<string, unknown>): PersonalMentions | undefined {
const given = record.mentions;
if (given === undefined) return undefined;
const mentions = asRecord(given);
const externalIDs = mentions.externalIDs ?? [];
if (!Array.isArray(externalIDs) || externalIDs.some((entry) => typeof entry !== "string")) {
throw new MalformedRequest("mentions.externalIDs must be a list of ids");
}
return { externalIDs: externalIDs as string[], isEveryone: mentions.isEveryone === true };
}
function parseAttachments(record: Record<string, unknown>): AttachmentAlreadyKept[] {
const given = record.attachments;
if (given === undefined) return [];
if (!Array.isArray(given)) throw new MalformedRequest("attachments must be a list");
return given.map((entry) => parseKeptAttachment(asRecord(entry)));
}
export function parseKeptAttachment(attachment: Record<string, unknown>): AttachmentAlreadyKept {
return {
filename: requiredText(attachment, "filename"),
contentType: requiredText(attachment, "contentType"),
address: requiredText(attachment, "address"),
sizeBytes: requiredCount(attachment, "sizeBytes"),
digest: requiredText(attachment, "digest"),
};
}
function requiredCount(record: Record<string, unknown>, field: string): number {
const value = optionalCount(record, field);
if (value === undefined) throw missing(field);
return value;
}
function requiredText(record: Record<string, unknown>, field: string): string {
const value = record[field];
if (typeof value !== "string" || value.trim() === "") throw missing(field);
return value;
}
export function parseCredentialAnswers(value: unknown): Record<string, string> {
const given = asRecord(value).answers;
if (given === undefined) throw missing("answers");
const answers: Record<string, string> = {};
for (const [field, answer] of Object.entries(asRecord(given))) {
if (typeof answer === "string") answers[field] = answer;
}
return answers;
}
export function parseActor(record: Record<string, unknown>): ActorCredential {
const given = record.actor;
if (given !== undefined) {
const actor = asRecord(given);
return { kind: requireText(actor, "kind"), secret: requireText(actor, "secret") };
}
const legacySecret = optionalText(record, legacyBuzzSecretField);
if (legacySecret) return { kind: "buzz-secret", secret: legacySecret };
throw missing("actor");
}
export function requireConversation(request: PersonRequest): string {
if (!request.conversationID) throw missing("conversationID");
return request.conversationID;
}
export function requireReadAt(request: PersonRequest): Date {
if (!request.readAt) throw missing("readAt");
const readAt = new Date(request.readAt);
if (Number.isNaN(readAt.getTime())) throw new MalformedRequest("readAt must be an ISO 8601 time");
return readAt;
}
export function inboxWindowOf(request: PersonRequest): PersonalInboxWindow {
const since = optionalTime(request.since, "since");
const until = optionalTime(request.until, "until");
return { ...(since && { since }), ...(until && { until }), heldIDs: request.heldIDs };
}
function optionalTime(value: string | undefined, field: string): Date | undefined {
if (!value) return undefined;
const time = new Date(value);
if (Number.isNaN(time.getTime())) throw new MalformedRequest(`${field} must be an ISO 8601 time`);
return time;
}
export function requireQuery(request: PersonRequest): string {
if (!request.query?.trim()) throw missing("query");
return request.query;
}
export function requireMessage(request: PersonRequest): string {
if (!request.messageID) throw missing("messageID");
return request.messageID;
}
export function requireName(request: PersonRequest): string {
if (!request.name) throw missing("name");
return request.name;
}
export function requireExternalID(request: PersonRequest): string {
if (!request.externalID) throw missing("externalID");
return request.externalID;
}
export function requireMediaURL(request: PersonRequest): string {
if (!request.mediaURL) throw missing("mediaURL");
return request.mediaURL;
}
const singleRangePattern = /^bytes=\d+-\d+$/;
export function requireMediaRange(request: PersonRequest): string {
if (!request.range) throw missing("range");
if (!singleRangePattern.test(request.range)) throw new MalformedRequest("range must be one bytes=START-END range");
return request.range;
}
export function requireMediaSource(request: PersonRequest): { url: string; contentType: string } {
if (!request.sourceURL) throw missing("sourceURL");
return { url: request.sourceURL, contentType: request.contentType ?? "application/octet-stream" };
}
export function requireLargestBytes(request: PersonRequest): number {
if (!request.largestBytes) throw missing("largestBytes");
return request.largestBytes;
}
const loopbackHostnames = new Set(["127.0.0.1", "localhost"]);
export function requireLoopbackArrivalsURL(request: PersonRequest): string {
if (!request.arrivalsURL) throw missing("arrivalsURL");
return loopbackURL("arrivalsURL", request.arrivalsURL);
}
export function optionalLoopbackTypingURL(request: PersonRequest): string | undefined {
if (!request.typingURL) return undefined;
return loopbackURL("typingURL", request.typingURL);
}
export function optionalLoopbackWithdrawalsURL(request: PersonRequest): string | undefined {
if (!request.withdrawalsURL) return undefined;
return loopbackURL("withdrawalsURL", request.withdrawalsURL);
}
function loopbackURL(field: string, offered: string): string {
const parsed = URL.parse(offered);
if (!parsed || parsed.protocol !== "http:" || !loopbackHostnames.has(parsed.hostname)) {
throw new MalformedRequest(`${field} must be an http address on this machine's loopback`);
}
return parsed.toString();
}
function parseCounterparts(record: Record<string, unknown>): string[] {
const given = record.counterpartExternalIDs;
if (Array.isArray(given)) return given.filter((entry): entry is string => typeof entry === "string");
const legacy = optionalText(record, legacyCounterpartField);
return legacy ? [legacy] : [];
}
function asRecord(value: unknown): Record<string, unknown> {
if (typeof value !== "object" || value === null) {
throw new MalformedRequest("expected a JSON object");
}
return value as Record<string, unknown>;
}
function requireText(record: Record<string, unknown>, field: string): string {
const value = record[field];
if (typeof value !== "string" || value.trim().length === 0) {
throw missing(field);
}
return value;
}
function optionalText(record: Record<string, unknown>, field: string): string | undefined {
const value = record[field];
return typeof value === "string" && value.length > 0 ? value : undefined;
}
function optionalCount(record: Record<string, unknown>, field: string): number | undefined {
const value = record[field];
return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : undefined;
}