PDS Admin tool make it easier to moderate your PDS with labels

wip

+259 -29
+5
.env
··· 1 + DATABASE_URL=file:./label-watcher.db 2 + MIGRATIONS_FOLDER=drizzle 3 + NOTIFY_SMTP_URL=smtps://resend:.... 4 + NOTIFY_SENDER_EMAIL=yougotmail@pdsmoover.com 5 + LOG_LEVEL=debug
+2
.env.example
··· 1 1 DATABASE_URL=file:./label-watcher.db 2 2 MIGRATIONS_FOLDER=drizzle 3 3 NOTIFY_SMTP_URL=smtps://resend:.... 4 + NOTIFY_SENDER_EMAIL=yougotmail@pdsmoover.com 5 + LOG_LEVEL=info
+1
drizzle/0001_workable_leech.sql
··· 1 + ALTER TABLE `labels_applied` ADD `labeler` text NOT NULL;
+170
drizzle/meta/0001_snapshot.json
··· 1 + { 2 + "version": "6", 3 + "dialect": "sqlite", 4 + "id": "368e7243-8e16-4e27-a7af-a906d976d75e", 5 + "prevId": "5a06d315-4901-4f9e-aab2-1c8dd03bb750", 6 + "tables": { 7 + "labeler_cursors": { 8 + "name": "labeler_cursors", 9 + "columns": { 10 + "labeler_id": { 11 + "name": "labeler_id", 12 + "type": "text", 13 + "primaryKey": false, 14 + "notNull": false, 15 + "autoincrement": false 16 + }, 17 + "cursor": { 18 + "name": "cursor", 19 + "type": "integer", 20 + "primaryKey": false, 21 + "notNull": true, 22 + "autoincrement": false 23 + } 24 + }, 25 + "indexes": { 26 + "labeler_cursors_labeler_id_unique": { 27 + "name": "labeler_cursors_labeler_id_unique", 28 + "columns": [ 29 + "labeler_id" 30 + ], 31 + "isUnique": true 32 + } 33 + }, 34 + "foreignKeys": {}, 35 + "compositePrimaryKeys": {}, 36 + "uniqueConstraints": {}, 37 + "checkConstraints": {} 38 + }, 39 + "labels_applied": { 40 + "name": "labels_applied", 41 + "columns": { 42 + "id": { 43 + "name": "id", 44 + "type": "integer", 45 + "primaryKey": true, 46 + "notNull": true, 47 + "autoincrement": true 48 + }, 49 + "did": { 50 + "name": "did", 51 + "type": "text", 52 + "primaryKey": false, 53 + "notNull": true, 54 + "autoincrement": false 55 + }, 56 + "label": { 57 + "name": "label", 58 + "type": "text", 59 + "primaryKey": false, 60 + "notNull": true, 61 + "autoincrement": false 62 + }, 63 + "labeler": { 64 + "name": "labeler", 65 + "type": "text", 66 + "primaryKey": false, 67 + "notNull": true, 68 + "autoincrement": false 69 + }, 70 + "action": { 71 + "name": "action", 72 + "type": "text", 73 + "primaryKey": false, 74 + "notNull": true, 75 + "autoincrement": false 76 + }, 77 + "negated": { 78 + "name": "negated", 79 + "type": "integer", 80 + "primaryKey": false, 81 + "notNull": true, 82 + "autoincrement": false, 83 + "default": false 84 + }, 85 + "date_applied": { 86 + "name": "date_applied", 87 + "type": "integer", 88 + "primaryKey": false, 89 + "notNull": true, 90 + "autoincrement": false 91 + } 92 + }, 93 + "indexes": {}, 94 + "foreignKeys": { 95 + "labels_applied_did_watched_repos_did_fk": { 96 + "name": "labels_applied_did_watched_repos_did_fk", 97 + "tableFrom": "labels_applied", 98 + "tableTo": "watched_repos", 99 + "columnsFrom": [ 100 + "did" 101 + ], 102 + "columnsTo": [ 103 + "did" 104 + ], 105 + "onDelete": "no action", 106 + "onUpdate": "no action" 107 + } 108 + }, 109 + "compositePrimaryKeys": {}, 110 + "uniqueConstraints": {}, 111 + "checkConstraints": {} 112 + }, 113 + "watched_repos": { 114 + "name": "watched_repos", 115 + "columns": { 116 + "did": { 117 + "name": "did", 118 + "type": "text", 119 + "primaryKey": true, 120 + "notNull": true, 121 + "autoincrement": false 122 + }, 123 + "pds_host": { 124 + "name": "pds_host", 125 + "type": "text", 126 + "primaryKey": false, 127 + "notNull": true, 128 + "autoincrement": false 129 + }, 130 + "active": { 131 + "name": "active", 132 + "type": "integer", 133 + "primaryKey": false, 134 + "notNull": true, 135 + "autoincrement": false 136 + }, 137 + "date_first_seen": { 138 + "name": "date_first_seen", 139 + "type": "integer", 140 + "primaryKey": false, 141 + "notNull": true, 142 + "autoincrement": false 143 + } 144 + }, 145 + "indexes": { 146 + "watched_repos_did_unique": { 147 + "name": "watched_repos_did_unique", 148 + "columns": [ 149 + "did" 150 + ], 151 + "isUnique": true 152 + } 153 + }, 154 + "foreignKeys": {}, 155 + "compositePrimaryKeys": {}, 156 + "uniqueConstraints": {}, 157 + "checkConstraints": {} 158 + } 159 + }, 160 + "views": {}, 161 + "enums": {}, 162 + "_meta": { 163 + "schemas": {}, 164 + "tables": {}, 165 + "columns": {} 166 + }, 167 + "internal": { 168 + "indexes": {} 169 + } 170 + }
+7
drizzle/meta/_journal.json
··· 8 8 "when": 1771615394802, 9 9 "tag": "0000_crazy_wallflower", 10 10 "breakpoints": true 11 + }, 12 + { 13 + "idx": 1, 14 + "version": "6", 15 + "when": 1771625783853, 16 + "tag": "0001_workable_leech", 17 + "breakpoints": true 11 18 } 12 19 ] 13 20 }
+1 -1
package.json
··· 5 5 "description": "", 6 6 "main": "index.js", 7 7 "scripts": { 8 - "start": "tsc && node dist/index.js | pino-pretty", 8 + "start": "tsc && node --env-file=.env dist/index.js | pino-pretty", 9 9 "db:generate": "drizzle-kit generate", 10 10 "db:migrate": "drizzle-kit migrate", 11 11 "db:studio": "drizzle-kit studio"
+1
src/db/schema.ts
··· 14 14 .notNull() 15 15 .references(() => watchedRepos.did), 16 16 label: text("label").notNull(), 17 + labeler: text("labeler").notNull(), 17 18 action: text("action").notNull(), 18 19 negated: integer("negated", { mode: "boolean" }).default(false).notNull(), 19 20 dateApplied: integer("date_applied", { mode: "timestamp" }).notNull(),
+46 -17
src/handlers/handleNewLabel.ts
··· 3 3 import { logger } from "../logger.js"; 4 4 import type { LibSQLDatabase } from "drizzle-orm/libsql"; 5 5 import * as schema from "../db/schema.js"; 6 + import { count, eq } from "drizzle-orm"; 6 7 7 8 export const handleNewLabel = async ( 8 9 config: LabelerConfig, 9 10 label: Label, 10 11 db: LibSQLDatabase<typeof schema>, 11 12 ) => { 12 - // TODO: MAKE SURE TO CHECK NEG 13 - logger.info({ host: config.host }, "From"); 14 - let labledDate = new Date(label.cts); 15 - if (config.labels[label.val]) { 16 - logger.info( 17 - { action: config.labels[label.val]?.action }, 18 - "Listed label found. Performing the action", 13 + try { 14 + // TODO: MAKE SURE TO CHECK NEG 15 + let labledDate = new Date(label.cts); 16 + logger.debug( 17 + { 18 + labeler: config.host, 19 + val: label.val, 20 + uri: label.uri, 21 + neg: label.neg, 22 + date: labledDate, 23 + }, 24 + "Label", 19 25 ); 26 + 27 + let labelConfig = config.labels[label.val]; 28 + if (labelConfig) { 29 + const isRepoWatched = await db 30 + .select() 31 + .from(schema.watchedRepos) 32 + .where(eq(schema.watchedRepos.did, label.uri)) 33 + .limit(1); 34 + 35 + if (isRepoWatched.length > 0) { 36 + logger.info( 37 + { action: config.labels[label.val]?.action }, 38 + `Listed label: ${label.val} found. Performing the action against: ${label.uri}`, 39 + ); 40 + 41 + await db.insert(schema.labelsApplied).values({ 42 + did: label.uri, 43 + label: label.val, 44 + labeler: config.host, 45 + action: labelConfig.action, 46 + negated: label.neg ?? false, 47 + dateApplied: labledDate, 48 + }); 49 + 50 + return; 51 + } 52 + logger.warn( 53 + { action: config.labels[label.val]?.action }, 54 + "Listed label found but repo is not watched. Skipping", 55 + ); 56 + } 57 + } catch (error) { 58 + logger.error({ error }, "Error handling new label"); 20 59 } 21 - logger.info( 22 - { 23 - src: label.src, 24 - val: label.val, 25 - uri: label.uri, 26 - neg: label.neg, 27 - date: labledDate, 28 - }, 29 - "Label", 30 - ); 31 60 };
+6 -3
src/handlers/lablerSubscriber.ts
··· 48 48 } 49 49 case "com.atproto.label.subscribeLabels#labels": { 50 50 for (const label of message.labels) { 51 - queue.add(async () => { 52 - await handleNewLabel(config, label, db); 53 - }); 51 + // We only care about labels for identities, not content for now 52 + if (label.uri.startsWith("did:")) { 53 + queue.add(async () => { 54 + await handleNewLabel(config, label, db); 55 + }); 56 + } 54 57 } 55 58 break; 56 59 }
+17 -7
src/index.ts
··· 45 45 const lastCursors = await db.select().from(labelerCursor); 46 46 47 47 // Sets up the subscribers to the labelers 48 - const labelSubscribers = Object.entries(settings.labeler).map(([_, config]) => { 49 - let lastCursorRow = lastCursors.find( 50 - (cursor) => cursor.labelerId === config.host, 51 - ); 52 - let lastCursor = lastCursorRow?.cursor ?? undefined; 53 - return labelerSubscriber(config, lastCursor, db, labelQueue); 54 - }); 48 + const labelSubscribers = Object.entries(settings.labeler) 49 + .map(([_, config]) => { 50 + if (config.labels == undefined) { 51 + logger.info( 52 + { host: config.host }, 53 + "No labels to watch not starting subscriber for this one", 54 + ); 55 + return null; 56 + } 57 + 58 + let lastCursorRow = lastCursors.find( 59 + (cursor) => cursor.labelerId === config.host, 60 + ); 61 + let lastCursor = lastCursorRow?.cursor ?? undefined; 62 + return labelerSubscriber(config, lastCursor, db, labelQueue); 63 + }) 64 + .filter((x) => x !== null); 55 65 56 66 const pdsSubscribers = Object.entries(settings.pds) 57 67 .map(([_, config]) => {
+3 -1
src/logger.ts
··· 1 1 import pino from "pino"; 2 2 3 - export const logger = pino(); 3 + export const logger = pino({ 4 + level: process.env.LOG_LEVEL ?? "info", 5 + });