🌿 Collaborative wiki on ATProto lichen.wiki
atproto
14
fork

Configure Feed

Select the types of activity you want to include in your feed.

Add limits to files

juprodh b32931ca c7239968

+254 -10
+86 -4
src/firehose/handlers.ts
··· 1 1 import type { CommitEvt } from "@atproto/sync"; 2 2 import { didOwnsUri } from "../lib/at-uri.ts"; 3 3 import { COLLECTIONS, normalizeRole } from "../lib/constants.ts"; 4 + import { LIMITS } from "../lib/limits.ts"; 4 5 import { 5 6 applyRevisionFromFirehose, 6 7 deleteBookmarkByUri, ··· 66 67 createdAt: string; 67 68 } 68 69 70 + // --- Size guards --- 71 + // 72 + // Other PDSes are not trusted to enforce our lexicon maxima. Records that 73 + // exceed the declared limits are logged and skipped — never throw, so one bad 74 + // record can't kill the subscriber. 75 + 76 + function logDrop(atUri: string, reason: string): void { 77 + console.warn(`[firehose] dropping record ${atUri}: ${reason}`); 78 + } 79 + 80 + function wikiExceedsLimits(atUri: string, r: WikiRecord): boolean { 81 + if (r.name.length > LIMITS.wiki.name) { 82 + logDrop(atUri, "wiki.name exceeds limit"); 83 + return true; 84 + } 85 + if (r.visibility.length > LIMITS.wiki.visibility) { 86 + logDrop(atUri, "wiki.visibility exceeds limit"); 87 + return true; 88 + } 89 + if (r.language !== undefined && r.language.length > LIMITS.wiki.language) { 90 + logDrop(atUri, "wiki.language exceeds limit"); 91 + return true; 92 + } 93 + if ( 94 + r.description !== undefined && 95 + r.description.length > LIMITS.wiki.description 96 + ) { 97 + logDrop(atUri, "wiki.description exceeds limit"); 98 + return true; 99 + } 100 + return false; 101 + } 102 + 103 + function noteExceedsLimits(atUri: string, r: NoteRecord): boolean { 104 + if (r.slug.length > LIMITS.note.slug) { 105 + logDrop(atUri, "note.slug exceeds limit"); 106 + return true; 107 + } 108 + if (r.title.length > LIMITS.note.title) { 109 + logDrop(atUri, "note.title exceeds limit"); 110 + return true; 111 + } 112 + return false; 113 + } 114 + 115 + function revisionExceedsLimits(atUri: string, r: RevisionRecord): boolean { 116 + if (r.diff.length > LIMITS.revision.diff) { 117 + logDrop(atUri, "revision.diff exceeds limit"); 118 + return true; 119 + } 120 + if (r.diffFormat.length > LIMITS.revision.diffFormat) { 121 + logDrop(atUri, "revision.diffFormat exceeds limit"); 122 + return true; 123 + } 124 + if (r.message !== undefined && r.message.length > LIMITS.revision.message) { 125 + logDrop(atUri, "revision.message exceeds limit"); 126 + return true; 127 + } 128 + if (r.blobs !== undefined && r.blobs.length > LIMITS.revision.blobCount) { 129 + logDrop(atUri, "revision.blobs exceeds count limit"); 130 + return true; 131 + } 132 + return false; 133 + } 134 + 135 + function membershipExceedsLimits(atUri: string, r: MembershipRecord): boolean { 136 + if (r.memberDid.length > LIMITS.membership.memberDid) { 137 + logDrop(atUri, "membership.memberDid exceeds limit"); 138 + return true; 139 + } 140 + if (r.role.length > LIMITS.membership.role) { 141 + logDrop(atUri, "membership.role exceeds limit"); 142 + return true; 143 + } 144 + return false; 145 + } 146 + 69 147 // --- Type guards --- 70 148 71 149 type Rec = Record<string, unknown>; ··· 134 212 135 213 switch (evt.collection) { 136 214 case COLLECTIONS.wiki: 137 - if (isWikiRecord(r)) handleWiki(evt.did, evt.rkey, atUri, r); 215 + if (isWikiRecord(r) && !wikiExceedsLimits(atUri, r)) 216 + handleWiki(evt.did, evt.rkey, atUri, r); 138 217 break; 139 218 case COLLECTIONS.note: 140 - if (isNoteRecord(r)) handleNote(evt.did, atUri, r); 219 + if (isNoteRecord(r) && !noteExceedsLimits(atUri, r)) 220 + handleNote(evt.did, atUri, r); 141 221 break; 142 222 case COLLECTIONS.noteRevision: 143 - if (isRevisionRecord(r)) handleRevision(evt.did, atUri, r); 223 + if (isRevisionRecord(r) && !revisionExceedsLimits(atUri, r)) 224 + handleRevision(evt.did, atUri, r); 144 225 break; 145 226 case COLLECTIONS.membership: 146 - if (isMembershipRecord(r)) handleMembership(evt.did, atUri, r); 227 + if (isMembershipRecord(r) && !membershipExceedsLimits(atUri, r)) 228 + handleMembership(evt.did, atUri, r); 147 229 break; 148 230 case COLLECTIONS.memberRequest: 149 231 if (isMemberRequestRecord(r)) handleMemberRequest(evt.did, atUri, r);
+4
src/lib/i18n/en.ts
··· 146 146 wikiNameRequired: "Wiki name is required.", 147 147 wikiSlugExists: 'A wiki with slug "{slug}" already exists.', 148 148 wikiLanguageRequired: "Language is required.", 149 + wikiNameTooLong: "Wiki name is too long (max {max} characters).", 150 + titleTooLong: "Title is too long (max {max} characters).", 151 + contentTooLong: "Content is too long (max {max} characters).", 152 + messageTooLong: "Edit summary is too long (max {max} characters).", 149 153 invalidZip: "Invalid or corrupt zip file.", 150 154 tooManyFiles: "Too many files in zip (max 100 markdown files).", 151 155 zipTooLarge: "Zip content exceeds the 50MB limit.",
+4
src/lib/i18n/fr.ts
··· 148 148 wikiNameRequired: "Le nom du wiki est requis.", 149 149 wikiSlugExists: 'Un wiki avec le slug "{slug}" existe déjà.', 150 150 wikiLanguageRequired: "La langue est requise.", 151 + wikiNameTooLong: "Le nom du wiki est trop long (max {max} caractères).", 152 + titleTooLong: "Le titre est trop long (max {max} caractères).", 153 + contentTooLong: "Le contenu est trop long (max {max} caractères).", 154 + messageTooLong: "Le résumé est trop long (max {max} caractères).", 151 155 invalidZip: "Fichier zip invalide ou corrompu.", 152 156 tooManyFiles: "Trop de fichiers dans le zip (max 100 fichiers markdown).", 153 157 zipTooLarge: "Le contenu du zip dépasse la limite de 50 Mo.",
+4
src/lib/i18n/index.ts
··· 144 144 wikiNameRequired: string; 145 145 wikiSlugExists: string; 146 146 wikiLanguageRequired: string; 147 + wikiNameTooLong: string; 148 + titleTooLong: string; 149 + contentTooLong: string; 150 + messageTooLong: string; 147 151 invalidZip: string; 148 152 tooManyFiles: string; 149 153 zipTooLarge: string;
+5 -4
src/lib/image.ts
··· 1 + import { LIMITS } from "./limits.ts"; 2 + 1 3 export const ALLOWED_MIME_TYPES = [ 2 4 "image/jpeg", 3 5 "image/png", ··· 6 8 ] as const; 7 9 8 10 type AllowedMimeType = (typeof ALLOWED_MIME_TYPES)[number]; 9 - 10 - const MAX_IMAGE_SIZE = 10 * 1024 * 1024; // 10MB 11 11 12 12 class ImageValidationError extends Error { 13 13 constructor(message: string) { ··· 30 30 ); 31 31 } 32 32 33 - if (data.length > MAX_IMAGE_SIZE) { 33 + if (data.length > LIMITS.image.bytes) { 34 + const maxMb = LIMITS.image.bytes / 1024 / 1024; 34 35 throw new ImageValidationError( 35 - `Image too large: ${(data.length / 1024 / 1024).toFixed(1)}MB. Maximum: 10MB`, 36 + `Image too large: ${(data.length / 1024 / 1024).toFixed(1)}MB. Maximum: ${maxMb}MB`, 36 37 ); 37 38 } 38 39
+37
src/lib/limits.ts
··· 1 + /** 2 + * Central size limits, mirroring the `maxLength` / `maxSize` values declared in 3 + * the `wiki.lichen.*` lexicons. These are the hard ceilings enforced by a 4 + * conforming PDS; we re-declare them here so the appview can: 5 + * 6 + * - surface friendly validation errors before hitting the PDS 7 + * - reject oversized records at firehose ingestion (other PDSes may be lax) 8 + * - later introduce per-tier sub-limits (paid users get higher ceilings, 9 + * lexicon max remains the absolute cap) 10 + * 11 + * Keep in sync with `lexicons/wiki.lichen.*.json`. 12 + */ 13 + export const LIMITS = { 14 + wiki: { 15 + name: 256, 16 + visibility: 32, 17 + language: 16, 18 + description: 300, 19 + }, 20 + note: { 21 + slug: 256, 22 + title: 256, 23 + }, 24 + revision: { 25 + diff: 1_000_000, 26 + diffFormat: 32, 27 + message: 1024, 28 + blobCount: 32, 29 + }, 30 + membership: { 31 + memberDid: 2048, 32 + role: 32, 33 + }, 34 + image: { 35 + bytes: 10 * 1024 * 1024, 36 + }, 37 + } as const;
+6
src/lib/note-validation.ts
··· 1 1 import { getNoteBySlug } from "../server/db/queries/index.ts"; 2 2 import type { Messages } from "./i18n/index.ts"; 3 3 import { fmt } from "./i18n/index.ts"; 4 + import { LIMITS } from "./limits.ts"; 4 5 import { isValidSlug, slugify } from "./slug.ts"; 5 6 6 7 /** Validates title and generates a unique slug for a new note. */ ··· 10 11 msg: Messages, 11 12 ): { noteSlug: string } | { error: string } { 12 13 if (!title.trim()) return { error: msg.error.titleRequired }; 14 + if (title.length > LIMITS.note.title) { 15 + return { 16 + error: fmt(msg.error.titleTooLong, { max: String(LIMITS.note.title) }), 17 + }; 18 + } 13 19 const noteSlug = slugify(title); 14 20 if (!isValidSlug(noteSlug)) 15 21 return { error: fmt(msg.error.invalidSlug, { title }) };
+30 -1
src/lib/orchestrators/note.ts
··· 22 22 import { COLLECTIONS } from "../constants.ts"; 23 23 import { createDiff } from "../diff.ts"; 24 24 import { ForbiddenError, NotFoundError, ValidationError } from "../errors.ts"; 25 - import type { Messages } from "../i18n/index.ts"; 25 + import { fmt, type Messages } from "../i18n/index.ts"; 26 + import { LIMITS } from "../limits.ts"; 26 27 import { validateNewNote } from "../note-validation.ts"; 27 28 import { currentTimestamp, generateTid } from "../tid.ts"; 28 29 import { withPdsError } from "./helpers.ts"; ··· 49 50 : { title, content, blobMeta }; 50 51 } 51 52 53 + function validateRevisionFields( 54 + content: string, 55 + message: string | undefined, 56 + msg: Messages, 57 + ): void { 58 + // The diff stored in the revision is almost always shorter than its content; 59 + // capping content against the diff ceiling fails fast for oversized pastes. 60 + if (content.length > LIMITS.revision.diff) { 61 + throw new ValidationError( 62 + fmt(msg.error.contentTooLong, { max: String(LIMITS.revision.diff) }), 63 + ); 64 + } 65 + if (message !== undefined && message.length > LIMITS.revision.message) { 66 + throw new ValidationError( 67 + fmt(msg.error.messageTooLong, { max: String(LIMITS.revision.message) }), 68 + ); 69 + } 70 + } 71 + 52 72 /** 53 73 * Write a revision record to the PDS. Shared between create and edit. 54 74 */ ··· 92 112 if ("error" in validation) { 93 113 throw new ValidationError(validation.error); 94 114 } 115 + 116 + validateRevisionFields(fields.content, fields.message, msg); 95 117 96 118 const { noteSlug } = validation; 97 119 const blobs = buildBlobsForContent(fields.content, fields.blobMeta); ··· 165 187 if (!fields.title.trim()) { 166 188 throw new ValidationError(msg.error.titleRequired); 167 189 } 190 + if (fields.title.length > LIMITS.note.title) { 191 + throw new ValidationError( 192 + fmt(msg.error.titleTooLong, { max: String(LIMITS.note.title) }), 193 + ); 194 + } 195 + 196 + validateRevisionFields(fields.content, fields.message, msg); 168 197 169 198 const did = ctx.did; 170 199 if (!did) throw new ForbiddenError();
+10 -1
src/lib/orchestrators/wiki.ts
··· 20 20 import { createDiff } from "../diff.ts"; 21 21 import { ForbiddenError, ValidationError } from "../errors.ts"; 22 22 import { fmt, type Messages, t } from "../i18n/index.ts"; 23 + import { LIMITS } from "../limits.ts"; 23 24 import { isValidSlug, slugify } from "../slug.ts"; 24 25 import { currentTimestamp, generateTid } from "../tid.ts"; 25 26 import { withPdsError } from "./helpers.ts"; ··· 52 53 throw new ValidationError(msg.error.wikiNameRequired); 53 54 } 54 55 56 + if (fields.name.length > LIMITS.wiki.name) { 57 + throw new ValidationError( 58 + fmt(msg.error.wikiNameTooLong, { max: String(LIMITS.wiki.name) }), 59 + ); 60 + } 61 + 55 62 if (!fields.language) { 56 63 throw new ValidationError(msg.error.wikiLanguageRequired); 57 64 } ··· 78 85 let atUri = `at://${did}/wiki.lichen.wiki/${slug}`; 79 86 const agent = ctx.session ? await getAgent(ctx.session) : null; 80 87 81 - const description = fields.description.trim().slice(0, 300); 88 + const description = fields.description 89 + .trim() 90 + .slice(0, LIMITS.wiki.description); 82 91 83 92 if (agent) { 84 93 await withPdsError("create wiki", async () => {
+68
tests/firehose/handlers.test.ts
··· 53 53 "incomplete-wiki", 54 54 "lang-wiki", 55 55 "nolang-wiki", 56 + "oversize-wiki", 56 57 ]; 57 58 58 59 function cleanupHandlerTestData() { ··· 620 621 expect(isBookmarked(BOB_DID, WIKI_AT_URI)).toBe(false); 621 622 }); 622 623 }); 624 + 625 + describe("size guards", () => { 626 + test("drops wiki record with name exceeding lexicon limit", () => { 627 + handleCommitEvent( 628 + makeCommitEvt({ 629 + event: "create", 630 + collection: "wiki.lichen.wiki", 631 + rkey: "oversize-wiki", 632 + did: ALICE_DID, 633 + record: { 634 + name: "x".repeat(257), 635 + visibility: "public", 636 + createdAt: "2026-01-01T00:00:00.000Z", 637 + }, 638 + }), 639 + ); 640 + expect(getWiki("oversize-wiki")).toBeNull(); 641 + }); 642 + 643 + test("drops revision record with oversize diff", () => { 644 + handleCommitEvent( 645 + makeCommitEvt({ 646 + event: "create", 647 + collection: "wiki.lichen.wiki", 648 + rkey: "test-wiki", 649 + did: ALICE_DID, 650 + record: { 651 + name: "Test Wiki", 652 + visibility: "public", 653 + createdAt: "2026-01-01T00:00:00.000Z", 654 + }, 655 + }), 656 + ); 657 + const noteUri = `at://${ALICE_DID}/wiki.lichen.note/oversize-note`; 658 + handleCommitEvent( 659 + makeCommitEvt({ 660 + event: "create", 661 + collection: "wiki.lichen.note", 662 + rkey: "oversize-note", 663 + did: ALICE_DID, 664 + record: { 665 + slug: "oversize-note", 666 + title: "Oversize", 667 + wikiRef: WIKI_AT_URI, 668 + createdAt: "2026-01-01T00:00:00.000Z", 669 + }, 670 + }), 671 + ); 672 + 673 + handleCommitEvent( 674 + makeCommitEvt({ 675 + event: "create", 676 + collection: "wiki.lichen.noteRevision", 677 + rkey: "rev-big", 678 + did: ALICE_DID, 679 + record: { 680 + noteRef: noteUri, 681 + diff: "x".repeat(1_000_001), 682 + diffFormat: "diff-match-patch", 683 + createdAt: "2026-01-01T00:00:00.000Z", 684 + }, 685 + }), 686 + ); 687 + 688 + expect(getCurrentNote("test-wiki", "oversize-note")).toBeNull(); 689 + }); 690 + });