atproto pastebin service: https://plonk.li

stuff

Changed files
+690 -55
lexicons
src
+23 -1
flake.nix
··· 10 supportedSystems = ["x86_64-linux" "aarch64-linux" "aarch64-darwin"]; 11 forAllSystems = nixpkgs.lib.genAttrs supportedSystems; 12 nixpkgsFor = forAllSystems (system: 13 - import nixpkgs { inherit system; }); 14 in { 15 16 devShell = forAllSystems (system: let 17 pkgs = nixpkgsFor."${system}";
··· 10 supportedSystems = ["x86_64-linux" "aarch64-linux" "aarch64-darwin"]; 11 forAllSystems = nixpkgs.lib.genAttrs supportedSystems; 12 nixpkgsFor = forAllSystems (system: 13 + import nixpkgs { 14 + inherit system; 15 + overlays = [self.overlay.default]; 16 + }); 17 in { 18 + overlay.default = final: prev: let 19 + pname = "plonk"; 20 + version = "0.1.0"; 21 + in { 22 + plonk = with final; 23 + buildNpmPackage { 24 + inherit pname version; 25 + src = ./.; 26 + packageJson = ./package.json; 27 + buildPhase = "npm run build"; 28 + npmDepsHash = "sha256-qGCbaFAHd/s9hOTWMjHCam6Kf6pU6IWPybfwYh0sOwc="; 29 + }; 30 + }; 31 + 32 + packages = forAllSystems (system: { 33 + inherit (nixpkgsFor."${system}") plonk; 34 + }); 35 + 36 + defaultPackage = forAllSystems (system: nixpkgsFor."${system}".plonk); 37 38 devShell = forAllSystems (system: let 39 pkgs = nixpkgsFor."${system}";
+17 -2
lexicons/paste.json
··· 7 "key": "tid", 8 "record": { 9 "type": "object", 10 - "required": ["code", "lang", "title", "createdAt"], 11 "properties": { 12 "code": { 13 "type": "string", ··· 15 "maxGraphemes": 65536, 16 "maxLength": 65536 17 }, 18 "lang": { 19 "type": "string", 20 "minLength": 1, ··· 27 "maxGraphemes": 100, 28 "maxLength": 100 29 }, 30 - "createdAt": { "type": "string", "format": "datetime" } 31 } 32 } 33 }
··· 7 "key": "tid", 8 "record": { 9 "type": "object", 10 + "required": [ 11 + "code", 12 + "shortUrl", 13 + "lang", 14 + "title", 15 + "createdAt" 16 + ], 17 "properties": { 18 "code": { 19 "type": "string", ··· 21 "maxGraphemes": 65536, 22 "maxLength": 65536 23 }, 24 + "shortUrl": { 25 + "type": "string", 26 + "minLength": 2, 27 + "maxGraphemes": 10, 28 + "maxLength": 10 29 + }, 30 "lang": { 31 "type": "string", 32 "minLength": 1, ··· 39 "maxGraphemes": 100, 40 "maxLength": 100 41 }, 42 + "createdAt": { 43 + "type": "string", 44 + "format": "datetime" 45 + } 46 } 47 } 48 }
+31
src/db.ts
··· 1 import SqliteDb from "better-sqlite3"; 2 import { randomBytes } from "crypto"; 3 4 import { 5 Kysely, ··· 12 13 export type DatabaseSchema = { 14 paste: Paste; 15 auth_state: AuthState; 16 auth_session: AuthSession; 17 }; ··· 19 export type Paste = { 20 uri: string; 21 authorDid: string; 22 code: string; 23 lang: string; 24 title: string; ··· 36 state: AuthStateJson; 37 }; 38 39 type AuthSessionJson = string; 40 type AuthStateJson = string; 41 ··· 85 await db.schema.dropTable("paste").execute(); 86 }, 87 }; 88 89 function generateShortString(length: number): string { 90 return randomBytes(length).toString("base64url").substring(0, length);
··· 1 import SqliteDb from "better-sqlite3"; 2 import { randomBytes } from "crypto"; 3 + import e from "express"; 4 5 import { 6 Kysely, ··· 13 14 export type DatabaseSchema = { 15 paste: Paste; 16 + comment: Comment; 17 auth_state: AuthState; 18 auth_session: AuthSession; 19 }; ··· 21 export type Paste = { 22 uri: string; 23 authorDid: string; 24 + shortUrl: string; 25 code: string; 26 lang: string; 27 title: string; ··· 39 state: AuthStateJson; 40 }; 41 42 + export type Comment = { 43 + uri: string; 44 + authorDid: string; 45 + body: string; 46 + createdAt: string; 47 + indexedAt: string; 48 + pasteUri: string; 49 + pasteCid: string; 50 + } 51 + 52 type AuthSessionJson = string; 53 type AuthStateJson = string; 54 ··· 98 await db.schema.dropTable("paste").execute(); 99 }, 100 }; 101 + 102 + migrations["002"] = { 103 + async up(db: Kysely<unknown>) { 104 + await db.schema 105 + .createTable("comment") 106 + .addColumn("uri", "varchar", (col) => col.primaryKey()) 107 + .addColumn("authorDid", "varchar", (col) => col.notNull()) 108 + .addColumn("body", "varchar", (col) => col.notNull()) 109 + .addColumn("createdAt", "varchar", (col) => col.notNull()) 110 + .addColumn("indexedAt", "varchar", (col) => col.notNull()) 111 + .addColumn("pasteUri", "varchar", (col) => col.notNull()) 112 + .addColumn("pasteCid", "varchar", (col) => col.notNull()) 113 + .execute(); 114 + }, 115 + async down(db: Kysely<unknown>) { 116 + await db.schema.dropTable("comments").execute(); 117 + }, 118 + } 119 120 function generateShortString(length: number): string { 121 return randomBytes(length).toString("base64url").substring(0, length);
+37 -5
src/ingester.ts
··· 2 import { IdResolver } from "@atproto/identity"; 3 import { Firehose } from "@atproto/sync"; 4 import type { Database } from "#/db"; 5 - import { newShortUrl } from "#/db"; 6 import * as Paste from "#/lexicons/types/ovh/plonk/paste"; 7 8 export function createIngester(db: Database, idResolver: IdResolver) { 9 const logger = pino({ name: "firehose ingestion" }); ··· 21 Paste.isRecord(record) && 22 Paste.validateRecord(record).success 23 ) { 24 - // Store the status in our SQLite 25 - const short_url = await newShortUrl(db); 26 await db 27 .insertInto("paste") 28 .values({ 29 uri: evt.uri.toString(), 30 - shortUrl, 31 authorDid: evt.did, 32 code: record.code, 33 lang: record.lang, ··· 44 }), 45 ) 46 .execute(); 47 } 48 } else if ( 49 evt.event === "delete" && ··· 54 .deleteFrom("paste") 55 .where("uri", "=", evt.uri.toString()) 56 .execute(); 57 - } 58 }, 59 onError: (err) => { 60 logger.error({ err }, "error on firehose ingestion");
··· 2 import { IdResolver } from "@atproto/identity"; 3 import { Firehose } from "@atproto/sync"; 4 import type { Database } from "#/db"; 5 import * as Paste from "#/lexicons/types/ovh/plonk/paste"; 6 + import * as Comment from "#/lexicons/types/ovh/plonk/comment"; 7 8 export function createIngester(db: Database, idResolver: IdResolver) { 9 const logger = pino({ name: "firehose ingestion" }); ··· 21 Paste.isRecord(record) && 22 Paste.validateRecord(record).success 23 ) { 24 await db 25 .insertInto("paste") 26 .values({ 27 uri: evt.uri.toString(), 28 + shortUrl: record.shortUrl, 29 authorDid: evt.did, 30 code: record.code, 31 lang: record.lang, ··· 42 }), 43 ) 44 .execute(); 45 + } else if ( 46 + evt.collection === "ovh.plonk.comment" && 47 + Comment.isRecord(record) && 48 + Comment.validateRecord(record).success 49 + ) { 50 + await db 51 + .insertInto("comment") 52 + .values({ 53 + uri: evt.uri.toString(), 54 + authorDid: evt.did, 55 + body: record.content, 56 + pasteUri: record.post.uri, 57 + pasteCid: record.post.cid, 58 + createdAt: record.createdAt, 59 + indexedAt: now.toISOString(), 60 + }) 61 + .onConflict((oc) => 62 + oc.column("uri").doUpdateSet({ 63 + body: record.content, 64 + pasteUri: record.post.uri, 65 + pasteCid: record.post.cid, 66 + indexedAt: now.toISOString(), 67 + }), 68 + ) 69 + .execute(); 70 } 71 } else if ( 72 evt.event === "delete" && ··· 77 .deleteFrom("paste") 78 .where("uri", "=", evt.uri.toString()) 79 .execute(); 80 + } else if ( 81 + evt.event === "delete" && 82 + evt.collection === "ovh.plonk.comment" 83 + ) { 84 + // Remove the status from our SQLite 85 + await db 86 + .deleteFrom("comment") 87 + .where("uri", "=", evt.uri.toString()) 88 + .execute(); 89 + } 90 }, 91 onError: (err) => { 92 logger.error({ err }, "error on firehose ingestion");
+40 -16
src/lexicons/index.ts
··· 9 StreamAuthVerifier, 10 } from '@atproto/xrpc-server' 11 import { schemas } from './lexicons' 12 13 export function createServer(options?: XrpcOptions): Server { 14 return new Server(options) ··· 17 export class Server { 18 xrpc: XrpcServer 19 ovh: OvhNS 20 - app: AppNS 21 com: ComNS 22 23 constructor(options?: XrpcOptions) { 24 this.xrpc = createXrpcServer(schemas, options) 25 this.ovh = new OvhNS(this) 26 - this.app = new AppNS(this) 27 this.com = new ComNS(this) 28 } 29 } 30 ··· 46 } 47 } 48 49 - export class AppNS { 50 _server: Server 51 - bsky: AppBskyNS 52 53 constructor(server: Server) { 54 this._server = server 55 - this.bsky = new AppBskyNS(server) 56 } 57 } 58 59 - export class AppBskyNS { 60 _server: Server 61 - actor: AppBskyActorNS 62 63 constructor(server: Server) { 64 this._server = server 65 - this.actor = new AppBskyActorNS(server) 66 } 67 } 68 69 - export class AppBskyActorNS { 70 _server: Server 71 72 constructor(server: Server) { 73 this._server = server 74 } 75 } 76 77 - export class ComNS { 78 _server: Server 79 - atproto: ComAtprotoNS 80 81 constructor(server: Server) { 82 this._server = server 83 - this.atproto = new ComAtprotoNS(server) 84 } 85 } 86 87 - export class ComAtprotoNS { 88 _server: Server 89 - repo: ComAtprotoRepoNS 90 91 constructor(server: Server) { 92 this._server = server 93 - this.repo = new ComAtprotoRepoNS(server) 94 } 95 } 96 97 - export class ComAtprotoRepoNS { 98 _server: Server 99 100 constructor(server: Server) {
··· 9 StreamAuthVerifier, 10 } from '@atproto/xrpc-server' 11 import { schemas } from './lexicons' 12 + import * as ComAtprotoRepoGetRecord from './types/com/atproto/repo/getRecord' 13 + import * as ComAtprotoRepoListRecords from './types/com/atproto/repo/listRecords' 14 15 export function createServer(options?: XrpcOptions): Server { 16 return new Server(options) ··· 19 export class Server { 20 xrpc: XrpcServer 21 ovh: OvhNS 22 com: ComNS 23 + app: AppNS 24 25 constructor(options?: XrpcOptions) { 26 this.xrpc = createXrpcServer(schemas, options) 27 this.ovh = new OvhNS(this) 28 this.com = new ComNS(this) 29 + this.app = new AppNS(this) 30 } 31 } 32 ··· 48 } 49 } 50 51 + export class ComNS { 52 _server: Server 53 + atproto: ComAtprotoNS 54 55 constructor(server: Server) { 56 this._server = server 57 + this.atproto = new ComAtprotoNS(server) 58 } 59 } 60 61 + export class ComAtprotoNS { 62 _server: Server 63 + repo: ComAtprotoRepoNS 64 65 constructor(server: Server) { 66 this._server = server 67 + this.repo = new ComAtprotoRepoNS(server) 68 } 69 } 70 71 + export class ComAtprotoRepoNS { 72 _server: Server 73 74 constructor(server: Server) { 75 this._server = server 76 } 77 + 78 + getRecord<AV extends AuthVerifier>( 79 + cfg: ConfigOf< 80 + AV, 81 + ComAtprotoRepoGetRecord.Handler<ExtractAuth<AV>>, 82 + ComAtprotoRepoGetRecord.HandlerReqCtx<ExtractAuth<AV>> 83 + >, 84 + ) { 85 + const nsid = 'com.atproto.repo.getRecord' // @ts-ignore 86 + return this._server.xrpc.method(nsid, cfg) 87 + } 88 + 89 + listRecords<AV extends AuthVerifier>( 90 + cfg: ConfigOf< 91 + AV, 92 + ComAtprotoRepoListRecords.Handler<ExtractAuth<AV>>, 93 + ComAtprotoRepoListRecords.HandlerReqCtx<ExtractAuth<AV>> 94 + >, 95 + ) { 96 + const nsid = 'com.atproto.repo.listRecords' // @ts-ignore 97 + return this._server.xrpc.method(nsid, cfg) 98 + } 99 } 100 101 + export class AppNS { 102 _server: Server 103 + bsky: AppBskyNS 104 105 constructor(server: Server) { 106 this._server = server 107 + this.bsky = new AppBskyNS(server) 108 } 109 } 110 111 + export class AppBskyNS { 112 _server: Server 113 + actor: AppBskyActorNS 114 115 constructor(server: Server) { 116 this._server = server 117 + this.actor = new AppBskyActorNS(server) 118 } 119 } 120 121 + export class AppBskyActorNS { 122 _server: Server 123 124 constructor(server: Server) {
+190 -1
src/lexicons/lexicons.ts
··· 4 import { LexiconDoc, Lexicons } from '@atproto/lexicon' 5 6 export const schemaDict = { 7 ComAtprotoLabelDefs: { 8 lexicon: 1, 9 id: 'com.atproto.label.defs', ··· 183 }, 184 }, 185 }, 186 OvhPlonkPaste: { 187 lexicon: 1, 188 id: 'ovh.plonk.paste', ··· 192 key: 'tid', 193 record: { 194 type: 'object', 195 - required: ['code', 'lang', 'title', 'createdAt'], 196 properties: { 197 code: { 198 type: 'string', 199 minLength: 1, 200 maxGraphemes: 65536, 201 maxLength: 65536, 202 }, 203 lang: { 204 type: 'string', ··· 301 export const schemas: LexiconDoc[] = Object.values(schemaDict) as LexiconDoc[] 302 export const lexicons: Lexicons = new Lexicons(schemas) 303 export const ids = { 304 ComAtprotoLabelDefs: 'com.atproto.label.defs', 305 OvhPlonkPaste: 'ovh.plonk.paste', 306 AppBskyActorProfile: 'app.bsky.actor.profile', 307 ComAtprotoRepoStrongRef: 'com.atproto.repo.strongRef',
··· 4 import { LexiconDoc, Lexicons } from '@atproto/lexicon' 5 6 export const schemaDict = { 7 + OvhPlonkComment: { 8 + lexicon: 1, 9 + id: 'ovh.plonk.comment', 10 + defs: { 11 + main: { 12 + type: 'record', 13 + key: 'tid', 14 + record: { 15 + type: 'object', 16 + required: ['content', 'createdAt', 'post'], 17 + properties: { 18 + content: { 19 + type: 'string', 20 + maxLength: 100000, 21 + maxGraphemes: 10000, 22 + description: 'comment body', 23 + }, 24 + createdAt: { 25 + type: 'string', 26 + format: 'datetime', 27 + description: 'comment creation timestamp', 28 + }, 29 + post: { 30 + type: 'ref', 31 + ref: 'lex:com.atproto.repo.strongRef', 32 + }, 33 + }, 34 + }, 35 + }, 36 + }, 37 + }, 38 ComAtprotoLabelDefs: { 39 lexicon: 1, 40 id: 'com.atproto.label.defs', ··· 214 }, 215 }, 216 }, 217 + ComAtprotoRepoGetRecord: { 218 + lexicon: 1, 219 + id: 'com.atproto.repo.getRecord', 220 + defs: { 221 + main: { 222 + type: 'query', 223 + description: 224 + 'Get a single record from a repository. Does not require auth.', 225 + parameters: { 226 + type: 'params', 227 + required: ['repo', 'collection', 'rkey'], 228 + properties: { 229 + repo: { 230 + type: 'string', 231 + format: 'at-identifier', 232 + description: 'The handle or DID of the repo.', 233 + }, 234 + collection: { 235 + type: 'string', 236 + format: 'nsid', 237 + description: 'The NSID of the record collection.', 238 + }, 239 + rkey: { 240 + type: 'string', 241 + description: 'The Record Key.', 242 + }, 243 + cid: { 244 + type: 'string', 245 + format: 'cid', 246 + description: 247 + 'The CID of the version of the record. If not specified, then return the most recent version.', 248 + }, 249 + }, 250 + }, 251 + output: { 252 + encoding: 'application/json', 253 + schema: { 254 + type: 'object', 255 + required: ['uri', 'value'], 256 + properties: { 257 + uri: { 258 + type: 'string', 259 + format: 'at-uri', 260 + }, 261 + cid: { 262 + type: 'string', 263 + format: 'cid', 264 + }, 265 + value: { 266 + type: 'unknown', 267 + }, 268 + }, 269 + }, 270 + }, 271 + errors: [ 272 + { 273 + name: 'RecordNotFound', 274 + }, 275 + ], 276 + }, 277 + }, 278 + }, 279 + ComAtprotoRepoListRecords: { 280 + lexicon: 1, 281 + id: 'com.atproto.repo.listRecords', 282 + defs: { 283 + main: { 284 + type: 'query', 285 + description: 286 + 'List a range of records in a repository, matching a specific collection. Does not require auth.', 287 + parameters: { 288 + type: 'params', 289 + required: ['repo', 'collection'], 290 + properties: { 291 + repo: { 292 + type: 'string', 293 + format: 'at-identifier', 294 + description: 'The handle or DID of the repo.', 295 + }, 296 + collection: { 297 + type: 'string', 298 + format: 'nsid', 299 + description: 'The NSID of the record type.', 300 + }, 301 + limit: { 302 + type: 'integer', 303 + minimum: 1, 304 + maximum: 100, 305 + default: 50, 306 + description: 'The number of records to return.', 307 + }, 308 + cursor: { 309 + type: 'string', 310 + }, 311 + rkeyStart: { 312 + type: 'string', 313 + description: 314 + 'DEPRECATED: The lowest sort-ordered rkey to start from (exclusive)', 315 + }, 316 + rkeyEnd: { 317 + type: 'string', 318 + description: 319 + 'DEPRECATED: The highest sort-ordered rkey to stop at (exclusive)', 320 + }, 321 + reverse: { 322 + type: 'boolean', 323 + description: 'Flag to reverse the order of the returned records.', 324 + }, 325 + }, 326 + }, 327 + output: { 328 + encoding: 'application/json', 329 + schema: { 330 + type: 'object', 331 + required: ['records'], 332 + properties: { 333 + cursor: { 334 + type: 'string', 335 + }, 336 + records: { 337 + type: 'array', 338 + items: { 339 + type: 'ref', 340 + ref: 'lex:com.atproto.repo.listRecords#record', 341 + }, 342 + }, 343 + }, 344 + }, 345 + }, 346 + }, 347 + record: { 348 + type: 'object', 349 + required: ['uri', 'cid', 'value'], 350 + properties: { 351 + uri: { 352 + type: 'string', 353 + format: 'at-uri', 354 + }, 355 + cid: { 356 + type: 'string', 357 + format: 'cid', 358 + }, 359 + value: { 360 + type: 'unknown', 361 + }, 362 + }, 363 + }, 364 + }, 365 + }, 366 OvhPlonkPaste: { 367 lexicon: 1, 368 id: 'ovh.plonk.paste', ··· 372 key: 'tid', 373 record: { 374 type: 'object', 375 + required: ['code', 'shortUrl', 'lang', 'title', 'createdAt'], 376 properties: { 377 code: { 378 type: 'string', 379 minLength: 1, 380 maxGraphemes: 65536, 381 maxLength: 65536, 382 + }, 383 + shortUrl: { 384 + type: 'string', 385 + minLength: 2, 386 + maxGraphemes: 10, 387 + maxLength: 10, 388 }, 389 lang: { 390 type: 'string', ··· 487 export const schemas: LexiconDoc[] = Object.values(schemaDict) as LexiconDoc[] 488 export const lexicons: Lexicons = new Lexicons(schemas) 489 export const ids = { 490 + OvhPlonkComment: 'ovh.plonk.comment', 491 ComAtprotoLabelDefs: 'com.atproto.label.defs', 492 + ComAtprotoRepoGetRecord: 'com.atproto.repo.getRecord', 493 + ComAtprotoRepoListRecords: 'com.atproto.repo.listRecords', 494 OvhPlonkPaste: 'ovh.plonk.paste', 495 AppBskyActorProfile: 'app.bsky.actor.profile', 496 ComAtprotoRepoStrongRef: 'com.atproto.repo.strongRef',
+55
src/lexicons/types/com/atproto/repo/getRecord.ts
···
··· 1 + /** 2 + * GENERATED CODE - DO NOT MODIFY 3 + */ 4 + import express from 'express' 5 + import { ValidationResult, BlobRef } from '@atproto/lexicon' 6 + import { lexicons } from '../../../../lexicons' 7 + import { isObj, hasProp } from '../../../../util' 8 + import { CID } from 'multiformats/cid' 9 + import { HandlerAuth, HandlerPipeThrough } from '@atproto/xrpc-server' 10 + 11 + export interface QueryParams { 12 + /** The handle or DID of the repo. */ 13 + repo: string 14 + /** The NSID of the record collection. */ 15 + collection: string 16 + /** The Record Key. */ 17 + rkey: string 18 + /** The CID of the version of the record. If not specified, then return the most recent version. */ 19 + cid?: string 20 + } 21 + 22 + export type InputSchema = undefined 23 + 24 + export interface OutputSchema { 25 + uri: string 26 + cid?: string 27 + value: {} 28 + [k: string]: unknown 29 + } 30 + 31 + export type HandlerInput = undefined 32 + 33 + export interface HandlerSuccess { 34 + encoding: 'application/json' 35 + body: OutputSchema 36 + headers?: { [key: string]: string } 37 + } 38 + 39 + export interface HandlerError { 40 + status: number 41 + message?: string 42 + error?: 'RecordNotFound' 43 + } 44 + 45 + export type HandlerOutput = HandlerError | HandlerSuccess | HandlerPipeThrough 46 + export type HandlerReqCtx<HA extends HandlerAuth = never> = { 47 + auth: HA 48 + params: QueryParams 49 + input: HandlerInput 50 + req: express.Request 51 + res: express.Response 52 + } 53 + export type Handler<HA extends HandlerAuth = never> = ( 54 + ctx: HandlerReqCtx<HA>, 55 + ) => Promise<HandlerOutput> | HandlerOutput
+77
src/lexicons/types/com/atproto/repo/listRecords.ts
···
··· 1 + /** 2 + * GENERATED CODE - DO NOT MODIFY 3 + */ 4 + import express from 'express' 5 + import { ValidationResult, BlobRef } from '@atproto/lexicon' 6 + import { lexicons } from '../../../../lexicons' 7 + import { isObj, hasProp } from '../../../../util' 8 + import { CID } from 'multiformats/cid' 9 + import { HandlerAuth, HandlerPipeThrough } from '@atproto/xrpc-server' 10 + 11 + export interface QueryParams { 12 + /** The handle or DID of the repo. */ 13 + repo: string 14 + /** The NSID of the record type. */ 15 + collection: string 16 + /** The number of records to return. */ 17 + limit: number 18 + cursor?: string 19 + /** DEPRECATED: The lowest sort-ordered rkey to start from (exclusive) */ 20 + rkeyStart?: string 21 + /** DEPRECATED: The highest sort-ordered rkey to stop at (exclusive) */ 22 + rkeyEnd?: string 23 + /** Flag to reverse the order of the returned records. */ 24 + reverse?: boolean 25 + } 26 + 27 + export type InputSchema = undefined 28 + 29 + export interface OutputSchema { 30 + cursor?: string 31 + records: Record[] 32 + [k: string]: unknown 33 + } 34 + 35 + export type HandlerInput = undefined 36 + 37 + export interface HandlerSuccess { 38 + encoding: 'application/json' 39 + body: OutputSchema 40 + headers?: { [key: string]: string } 41 + } 42 + 43 + export interface HandlerError { 44 + status: number 45 + message?: string 46 + } 47 + 48 + export type HandlerOutput = HandlerError | HandlerSuccess | HandlerPipeThrough 49 + export type HandlerReqCtx<HA extends HandlerAuth = never> = { 50 + auth: HA 51 + params: QueryParams 52 + input: HandlerInput 53 + req: express.Request 54 + res: express.Response 55 + } 56 + export type Handler<HA extends HandlerAuth = never> = ( 57 + ctx: HandlerReqCtx<HA>, 58 + ) => Promise<HandlerOutput> | HandlerOutput 59 + 60 + export interface Record { 61 + uri: string 62 + cid: string 63 + value: {} 64 + [k: string]: unknown 65 + } 66 + 67 + export function isRecord(v: unknown): v is Record { 68 + return ( 69 + isObj(v) && 70 + hasProp(v, '$type') && 71 + v.$type === 'com.atproto.repo.listRecords#record' 72 + ) 73 + } 74 + 75 + export function validateRecord(v: unknown): ValidationResult { 76 + return lexicons.validate('com.atproto.repo.listRecords#record', v) 77 + }
+29
src/lexicons/types/ovh/plonk/comment.ts
···
··· 1 + /** 2 + * GENERATED CODE - DO NOT MODIFY 3 + */ 4 + import { ValidationResult, BlobRef } from '@atproto/lexicon' 5 + import { lexicons } from '../../../lexicons' 6 + import { isObj, hasProp } from '../../../util' 7 + import { CID } from 'multiformats/cid' 8 + import * as ComAtprotoRepoStrongRef from '../../com/atproto/repo/strongRef' 9 + 10 + export interface Record { 11 + /** comment body */ 12 + content: string 13 + /** comment creation timestamp */ 14 + createdAt: string 15 + post: ComAtprotoRepoStrongRef.Main 16 + [k: string]: unknown 17 + } 18 + 19 + export function isRecord(v: unknown): v is Record { 20 + return ( 21 + isObj(v) && 22 + hasProp(v, '$type') && 23 + (v.$type === 'ovh.plonk.comment#main' || v.$type === 'ovh.plonk.comment') 24 + ) 25 + } 26 + 27 + export function validateRecord(v: unknown): ValidationResult { 28 + return lexicons.validate('ovh.plonk.comment#main', v) 29 + }
+1
src/lexicons/types/ovh/plonk/paste.ts
··· 8 9 export interface Record { 10 code: string 11 lang: string 12 title: string 13 createdAt: string
··· 8 9 export interface Record { 10 code: string 11 + shortUrl: string 12 lang: string 13 title: string 14 createdAt: string
+3 -3
src/mixins/post.pug
··· 1 - mixin post(paste, didHandleMap) 2 div.post 3 p 4 a(href=`/p/${paste.shortUrl}`) 5 | #{paste.title} 6 p.post-info 7 | by 8 - a(href=`/u/${encodeURIComponent(paste.authorDid)}`) 9 - | @#{didHandleMap[paste.authorDid]} 10 | &nbsp;· 11 | #{timeDifference(now, Date.parse(paste.createdAt))} ago 12 | ·
··· 1 + mixin post(paste, handle, did) 2 div.post 3 p 4 a(href=`/p/${paste.shortUrl}`) 5 | #{paste.title} 6 p.post-info 7 | by 8 + a(href=`/u/${did}`) 9 + | @#{handle} 10 | &nbsp;· 11 | #{timeDifference(now, Date.parse(paste.createdAt))} ago 12 | ·
+1 -1
src/public/styles.css
··· 127 align-self: flex-end; 128 } 129 130 - .timeline { 131 display: flex; 132 flex-direction: column; 133 gap: 1rem;
··· 127 align-self: flex-end; 128 } 129 130 + .timeline, .comments { 131 display: flex; 132 flex-direction: column; 133 gap: 1rem;
+152 -23
src/routes.ts
··· 8 import { Agent } from "@atproto/api"; 9 import { getPds, DidResolver } from "@atproto/identity"; 10 import { TID } from "@atproto/common"; 11 import { newShortUrl } from "#/db"; 12 13 import * as Paste from "#/lexicons/types/ovh/plonk/paste"; 14 15 type Session = { 16 did: string; ··· 114 115 // Map user DIDs to their domain-name handles 116 const didHandleMap = await ctx.resolver.resolveDidsToHandles( 117 - pastes.map((s) => s.authorDid), 118 ); 119 120 if (!agent) { ··· 130 131 router.get("/u/:authorDid", async (req, res) => { 132 const { authorDid } = req.params; 133 - const pastes = await ctx.db 134 - .selectFrom("paste") 135 - .selectAll() 136 - .where("authorDid", "=", authorDid) 137 - .orderBy("indexedAt", "desc") 138 - .execute(); 139 let didHandleMap = {}; 140 didHandleMap[authorDid] = await ctx.resolver.resolveDidToHandle(authorDid); 141 return res.render("user", { pastes, authorDid, didHandleMap }); ··· 151 if (!ret) { 152 return res.status(404); 153 } 154 const { authorDid: did, uri } = ret; 155 - const handle = await ctx.resolver.resolveDidToHandle(did); 156 const resolver = new DidResolver({}); 157 const didDocument = await resolver.resolve(did); 158 if (!didDocument) { ··· 162 if (!pds) { 163 return res.status(404); 164 } 165 const aturi = new AtUri(uri); 166 - const url = new URL(`${pds}/xrpc/com.atproto.repo.getRecord`); 167 - url.searchParams.set("repo", aturi.hostname); 168 - url.searchParams.set("collection", aturi.collection); 169 - url.searchParams.set("rkey", aturi.rkey); 170 - 171 - const response = await fetch(url.toString()); 172 - 173 - if (!response.ok) { 174 - return res.status(404); 175 - } 176 177 - const pasteRecord = await response.json(); 178 const paste = 179 - Paste.isRecord(pasteRecord.value) && 180 - Paste.validateRecord(pasteRecord.value).success 181 - ? pasteRecord.value 182 : {}; 183 184 - return res.render("paste", { paste, handle, shortUrl }); 185 }); 186 187 router.get("/r/:shortUrl", async (req, res) => { 188 const { shortUrl } = req.params; 189 const ret = await ctx.db ··· 199 return res.send(ret.code); 200 }); 201 202 router.post("/paste", async (req, res) => { 203 const agent = await getSessionAgent(req, res, ctx); 204 if (!agent) { ··· 209 } 210 211 const rkey = TID.nextStr(); 212 const record = { 213 $type: "ovh.plonk.paste", 214 code: req.body?.code, 215 lang: req.body?.lang, 216 title: req.body?.title, 217 createdAt: new Date().toISOString(), 218 }; ··· 259 .execute(); 260 ctx.logger.info(res, "wrote back to db"); 261 return res.redirect(`/p/${shortUrl}`); 262 } catch (err) { 263 ctx.logger.warn( 264 { err },
··· 8 import { Agent } from "@atproto/api"; 9 import { getPds, DidResolver } from "@atproto/identity"; 10 import { TID } from "@atproto/common"; 11 + import { Agent } from "@atproto/api"; 12 import { newShortUrl } from "#/db"; 13 14 import * as Paste from "#/lexicons/types/ovh/plonk/paste"; 15 + import * as Comment from "#/lexicons/types/ovh/plonk/comment"; 16 + import { ComAtprotoRepoNS } from "#/lexicons"; 17 18 type Session = { 19 did: string; ··· 117 118 // Map user DIDs to their domain-name handles 119 const didHandleMap = await ctx.resolver.resolveDidsToHandles( 120 + pastes.map((s) => s.authorDid).concat(agent? [agent.assertDid]:[]), 121 ); 122 123 if (!agent) { ··· 133 134 router.get("/u/:authorDid", async (req, res) => { 135 const { authorDid } = req.params; 136 + const resolver = new DidResolver({}); 137 + const didDocument = await resolver.resolve(authorDid); 138 + if (!didDocument) { 139 + return res.status(404); 140 + } 141 + const pds = getPds(didDocument); 142 + if (!pds) { 143 + return res.status(404); 144 + } 145 + const agent = new Agent(pds); 146 + const response = await agent.com.atproto.repo.listRecords({ 147 + repo: authorDid, 148 + collection: 'ovh.plonk.paste', 149 + limit: 99, 150 + }); 151 + const pastes = response.data.records; 152 let didHandleMap = {}; 153 didHandleMap[authorDid] = await ctx.resolver.resolveDidToHandle(authorDid); 154 return res.render("user", { pastes, authorDid, didHandleMap }); ··· 164 if (!ret) { 165 return res.status(404); 166 } 167 + var comments = await ctx.db 168 + .selectFrom("comment") 169 + .selectAll() 170 + .where("pasteUri", '=', ret.uri) 171 + .execute(); 172 const { authorDid: did, uri } = ret; 173 + const didHandleMap = await ctx.resolver.resolveDidsToHandles( 174 + comments.map((c) => c.authorDid).concat([did]), 175 + ) 176 const resolver = new DidResolver({}); 177 const didDocument = await resolver.resolve(did); 178 if (!didDocument) { ··· 182 if (!pds) { 183 return res.status(404); 184 } 185 + const agent = new Agent(pds); 186 const aturi = new AtUri(uri); 187 + const response = await agent.com.atproto.repo.getRecord({ 188 + repo: aturi.hostname, 189 + collection: aturi.collection, 190 + rkey: aturi.rkey 191 + }); 192 193 const paste = 194 + Paste.isRecord(response.data.value) && 195 + Paste.validateRecord(response.data.value).success 196 + ? response.data.value 197 : {}; 198 199 + return res.render("paste", { paste, authorDid: did, uri: response.data.uri, didHandleMap, shortUrl, comments }); 200 }); 201 202 + router.get("/p/:shortUrl/raw", async (req, res) => { 203 + res.redirect(`/r/${req.params.shortUrl}`) 204 + }); 205 router.get("/r/:shortUrl", async (req, res) => { 206 const { shortUrl } = req.params; 207 const ret = await ctx.db ··· 217 return res.send(ret.code); 218 }); 219 220 + router.get("/reset", async (req, res) => { 221 + const agent = await getSessionAgent(req, res, ctx); 222 + if (!agent) { 223 + return res.redirect('/'); 224 + } 225 + const response = await agent.com.atproto.repo.listRecords({ 226 + repo: agent.assertDid, 227 + collection: 'ovh.plonk.paste', 228 + limit: 10, 229 + }); 230 + const vals = response.data.records; 231 + for (const v of vals) { 232 + const aturl = new AtUri(v.uri); 233 + await agent.com.atproto.repo.deleteRecord({ 234 + repo: agent.assertDid, 235 + collection: aturl.collection, 236 + rkey: aturl.rkey, 237 + }); 238 + } 239 + return res.redirect('/'); 240 + }); 241 + 242 router.post("/paste", async (req, res) => { 243 const agent = await getSessionAgent(req, res, ctx); 244 if (!agent) { ··· 249 } 250 251 const rkey = TID.nextStr(); 252 + const shortUrl = await newShortUrl(ctx.db); 253 const record = { 254 $type: "ovh.plonk.paste", 255 code: req.body?.code, 256 lang: req.body?.lang, 257 + shortUrl, 258 title: req.body?.title, 259 createdAt: new Date().toISOString(), 260 }; ··· 301 .execute(); 302 ctx.logger.info(res, "wrote back to db"); 303 return res.redirect(`/p/${shortUrl}`); 304 + } catch (err) { 305 + ctx.logger.warn( 306 + { err }, 307 + "failed to update computed view; ignoring as it should be caught by the firehose", 308 + ); 309 + } 310 + 311 + return res.redirect("/"); 312 + }); 313 + 314 + router.post("/:paste/comment", async (req, res) => { 315 + const agent = await getSessionAgent(req, res, ctx); 316 + 317 + if (!agent) { 318 + return res 319 + .status(401) 320 + .type("html") 321 + .send("<h1>Error: Session required</h1>"); 322 + } 323 + 324 + const pasteUri = req.params.paste; 325 + const aturi = new AtUri(pasteUri); 326 + const pasteResponse = await agent.com.atproto.repo.getRecord({ 327 + repo: aturi.hostname, 328 + collection: aturi.collection, 329 + rkey: aturi.rkey 330 + }); 331 + const pasteCid = pasteResponse.data.cid; 332 + if (!pasteCid) { 333 + return res 334 + .status(401) 335 + .type("html") 336 + .send("invalid paste"); 337 + } 338 + 339 + const rkey = TID.nextStr(); 340 + const record = { 341 + $type: "ovh.plonk.comment", 342 + content: req.body?.comment, 343 + post: { 344 + uri: pasteUri, 345 + cid: pasteCid 346 + }, 347 + createdAt: new Date().toISOString(), 348 + }; 349 + 350 + if (!Comment.validateRecord(record).success) { 351 + return res 352 + .status(400) 353 + .type("html") 354 + .send("<h1>Error: Invalid status</h1>"); 355 + } 356 + 357 + let uri; 358 + try { 359 + const res = await agent.com.atproto.repo.putRecord({ 360 + repo: agent.assertDid, 361 + collection: "ovh.plonk.comment", 362 + rkey, 363 + record, 364 + validate: false, 365 + }); 366 + uri = res.data.uri; 367 + } catch (err) { 368 + ctx.logger.warn({ err }, "failed to put record"); 369 + return res 370 + .status(500) 371 + .type("html") 372 + .send("<h3>Error: Failed to write record</h1>"); 373 + } 374 + 375 + try { 376 + await ctx.db 377 + .insertInto("comment") 378 + .values({ 379 + uri, 380 + body: record.content, 381 + authorDid: agent.assertDid, 382 + pasteUri: record.post.uri, 383 + pasteCid: record.post.cid, 384 + createdAt: record.createdAt, 385 + indexedAt: new Date().toISOString(), 386 + }) 387 + .execute(); 388 + ctx.logger.info(res, "wrote back to db"); 389 + const originalPaste = await ctx.db.selectFrom('paste').selectAll().where('uri', '=', pasteUri).executeTakeFirst(); 390 + return res.redirect(`/p/${originalPaste.shortUrl}#${encodeURIComponent(uri)}`); 391 } catch (err) { 392 ctx.logger.warn( 393 { err },
+2 -1
src/views/index.pug
··· 44 45 div.timeline 46 each paste in pastes 47 - +post(paste, didHandleMap)
··· 44 45 div.timeline 46 each paste in pastes 47 + - var handle = didHandleMap[paste.authorDid] 48 + +post(paste, handle, paste.authorDid)
+19 -1
src/views/paste.pug
··· 8 main#content 9 h1 #{paste.title} 10 p 11 - | by @#{handle} · 12 | #{timeDifference(now, Date.parse(paste.createdAt))} ago · 13 | #{paste.lang} · 14 | #{paste.code.split('\n').length} loc · 15 a(href=`/r/${shortUrl}`) raw 16 pre 17 | #{paste.code}
··· 8 main#content 9 h1 #{paste.title} 10 p 11 + | by @#{didHandleMap[authorDid]} · 12 | #{timeDifference(now, Date.parse(paste.createdAt))} ago · 13 | #{paste.lang} · 14 | #{paste.code.split('\n').length} loc · 15 a(href=`/r/${shortUrl}`) raw 16 pre 17 | #{paste.code} 18 + hr 19 + 20 + div.comments 21 + each comment in comments 22 + div.comment(id=`${encodeURIComponent(comment.uri)}`) 23 + p 24 + | by @#{didHandleMap[comment.authorDid]} · 25 + | #{timeDifference(now, Date.parse(paste.createdAt))} ago 26 + p 27 + | #{comment.body} 28 + hr 29 + 30 + form(action=`/${encodeURIComponent(uri)}/comment` method="post").post-form 31 + div.post-row 32 + textarea#code(name="comment" rows="5" placeholder="add a comment" required).post-input-code 33 + 34 + div.post-submit-row 35 + button(type="submit").post-input-submit zonk!
+1 -1
src/views/user.pug
··· 11 h1 plonks by @#{handle} 12 div.timeline 13 each paste in pastes 14 - +post(paste, didHandleMap)
··· 11 h1 plonks by @#{handle} 12 div.timeline 13 each paste in pastes 14 + +post(paste.value, handle, authorDid)
+12
tsup.config.ts
···
··· 1 + import { defineConfig } from 'tsup'; 2 + 3 + export default defineConfig({ 4 + entry: ['src/index.ts'], 5 + outDir: 'dist', 6 + clean: true, 7 + format: 'esm', 8 + target: 'node18', 9 + dts: true, 10 + minify: true, 11 + sourcemap: true, 12 + });