atproto pastebin service: https://plonk.li

stuff

Changed files
+690 -55
lexicons
src
+23 -1
flake.nix
··· 10 10 supportedSystems = ["x86_64-linux" "aarch64-linux" "aarch64-darwin"]; 11 11 forAllSystems = nixpkgs.lib.genAttrs supportedSystems; 12 12 nixpkgsFor = forAllSystems (system: 13 - import nixpkgs { inherit system; }); 13 + import nixpkgs { 14 + inherit system; 15 + overlays = [self.overlay.default]; 16 + }); 14 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); 15 37 16 38 devShell = forAllSystems (system: let 17 39 pkgs = nixpkgsFor."${system}";
+17 -2
lexicons/paste.json
··· 7 7 "key": "tid", 8 8 "record": { 9 9 "type": "object", 10 - "required": ["code", "lang", "title", "createdAt"], 10 + "required": [ 11 + "code", 12 + "shortUrl", 13 + "lang", 14 + "title", 15 + "createdAt" 16 + ], 11 17 "properties": { 12 18 "code": { 13 19 "type": "string", ··· 15 21 "maxGraphemes": 65536, 16 22 "maxLength": 65536 17 23 }, 24 + "shortUrl": { 25 + "type": "string", 26 + "minLength": 2, 27 + "maxGraphemes": 10, 28 + "maxLength": 10 29 + }, 18 30 "lang": { 19 31 "type": "string", 20 32 "minLength": 1, ··· 27 39 "maxGraphemes": 100, 28 40 "maxLength": 100 29 41 }, 30 - "createdAt": { "type": "string", "format": "datetime" } 42 + "createdAt": { 43 + "type": "string", 44 + "format": "datetime" 45 + } 31 46 } 32 47 } 33 48 }
+31
src/db.ts
··· 1 1 import SqliteDb from "better-sqlite3"; 2 2 import { randomBytes } from "crypto"; 3 + import e from "express"; 3 4 4 5 import { 5 6 Kysely, ··· 12 13 13 14 export type DatabaseSchema = { 14 15 paste: Paste; 16 + comment: Comment; 15 17 auth_state: AuthState; 16 18 auth_session: AuthSession; 17 19 }; ··· 19 21 export type Paste = { 20 22 uri: string; 21 23 authorDid: string; 24 + shortUrl: string; 22 25 code: string; 23 26 lang: string; 24 27 title: string; ··· 36 39 state: AuthStateJson; 37 40 }; 38 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 + 39 52 type AuthSessionJson = string; 40 53 type AuthStateJson = string; 41 54 ··· 85 98 await db.schema.dropTable("paste").execute(); 86 99 }, 87 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 + } 88 119 89 120 function generateShortString(length: number): string { 90 121 return randomBytes(length).toString("base64url").substring(0, length);
+37 -5
src/ingester.ts
··· 2 2 import { IdResolver } from "@atproto/identity"; 3 3 import { Firehose } from "@atproto/sync"; 4 4 import type { Database } from "#/db"; 5 - import { newShortUrl } from "#/db"; 6 5 import * as Paste from "#/lexicons/types/ovh/plonk/paste"; 6 + import * as Comment from "#/lexicons/types/ovh/plonk/comment"; 7 7 8 8 export function createIngester(db: Database, idResolver: IdResolver) { 9 9 const logger = pino({ name: "firehose ingestion" }); ··· 21 21 Paste.isRecord(record) && 22 22 Paste.validateRecord(record).success 23 23 ) { 24 - // Store the status in our SQLite 25 - const short_url = await newShortUrl(db); 26 24 await db 27 25 .insertInto("paste") 28 26 .values({ 29 27 uri: evt.uri.toString(), 30 - shortUrl, 28 + shortUrl: record.shortUrl, 31 29 authorDid: evt.did, 32 30 code: record.code, 33 31 lang: record.lang, ··· 44 42 }), 45 43 ) 46 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(); 47 70 } 48 71 } else if ( 49 72 evt.event === "delete" && ··· 54 77 .deleteFrom("paste") 55 78 .where("uri", "=", evt.uri.toString()) 56 79 .execute(); 57 - } 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 + } 58 90 }, 59 91 onError: (err) => { 60 92 logger.error({ err }, "error on firehose ingestion");
+40 -16
src/lexicons/index.ts
··· 9 9 StreamAuthVerifier, 10 10 } from '@atproto/xrpc-server' 11 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' 12 14 13 15 export function createServer(options?: XrpcOptions): Server { 14 16 return new Server(options) ··· 17 19 export class Server { 18 20 xrpc: XrpcServer 19 21 ovh: OvhNS 20 - app: AppNS 21 22 com: ComNS 23 + app: AppNS 22 24 23 25 constructor(options?: XrpcOptions) { 24 26 this.xrpc = createXrpcServer(schemas, options) 25 27 this.ovh = new OvhNS(this) 26 - this.app = new AppNS(this) 27 28 this.com = new ComNS(this) 29 + this.app = new AppNS(this) 28 30 } 29 31 } 30 32 ··· 46 48 } 47 49 } 48 50 49 - export class AppNS { 51 + export class ComNS { 50 52 _server: Server 51 - bsky: AppBskyNS 53 + atproto: ComAtprotoNS 52 54 53 55 constructor(server: Server) { 54 56 this._server = server 55 - this.bsky = new AppBskyNS(server) 57 + this.atproto = new ComAtprotoNS(server) 56 58 } 57 59 } 58 60 59 - export class AppBskyNS { 61 + export class ComAtprotoNS { 60 62 _server: Server 61 - actor: AppBskyActorNS 63 + repo: ComAtprotoRepoNS 62 64 63 65 constructor(server: Server) { 64 66 this._server = server 65 - this.actor = new AppBskyActorNS(server) 67 + this.repo = new ComAtprotoRepoNS(server) 66 68 } 67 69 } 68 70 69 - export class AppBskyActorNS { 71 + export class ComAtprotoRepoNS { 70 72 _server: Server 71 73 72 74 constructor(server: Server) { 73 75 this._server = server 74 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 + } 75 99 } 76 100 77 - export class ComNS { 101 + export class AppNS { 78 102 _server: Server 79 - atproto: ComAtprotoNS 103 + bsky: AppBskyNS 80 104 81 105 constructor(server: Server) { 82 106 this._server = server 83 - this.atproto = new ComAtprotoNS(server) 107 + this.bsky = new AppBskyNS(server) 84 108 } 85 109 } 86 110 87 - export class ComAtprotoNS { 111 + export class AppBskyNS { 88 112 _server: Server 89 - repo: ComAtprotoRepoNS 113 + actor: AppBskyActorNS 90 114 91 115 constructor(server: Server) { 92 116 this._server = server 93 - this.repo = new ComAtprotoRepoNS(server) 117 + this.actor = new AppBskyActorNS(server) 94 118 } 95 119 } 96 120 97 - export class ComAtprotoRepoNS { 121 + export class AppBskyActorNS { 98 122 _server: Server 99 123 100 124 constructor(server: Server) {
+190 -1
src/lexicons/lexicons.ts
··· 4 4 import { LexiconDoc, Lexicons } from '@atproto/lexicon' 5 5 6 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 + }, 7 38 ComAtprotoLabelDefs: { 8 39 lexicon: 1, 9 40 id: 'com.atproto.label.defs', ··· 183 214 }, 184 215 }, 185 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 + }, 186 366 OvhPlonkPaste: { 187 367 lexicon: 1, 188 368 id: 'ovh.plonk.paste', ··· 192 372 key: 'tid', 193 373 record: { 194 374 type: 'object', 195 - required: ['code', 'lang', 'title', 'createdAt'], 375 + required: ['code', 'shortUrl', 'lang', 'title', 'createdAt'], 196 376 properties: { 197 377 code: { 198 378 type: 'string', 199 379 minLength: 1, 200 380 maxGraphemes: 65536, 201 381 maxLength: 65536, 382 + }, 383 + shortUrl: { 384 + type: 'string', 385 + minLength: 2, 386 + maxGraphemes: 10, 387 + maxLength: 10, 202 388 }, 203 389 lang: { 204 390 type: 'string', ··· 301 487 export const schemas: LexiconDoc[] = Object.values(schemaDict) as LexiconDoc[] 302 488 export const lexicons: Lexicons = new Lexicons(schemas) 303 489 export const ids = { 490 + OvhPlonkComment: 'ovh.plonk.comment', 304 491 ComAtprotoLabelDefs: 'com.atproto.label.defs', 492 + ComAtprotoRepoGetRecord: 'com.atproto.repo.getRecord', 493 + ComAtprotoRepoListRecords: 'com.atproto.repo.listRecords', 305 494 OvhPlonkPaste: 'ovh.plonk.paste', 306 495 AppBskyActorProfile: 'app.bsky.actor.profile', 307 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 8 9 9 export interface Record { 10 10 code: string 11 + shortUrl: string 11 12 lang: string 12 13 title: string 13 14 createdAt: string
+3 -3
src/mixins/post.pug
··· 1 - mixin post(paste, didHandleMap) 1 + mixin post(paste, handle, did) 2 2 div.post 3 3 p 4 4 a(href=`/p/${paste.shortUrl}`) 5 5 | #{paste.title} 6 6 p.post-info 7 7 | by 8 - a(href=`/u/${encodeURIComponent(paste.authorDid)}`) 9 - | @#{didHandleMap[paste.authorDid]} 8 + a(href=`/u/${did}`) 9 + | @#{handle} 10 10 | &nbsp;· 11 11 | #{timeDifference(now, Date.parse(paste.createdAt))} ago 12 12 | ·
+1 -1
src/public/styles.css
··· 127 127 align-self: flex-end; 128 128 } 129 129 130 - .timeline { 130 + .timeline, .comments { 131 131 display: flex; 132 132 flex-direction: column; 133 133 gap: 1rem;
+152 -23
src/routes.ts
··· 8 8 import { Agent } from "@atproto/api"; 9 9 import { getPds, DidResolver } from "@atproto/identity"; 10 10 import { TID } from "@atproto/common"; 11 + import { Agent } from "@atproto/api"; 11 12 import { newShortUrl } from "#/db"; 12 13 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"; 14 17 15 18 type Session = { 16 19 did: string; ··· 114 117 115 118 // Map user DIDs to their domain-name handles 116 119 const didHandleMap = await ctx.resolver.resolveDidsToHandles( 117 - pastes.map((s) => s.authorDid), 120 + pastes.map((s) => s.authorDid).concat(agent? [agent.assertDid]:[]), 118 121 ); 119 122 120 123 if (!agent) { ··· 130 133 131 134 router.get("/u/:authorDid", async (req, res) => { 132 135 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(); 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; 139 152 let didHandleMap = {}; 140 153 didHandleMap[authorDid] = await ctx.resolver.resolveDidToHandle(authorDid); 141 154 return res.render("user", { pastes, authorDid, didHandleMap }); ··· 151 164 if (!ret) { 152 165 return res.status(404); 153 166 } 167 + var comments = await ctx.db 168 + .selectFrom("comment") 169 + .selectAll() 170 + .where("pasteUri", '=', ret.uri) 171 + .execute(); 154 172 const { authorDid: did, uri } = ret; 155 - const handle = await ctx.resolver.resolveDidToHandle(did); 173 + const didHandleMap = await ctx.resolver.resolveDidsToHandles( 174 + comments.map((c) => c.authorDid).concat([did]), 175 + ) 156 176 const resolver = new DidResolver({}); 157 177 const didDocument = await resolver.resolve(did); 158 178 if (!didDocument) { ··· 162 182 if (!pds) { 163 183 return res.status(404); 164 184 } 185 + const agent = new Agent(pds); 165 186 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 - } 187 + const response = await agent.com.atproto.repo.getRecord({ 188 + repo: aturi.hostname, 189 + collection: aturi.collection, 190 + rkey: aturi.rkey 191 + }); 176 192 177 - const pasteRecord = await response.json(); 178 193 const paste = 179 - Paste.isRecord(pasteRecord.value) && 180 - Paste.validateRecord(pasteRecord.value).success 181 - ? pasteRecord.value 194 + Paste.isRecord(response.data.value) && 195 + Paste.validateRecord(response.data.value).success 196 + ? response.data.value 182 197 : {}; 183 198 184 - return res.render("paste", { paste, handle, shortUrl }); 199 + return res.render("paste", { paste, authorDid: did, uri: response.data.uri, didHandleMap, shortUrl, comments }); 185 200 }); 186 201 202 + router.get("/p/:shortUrl/raw", async (req, res) => { 203 + res.redirect(`/r/${req.params.shortUrl}`) 204 + }); 187 205 router.get("/r/:shortUrl", async (req, res) => { 188 206 const { shortUrl } = req.params; 189 207 const ret = await ctx.db ··· 199 217 return res.send(ret.code); 200 218 }); 201 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 + 202 242 router.post("/paste", async (req, res) => { 203 243 const agent = await getSessionAgent(req, res, ctx); 204 244 if (!agent) { ··· 209 249 } 210 250 211 251 const rkey = TID.nextStr(); 252 + const shortUrl = await newShortUrl(ctx.db); 212 253 const record = { 213 254 $type: "ovh.plonk.paste", 214 255 code: req.body?.code, 215 256 lang: req.body?.lang, 257 + shortUrl, 216 258 title: req.body?.title, 217 259 createdAt: new Date().toISOString(), 218 260 }; ··· 259 301 .execute(); 260 302 ctx.logger.info(res, "wrote back to db"); 261 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)}`); 262 391 } catch (err) { 263 392 ctx.logger.warn( 264 393 { err },
+2 -1
src/views/index.pug
··· 44 44 45 45 div.timeline 46 46 each paste in pastes 47 - +post(paste, didHandleMap) 47 + - var handle = didHandleMap[paste.authorDid] 48 + +post(paste, handle, paste.authorDid)
+19 -1
src/views/paste.pug
··· 8 8 main#content 9 9 h1 #{paste.title} 10 10 p 11 - | by @#{handle} · 11 + | by @#{didHandleMap[authorDid]} · 12 12 | #{timeDifference(now, Date.parse(paste.createdAt))} ago · 13 13 | #{paste.lang} · 14 14 | #{paste.code.split('\n').length} loc · 15 15 a(href=`/r/${shortUrl}`) raw 16 16 pre 17 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 11 h1 plonks by @#{handle} 12 12 div.timeline 13 13 each paste in pastes 14 - +post(paste, didHandleMap) 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 + });