social bookmarking for atproto

[appview/indexer] support profile record and implement basic record creation validation

hexmani.ac 83360308 dc79728a

verified
Changed files
+421 -200
.idea
dictionaries
backend
lexdocs
clippr
social
clippr
actor
lexicons
lib
lexicons
types
social
clippr
actor
+1
.idea/dictionaries/project.xml
··· 15 15 <w>rkey</w> 16 16 <w>tseslint</w> 17 17 <w>xrpc</w> 18 + <w>xxhash</w> 18 19 </words> 19 20 </dictionary> 20 21 </component>
+2 -1
backend/.prettierignore
··· 1 - pnpm-lock.yaml 1 + pnpm-lock.yaml 2 + dist/*
+1
backend/drizzle.config.ts
··· 5 5 */ 6 6 7 7 import { defineConfig } from "drizzle-kit"; 8 + // @ts-expect-error Read from the TypeScript file instead of assuming that it's JavaScript 8 9 import { Config } from "./src/config.ts"; 9 10 10 11 const config = Config.getInstance();
+51 -50
backend/package.json
··· 1 1 { 2 - "name": "@clipprjs/server", 3 - "version": "0.1.0", 4 - "repository": "https://tangled.sh/@hexmani.ac/clippr", 5 - "license": "AGPL-3.0-only", 6 - "scripts": { 7 - "dev": "tsx watch src/main.ts", 8 - "build": "tsc", 9 - "start": "node dist/index.js", 10 - "lint": "npx eslint .", 11 - "lint-write": "npx eslint . --fix", 12 - "fmt": "npx prettier --write .", 13 - "db:push": "npx drizzle-kit push" 14 - }, 15 - "type": "module", 16 - "main": "src/main.ts", 17 - "engines": { 18 - "node": ">=24" 19 - }, 20 - "dependencies": { 21 - "@atcute/atproto": "^3.1.0", 22 - "@atcute/lexicon-doc": "^1.0.3", 23 - "@atcute/lexicons": "^1.1.0", 24 - "@clipprjs/lexicons": "^0.1.1", 25 - "@eslint/eslintrc": "^3.3.1", 26 - "@hono/node-server": "^1.15.0", 27 - "@libsql/client": "^0.15.9", 28 - "@skyware/jetstream": "^0.2.2", 29 - "drizzle-orm": "^0.44.2", 30 - "hono": "^4.8.4", 31 - "toml": "^3.0.0", 32 - "winston": "^3.17.0" 33 - }, 34 - "devDependencies": { 35 - "@atcute/lex-cli": "^2.1.1", 36 - "@eslint/js": "^9.30.1", 37 - "@typescript-eslint/eslint-plugin": "^8.35.1", 38 - "@typescript-eslint/parser": "^8.35.1", 39 - "drizzle-kit": "^0.31.4", 40 - "eslint": "^9.30.1", 41 - "eslint-config-prettier": "^10.1.5", 42 - "eslint-plugin-drizzle": "^0.2.3", 43 - "eslint-plugin-import": "^2.32.0", 44 - "eslint-plugin-prettier": "^5.5.1", 45 - "globals": "^16.3.0", 46 - "jiti": "^2.4.2", 47 - "prettier": "^3.6.2", 48 - "tsx": "^4.20.3", 49 - "typescript": "^5.8.3", 50 - "typescript-eslint": "^8.35.1" 51 - } 2 + "name": "@clipprjs/server", 3 + "version": "0.1.0", 4 + "repository": "https://tangled.sh/@hexmani.ac/clippr", 5 + "license": "AGPL-3.0-only", 6 + "scripts": { 7 + "dev": "tsx watch src/main.ts", 8 + "build": "rm -r dist/; tsc", 9 + "start": "node dist/src/main.js", 10 + "lint": "npx eslint .", 11 + "lint-write": "npx eslint . --fix", 12 + "fmt": "npx prettier --write .", 13 + "db:push": "npx drizzle-kit push" 14 + }, 15 + "type": "module", 16 + "main": "src/main.ts", 17 + "engines": { 18 + "node": ">=24" 19 + }, 20 + "dependencies": { 21 + "@atcute/atproto": "^3.1.0", 22 + "@atcute/lexicon-doc": "^1.0.3", 23 + "@atcute/lexicons": "^1.1.0", 24 + "@clipprjs/lexicons": "^0.1.3", 25 + "@eslint/eslintrc": "^3.3.1", 26 + "@hono/node-server": "^1.15.0", 27 + "@libsql/client": "^0.15.9", 28 + "@skyware/jetstream": "^0.2.2", 29 + "drizzle-orm": "^0.44.2", 30 + "hono": "^4.8.4", 31 + "toml": "^3.0.0", 32 + "winston": "^3.17.0", 33 + "xxhash-wasm": "^1.1.0" 34 + }, 35 + "devDependencies": { 36 + "@atcute/lex-cli": "^2.1.1", 37 + "@eslint/js": "^9.30.1", 38 + "@typescript-eslint/eslint-plugin": "^8.35.1", 39 + "@typescript-eslint/parser": "^8.35.1", 40 + "drizzle-kit": "^0.31.4", 41 + "eslint": "^9.30.1", 42 + "eslint-config-prettier": "^10.1.5", 43 + "eslint-plugin-drizzle": "^0.2.3", 44 + "eslint-plugin-import": "^2.32.0", 45 + "eslint-plugin-prettier": "^5.5.1", 46 + "globals": "^16.3.0", 47 + "jiti": "^2.4.2", 48 + "prettier": "^3.6.2", 49 + "tsx": "^4.20.3", 50 + "typescript": "^5.8.3", 51 + "typescript-eslint": "^8.35.1" 52 + } 52 53 }
+13 -113
backend/pnpm-lock.yaml
··· 18 18 specifier: ^1.1.0 19 19 version: 1.1.0 20 20 '@clipprjs/lexicons': 21 - specifier: ^0.1.1 22 - version: 0.1.1 21 + specifier: ^0.1.3 22 + version: 0.1.3 23 23 '@eslint/eslintrc': 24 24 specifier: ^3.3.1 25 25 version: 3.3.1 ··· 38 38 hono: 39 39 specifier: ^4.8.4 40 40 version: 4.8.4 41 - hono-pino: 42 - specifier: ^0.9.1 43 - version: 0.9.1(hono@4.8.4)(pino@9.7.0) 44 - pino: 45 - specifier: ^9.7.0 46 - version: 9.7.0 47 41 toml: 48 42 specifier: ^3.0.0 49 43 version: 3.0.0 50 44 winston: 51 45 specifier: ^3.17.0 52 46 version: 3.17.0 47 + xxhash-wasm: 48 + specifier: ^1.1.0 49 + version: 1.1.0 53 50 devDependencies: 54 51 '@atcute/lex-cli': 55 52 specifier: ^2.1.1 ··· 127 124 resolution: {integrity: sha512-4QwGbuhh/JesHRQj79mO/l37PvJj4l/tlAu7+S1n4h47qwaNpZ0WDvIwUGLYUsdi9uQ5UPpiG9wb1Wm3XUFBUQ==} 128 125 engines: {node: '>= 18'} 129 126 130 - '@clipprjs/lexicons@0.1.1': 131 - resolution: {integrity: sha512-/TONuKi8ASHJe5ycg4wKj9m9paAQCcufEB+XJcnvSCLMM3MArAJpg4bmU1epRE3xuSLtkLnOTsmv8xJm8fjSpg==} 127 + '@clipprjs/lexicons@0.1.3': 128 + resolution: {integrity: sha512-jv52Ib/E4hhoD/rXntZgBmVcCqBJe1EY7+WOLBqCyVvkdajMzpYHDHMFxALU61z7Gc6ducYJK3S5Ki7EnBywCw==} 132 129 133 130 '@colors/colors@1.6.0': 134 131 resolution: {integrity: sha512-Ir+AOibqzrIsL6ajt3Rz3LskB7OiMVHqltZmspbW/TJuTVuyOMirVqAkjfY6JISiLHgyNqicAC8AyHHGzNd/dA==} ··· 717 714 async@3.2.6: 718 715 resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==} 719 716 720 - atomic-sleep@1.0.0: 721 - resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==} 722 - engines: {node: '>=8.0.0'} 723 - 724 717 available-typed-arrays@1.0.7: 725 718 resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} 726 719 engines: {node: '>= 0.4'} ··· 836 829 define-properties@1.2.1: 837 830 resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} 838 831 engines: {node: '>= 0.4'} 839 - 840 - defu@6.1.4: 841 - resolution: {integrity: sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==} 842 832 843 833 detect-libc@2.0.2: 844 834 resolution: {integrity: sha512-UX6sGumvvqSaXgdKGUsgZWqcUyIXZ/vZTrlRT/iobiKhGL0zL4d3osHj3uqllWJK+i+sixDS/3COVEOFbupFyw==} ··· 1121 1111 fast-levenshtein@2.0.6: 1122 1112 resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} 1123 1113 1124 - fast-redact@3.5.0: 1125 - resolution: {integrity: sha512-dwsoQlS7h9hMeYUq1W++23NDcBLV4KqONnITDV9DjfS3q1SgDGVrBdvvTLUotWtPSD7asWDV9/CmsZPy8Hf70A==} 1126 - engines: {node: '>=6'} 1127 - 1128 1114 fastq@1.19.1: 1129 1115 resolution: {integrity: sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==} 1130 1116 ··· 1249 1235 resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} 1250 1236 engines: {node: '>= 0.4'} 1251 1237 1252 - hono-pino@0.9.1: 1253 - resolution: {integrity: sha512-5HopJgf7FBAHw1NBXNSDMB9Iuxp6RD0IkXDqmA+MxNQk6s566B0a8GtdSkApbow9wbrhghtHE8aLri9RsvzG1A==} 1254 - engines: {node: '>=18'} 1255 - peerDependencies: 1256 - hono: '>=4.0.0' 1257 - pino: '>=7.1.0' 1258 - 1259 1238 hono@4.8.4: 1260 1239 resolution: {integrity: sha512-KOIBp1+iUs0HrKztM4EHiB2UtzZDTBihDtOF5K6+WaJjCPeaW4Q92R8j63jOhvJI5+tZSMuKD9REVEXXY9illg==} 1261 1240 engines: {node: '>=16.9.0'} ··· 1507 1486 resolution: {integrity: sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==} 1508 1487 engines: {node: '>= 0.4'} 1509 1488 1510 - on-exit-leak-free@2.1.2: 1511 - resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==} 1512 - engines: {node: '>=14.0.0'} 1513 - 1514 1489 one-time@1.0.0: 1515 1490 resolution: {integrity: sha512-5DXOiRKwuSEcQ/l0kGCF6Q3jcADFv5tSmRaJck/OqkVFcOzutB134KRSfF0xDrL39MNnqxbHBbUUcjZIhTgb2g==} 1516 1491 ··· 1554 1529 picomatch@2.3.1: 1555 1530 resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} 1556 1531 engines: {node: '>=8.6'} 1557 - 1558 - pino-abstract-transport@2.0.0: 1559 - resolution: {integrity: sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw==} 1560 - 1561 - pino-std-serializers@7.0.0: 1562 - resolution: {integrity: sha512-e906FRY0+tV27iq4juKzSYPbUj2do2X2JX4EzSca1631EB2QJQUqGbDuERal7LCtOpxl6x3+nvo9NPZcmjkiFA==} 1563 - 1564 - pino@9.7.0: 1565 - resolution: {integrity: sha512-vnMCM6xZTb1WDmLvtG2lE/2p+t9hDEIvTWJsu6FejkE62vB7gDhvzrpFR4Cw2to+9JNQxVnkAKVPA1KPB98vWg==} 1566 - hasBin: true 1567 1532 1568 1533 possible-typed-array-names@1.1.0: 1569 1534 resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} ··· 1582 1547 engines: {node: '>=14'} 1583 1548 hasBin: true 1584 1549 1585 - process-warning@5.0.0: 1586 - resolution: {integrity: sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==} 1587 - 1588 1550 promise-limit@2.7.0: 1589 1551 resolution: {integrity: sha512-7nJ6v5lnJsXwGprnGXga4wx6d1POjvi5Qmf1ivTRxTjH4Z/9Czja/UCMLVmB9N93GeWOU93XaFaEt6jbuoagNw==} 1590 1552 ··· 1595 1557 queue-microtask@1.2.3: 1596 1558 resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} 1597 1559 1598 - quick-format-unescaped@4.0.4: 1599 - resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==} 1600 - 1601 1560 readable-stream@3.6.2: 1602 1561 resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} 1603 1562 engines: {node: '>= 6'} 1604 - 1605 - real-require@0.2.0: 1606 - resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==} 1607 - engines: {node: '>= 12.13.0'} 1608 1563 1609 1564 reflect.getprototypeof@1.0.10: 1610 1565 resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==} ··· 1700 1655 simple-swizzle@0.2.2: 1701 1656 resolution: {integrity: sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg==} 1702 1657 1703 - sonic-boom@4.2.0: 1704 - resolution: {integrity: sha512-INb7TM37/mAcsGmc9hyyI6+QR3rR1zVRu36B0NeGXKnOOLiZOfER5SA+N7X7k3yUYRzLWafduTDvJAfDswwEww==} 1705 - 1706 1658 source-map-support@0.5.21: 1707 1659 resolution: {integrity: sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==} 1708 1660 ··· 1710 1662 resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} 1711 1663 engines: {node: '>=0.10.0'} 1712 1664 1713 - split2@4.2.0: 1714 - resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} 1715 - engines: {node: '>= 10.x'} 1716 - 1717 1665 stack-trace@0.0.10: 1718 1666 resolution: {integrity: sha512-KGzahc7puUKkzyMt+IqAep+TVNbKP+k2Lmwhub39m1AsTSkaDutx56aDCo+HLDzf/D26BIHTJWNiTG1KAJiQCg==} 1719 1667 ··· 1758 1706 1759 1707 text-hex@1.0.0: 1760 1708 resolution: {integrity: sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg==} 1761 - 1762 - thread-stream@3.1.0: 1763 - resolution: {integrity: sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A==} 1764 1709 1765 1710 to-regex-range@5.0.1: 1766 1711 resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} ··· 1881 1826 utf-8-validate: 1882 1827 optional: true 1883 1828 1829 + xxhash-wasm@1.1.0: 1830 + resolution: {integrity: sha512-147y/6YNh+tlp6nd/2pWq38i9h6mz/EuQ6njIrmW8D1BS5nCqs0P6DG+m6zTGnNz5I+uhZ0SHxBs9BsPrwcKDA==} 1831 + 1884 1832 yocto-queue@0.1.0: 1885 1833 resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} 1886 1834 engines: {node: '>=10'} ··· 1915 1863 1916 1864 '@badrap/valita@0.4.5': {} 1917 1865 1918 - '@clipprjs/lexicons@0.1.1': 1866 + '@clipprjs/lexicons@0.1.3': 1919 1867 dependencies: 1920 1868 '@atcute/atproto': 3.1.0 1921 1869 '@atcute/lexicons': 1.1.0 ··· 2418 2366 2419 2367 async@3.2.6: {} 2420 2368 2421 - atomic-sleep@1.0.0: {} 2422 - 2423 2369 available-typed-arrays@1.0.7: 2424 2370 dependencies: 2425 2371 possible-typed-array-names: 1.1.0 ··· 2546 2492 define-data-property: 1.1.4 2547 2493 has-property-descriptors: 1.0.2 2548 2494 object-keys: 1.1.1 2549 - 2550 - defu@6.1.4: {} 2551 2495 2552 2496 detect-libc@2.0.2: {} 2553 2497 ··· 2874 2818 2875 2819 fast-levenshtein@2.0.6: {} 2876 2820 2877 - fast-redact@3.5.0: {} 2878 - 2879 2821 fastq@1.19.1: 2880 2822 dependencies: 2881 2823 reusify: 1.1.0 ··· 3003 2945 hasown@2.0.2: 3004 2946 dependencies: 3005 2947 function-bind: 1.1.2 3006 - 3007 - hono-pino@0.9.1(hono@4.8.4)(pino@9.7.0): 3008 - dependencies: 3009 - defu: 6.1.4 3010 - hono: 4.8.4 3011 - pino: 9.7.0 3012 2948 3013 2949 hono@4.8.4: {} 3014 2950 ··· 3269 3205 define-properties: 1.2.1 3270 3206 es-object-atoms: 1.1.1 3271 3207 3272 - on-exit-leak-free@2.1.2: {} 3273 - 3274 3208 one-time@1.0.0: 3275 3209 dependencies: 3276 3210 fn.name: 1.1.0 ··· 3316 3250 3317 3251 picomatch@2.3.1: {} 3318 3252 3319 - pino-abstract-transport@2.0.0: 3320 - dependencies: 3321 - split2: 4.2.0 3322 - 3323 - pino-std-serializers@7.0.0: {} 3324 - 3325 - pino@9.7.0: 3326 - dependencies: 3327 - atomic-sleep: 1.0.0 3328 - fast-redact: 3.5.0 3329 - on-exit-leak-free: 2.1.2 3330 - pino-abstract-transport: 2.0.0 3331 - pino-std-serializers: 7.0.0 3332 - process-warning: 5.0.0 3333 - quick-format-unescaped: 4.0.4 3334 - real-require: 0.2.0 3335 - safe-stable-stringify: 2.5.0 3336 - sonic-boom: 4.2.0 3337 - thread-stream: 3.1.0 3338 - 3339 3253 possible-typed-array-names@1.1.0: {} 3340 3254 3341 3255 prelude-ls@1.2.1: {} ··· 3346 3260 3347 3261 prettier@3.6.2: {} 3348 3262 3349 - process-warning@5.0.0: {} 3350 - 3351 3263 promise-limit@2.7.0: {} 3352 3264 3353 3265 punycode@2.3.1: {} 3354 3266 3355 3267 queue-microtask@1.2.3: {} 3356 3268 3357 - quick-format-unescaped@4.0.4: {} 3358 - 3359 3269 readable-stream@3.6.2: 3360 3270 dependencies: 3361 3271 inherits: 2.0.4 3362 3272 string_decoder: 1.3.0 3363 3273 util-deprecate: 1.0.2 3364 - 3365 - real-require@0.2.0: {} 3366 3274 3367 3275 reflect.getprototypeof@1.0.10: 3368 3276 dependencies: ··· 3487 3395 dependencies: 3488 3396 is-arrayish: 0.3.2 3489 3397 3490 - sonic-boom@4.2.0: 3491 - dependencies: 3492 - atomic-sleep: 1.0.0 3493 - 3494 3398 source-map-support@0.5.21: 3495 3399 dependencies: 3496 3400 buffer-from: 1.1.2 ··· 3498 3402 3499 3403 source-map@0.6.1: {} 3500 3404 3501 - split2@4.2.0: {} 3502 - 3503 3405 stack-trace@0.0.10: {} 3504 3406 3505 3407 stop-iteration-iterator@1.1.0: ··· 3549 3451 '@pkgr/core': 0.2.7 3550 3452 3551 3453 text-hex@1.0.0: {} 3552 - 3553 - thread-stream@3.1.0: 3554 - dependencies: 3555 - real-require: 0.2.0 3556 3454 3557 3455 to-regex-range@5.0.1: 3558 3456 dependencies: ··· 3714 3612 word-wrap@1.2.5: {} 3715 3613 3716 3614 ws@8.18.3: {} 3615 + 3616 + xxhash-wasm@1.1.0: {} 3717 3617 3718 3618 yocto-queue@0.1.0: {}
+1 -1
backend/src/db/database.ts
··· 5 5 */ 6 6 7 7 import { drizzle } from "drizzle-orm/libsql"; 8 - import { Config } from "../config.ts"; 8 + import { Config } from "../config.js"; 9 9 import Logger from "../logger.js"; 10 10 11 11 const config = Config.getInstance();
+17 -1
backend/src/db/schema.ts
··· 4 4 * SPDX-License-Identifier: AGPL-3.0-only 5 5 */ 6 6 7 + // noinspection Annotator 8 + 7 9 import { int, sqliteTable, text } from "drizzle-orm/sqlite-core"; 8 10 import { sql } from "drizzle-orm"; 9 11 10 12 // WebStorm keeps throwing errors with the default statements as it wants 11 - // an actual SQLite query, despite being valid. Oh well. 13 + // an actual SQLite query, despite being valid. Sucks. 12 14 export const clipsTable = sqliteTable("clips", { 13 15 id: int("id").primaryKey({ autoIncrement: true }), 14 16 timestamp: int("time_us", { mode: "timestamp_ms" }) ··· 46 48 .notNull() 47 49 .default(sql`(unixepoch() * 1000)`), 48 50 }); 51 + 52 + export const usersTable = sqliteTable("profiles", { 53 + id: int("id").primaryKey({ autoIncrement: true }), 54 + timestamp: int("time_us", { mode: "timestamp_ms" }) 55 + .notNull() 56 + .default(sql`(unixepoch() * 1000)`), 57 + did: text("did").notNull(), 58 + displayName: text("displayName"), 59 + description: text("description"), 60 + avatar: text("avatar"), 61 + createdAt: int("createdAt", { mode: "timestamp_ms" }) 62 + .notNull() 63 + .default(sql`(unixepoch() * 1000)`), 64 + });
+10 -10
backend/src/main.ts
··· 5 5 */ 6 6 7 7 import { serve, type ServerType } from "@hono/node-server"; 8 - import { Config } from "./config.ts"; 9 - import { readFromFirehose, startFirehose, stopFirehose } from "./network/jetstream.ts"; 10 - import app from "./server.ts"; 8 + import { Config } from "./config.js"; 9 + import { readFromFirehose, startFirehose, stopFirehose } from "./network/jetstream.js"; 10 + import app from "./server.js"; 11 11 import { Database } from "./db/database.js"; 12 12 import Logger from "./logger.js"; 13 13 14 14 async function main() { 15 15 const logger = Logger; 16 - logger.info("clippr-BE starting..."); 16 + logger.info("Clippr-BE starting..."); 17 17 18 - logger.info("initializing config"); 18 + logger.verbose("Reading configuration..."); 19 19 const config = Config.getInstance(); 20 20 21 - logger.info("initializing database"); 21 + logger.verbose("Initializing database..."); 22 22 Database.getInstance(); 23 23 24 - logger.info("initializing firehose connection"); 24 + logger.verbose("Initializing Jetstream connection..."); 25 25 startFirehose(); 26 26 readFromFirehose(); 27 27 ··· 32 32 }); 33 33 34 34 logger.info( 35 - `server started at http://${config.get("hostname")}:${config.get("port")}`, 35 + `XRPC server launched at http://${config.get("hostname")}:${config.get("port")}`, 36 36 ); 37 37 38 38 process.removeAllListeners("SIGINT"); ··· 42 42 process.once("SIGTERM", () => gracefulShutdown("SIGTERM")); 43 43 44 44 function gracefulShutdown(signal: string) { 45 - logger.info(`received ${signal}, shutting down...`); 45 + logger.info(`Received ${signal}, shutting down...`); 46 46 stopFirehose(); 47 47 server.close(); 48 - logger.info("server shut down, bye!"); 48 + logger.info("Bye!"); 49 49 process.exit(0); 50 50 } 51 51 }
+161 -9
backend/src/network/commit.ts
··· 5 5 */ 6 6 7 7 import type { CommitEvent } from "@skyware/jetstream"; 8 - import { Database } from "../db/database.ts"; 9 - import { clipsTable, tagsTable } from "../db/schema.js"; 8 + import { Database } from "../db/database.js"; 9 + import { clipsTable, tagsTable, usersTable } from "../db/schema.js"; 10 10 import { is } from "@atcute/lexicons"; 11 - import { SocialClipprFeedClip, SocialClipprFeedTag } from "@clipprjs/lexicons"; 11 + import { SocialClipprActorProfile, SocialClipprFeedClip, SocialClipprFeedTag } from "@clipprjs/lexicons"; 12 12 import type { At } from "@atcute/client/lexicons"; 13 13 import Logger from "../logger.js"; 14 + import { isBlob } from "@atcute/lexicons/interfaces"; 15 + import { validateClip, validateProfile, validateTag } from "./validator.js"; 16 + import xxhash from "xxhash-wasm"; 14 17 15 18 const db = Database.getInstance().getDb(); 16 19 20 + /// Converts an ``At.DID`` type to a proper string, for type reasons. 17 21 function convertDidToString(did: At.DID): string { 18 22 return did.toString(); 19 23 } 20 24 25 + /// Converts a microsecond Unix date to a Date object, for type reasons. 21 26 function convertMicroToDate(micro: number): Date { 22 27 return new Date(micro / 1000); 23 28 } 24 29 25 30 export async function handleClip( 26 31 event: CommitEvent<`social.clippr.${string}`>, 27 - ) { 28 - if (event.commit.operation !== "create") return; // We currently do not handle these. 32 + ): Promise<void> { 33 + if (event.commit.operation !== "create") { 34 + Logger.warn( 35 + `Operation '${event.commit.operation}' for ${event.commit.collection} not supported. Ignoring.`, 36 + ); 37 + return; 38 + } // We currently do not handle these. 39 + 40 + if (event.commit.record.$type !== "social.clippr.feed.clip") { 41 + Logger.verbose( 42 + "Invalid type for incoming clip record", 43 + event.commit.record, 44 + ); 45 + } 29 46 30 47 if (!is(SocialClipprFeedClip.mainSchema, event.commit.record)) { 31 - Logger.verbose("Invalid clip", event.commit.record); 48 + Logger.verbose( 49 + "Invalid schema for incoming clip record", 50 + event.commit.record, 51 + ); 32 52 return; 33 53 } 34 54 ··· 45 65 url: event.commit.record.url, 46 66 }; 47 67 68 + // xxh64, NOT xxh3 learned that the hard way 69 + const { h64 } = await xxhash(); 70 + const urlHash = h64(record.url).toString(16); 71 + 72 + if (urlHash !== event.commit.rkey) { 73 + Logger.verbose( 74 + `Record key hash (${event.commit.rkey}) does not match hash of URL (${urlHash}) in incoming clip record`, 75 + event.commit.record, 76 + ); 77 + return; 78 + } 79 + 80 + if (!(await validateClip(record))) { 81 + return; 82 + } 83 + 48 84 await db.insert(clipsTable).values({ 49 85 // @ts-expect-error Weird type error despite being a normal string. 50 86 did: convertDidToString(event.did), ··· 64 100 Logger.verbose("Indexed new clip:", event.did, event.commit.rkey); 65 101 } 66 102 67 - export async function handleTag(event: CommitEvent<`social.clippr.${string}`>) { 68 - if (event.commit.operation !== "create") return; // We currently do not handle these. 103 + export async function handleTag( 104 + event: CommitEvent<`social.clippr.${string}`>, 105 + ): Promise<void> { 106 + if (event.commit.operation !== "create") { 107 + Logger.warn( 108 + `Operation '${event.commit.operation}' for ${event.commit.collection} not supported. Ignoring.`, 109 + ); 110 + return; 111 + } // We currently do not handle these. 112 + 113 + if (event.commit.record.$type !== "social.clippr.feed.tag") { 114 + Logger.verbose("Invalid type for incoming tag record", event.commit.record); 115 + return; 116 + } 69 117 70 118 if (!is(SocialClipprFeedTag.mainSchema, event.commit.record)) { 71 - Logger.verbose("Invalid tag", event.commit.record); 119 + Logger.verbose( 120 + "Invalid schema for incoming tag record", 121 + event.commit.record, 122 + ); 72 123 return; 73 124 } 74 125 ··· 79 130 color: event.commit.record.color, 80 131 }; 81 132 133 + if (record.name !== event.commit.rkey) { 134 + Logger.verbose( 135 + "Record key does not match name of incoming tag record", 136 + event.commit.record, 137 + ); 138 + return; 139 + } 140 + 141 + // Independent validations 142 + if (!(await validateTag(record))) { 143 + return; 144 + } 145 + 82 146 await db.insert(tagsTable).values({ 83 147 did: convertDidToString(event.did), 84 148 timestamp: convertMicroToDate(event.time_us), ··· 90 154 91 155 Logger.verbose("Indexed new tag:", event.did, event.commit.rkey); 92 156 } 157 + 158 + export async function handleProfile( 159 + event: CommitEvent<`social.clippr.${string}`>, 160 + ): Promise<void> { 161 + if (event.commit.operation !== "create") { 162 + Logger.warn( 163 + `Operation '${event.commit.operation}' for ${event.commit.collection} not supported. Ignoring.`, 164 + ); 165 + return; 166 + } // We currently do not handle these. 167 + 168 + if (event.commit.record.$type !== "social.clippr.actor.profile") { 169 + Logger.verbose( 170 + "Invalid type for incoming profile record", 171 + event.commit.record, 172 + ); 173 + return; 174 + } 175 + 176 + if (!is(SocialClipprActorProfile.mainSchema, event.commit.record)) { 177 + Logger.verbose( 178 + "Invalid schema for incoming profile record", 179 + event.commit.record, 180 + ); 181 + return; 182 + } 183 + 184 + const record: SocialClipprActorProfile.Main = { 185 + $type: "social.clippr.actor.profile", 186 + createdAt: event.commit.record.createdAt, 187 + displayName: event.commit.record.displayName, 188 + description: event.commit.record.description || undefined, 189 + avatar: event.commit.record.avatar || undefined, 190 + }; 191 + 192 + if (event.commit.rkey !== "self") { 193 + Logger.verbose( 194 + "Record key of incoming profile record does not match 'self'", 195 + event.commit.record, 196 + ); 197 + return; 198 + } 199 + 200 + // This needs to be here so the avatar can be recognized as a proper blob. 201 + if (record.avatar) { 202 + if (!isBlob(record.avatar)) { 203 + Logger.verbose("Avatar in incoming profile record is not a blob", record); 204 + return; 205 + } 206 + 207 + if (record.avatar.mimeType.match(/^image\/(png|jpeg)$/i) === null) { 208 + Logger.verbose( 209 + "Avatar in incoming profile record is not a PNG or JPEG", 210 + record, 211 + ); 212 + return; 213 + } 214 + 215 + if (record.avatar.ref?.$link === undefined) { 216 + Logger.verbose( 217 + "Avatar in incoming profile record has no link to blob", 218 + record, 219 + ); 220 + return; 221 + } 222 + 223 + if (record.avatar.size > 1000000) { 224 + Logger.verbose("Avatar in incoming profile record is too large", record); 225 + return; 226 + } 227 + } 228 + 229 + // Independent validations 230 + if (!(await validateProfile(record))) { 231 + return; 232 + } 233 + 234 + await db.insert(usersTable).values({ 235 + did: convertDidToString(event.did), 236 + timestamp: convertMicroToDate(event.time_us), 237 + createdAt: new Date(record.createdAt), 238 + displayName: record.displayName, 239 + avatar: record.avatar?.ref.$link, 240 + description: record.description, 241 + }); 242 + 243 + Logger.verbose("Indexed new profile for:", convertDidToString(event.did)); 244 + }
+8 -5
backend/src/network/jetstream.ts
··· 5 5 */ 6 6 7 7 import { Jetstream } from "@skyware/jetstream"; 8 - import { Config } from "../config.ts"; 9 - import { handleClip, handleTag } from "./commit.js"; 8 + import { Config } from "../config.js"; 9 + import { handleClip, handleProfile, handleTag } from "./commit.js"; 10 10 import Logger from "../logger.js"; 11 11 12 12 const config = Config.getInstance(); ··· 34 34 case "social.clippr.feed.tag": 35 35 handleTag(e); 36 36 break; 37 + case "social.clippr.actor.profile": 38 + handleProfile(e); 39 + break; 37 40 default: 38 - Logger.debug( 39 - `commit for ${e.commit.collection} is not relevant, dropping`, 41 + Logger.verbose( 42 + `Commit for ${e.commit.collection} is not relevant, dropping`, 40 43 ); 41 44 break; 42 45 } ··· 51 54 }); 52 55 53 56 jetstream.on("error", (e) => { 54 - Logger.warn(e); 57 + Logger.warn(`Error while reading from firehose: ${e.message}`); 55 58 }); 56 59 }
+148
backend/src/network/validator.ts
··· 1 + /* 2 + * clippr: a social bookmarking service for the AT Protocol 3 + * Copyright (c) 2025 clippr contributors. 4 + * SPDX-License-Identifier: AGPL-3.0-only 5 + */ 6 + 7 + import {SocialClipprActorProfile, SocialClipprFeedClip, SocialClipprFeedTag,} from "@clipprjs/lexicons"; 8 + import {isDatetime, isLanguageCode} from "@atcute/lexicons/syntax"; 9 + import Logger from "../logger.js"; 10 + 11 + export async function validateProfile( 12 + record: SocialClipprActorProfile.Main, 13 + ): Promise<boolean> { 14 + if (!isDatetime(record.createdAt)) { 15 + Logger.verbose( 16 + "Invalid createdAt timestamp for incoming profile record", 17 + record, 18 + ); 19 + return false; 20 + } 21 + 22 + if (record.displayName) { 23 + if (record.displayName.length > 64) { 24 + Logger.verbose( 25 + "Too long displayName from incoming profile record", 26 + record, 27 + ); 28 + return false; 29 + } 30 + } else { 31 + Logger.verbose("No displayName from incoming profile record", record); 32 + return false; 33 + } 34 + 35 + if (record.description) { 36 + if (record.description.length > 500) { 37 + Logger.verbose( 38 + "Too long description from incoming profile record", 39 + record, 40 + ); 41 + return false; 42 + } 43 + } 44 + 45 + return true; 46 + } 47 + 48 + export async function validateTag( 49 + record: SocialClipprFeedTag.Main, 50 + ): Promise<boolean> { 51 + if (!isDatetime(record.createdAt)) { 52 + Logger.verbose( 53 + "Invalid createdAt timestamp for incoming tag record", 54 + record, 55 + ); 56 + return false; 57 + } 58 + 59 + if (record.name.length > 64) { 60 + Logger.verbose("Invalid name length for incoming tag record", record); 61 + return false; 62 + } 63 + 64 + if (record.color) { 65 + if (record.color.length > 7) { 66 + Logger.verbose("Invalid color length for incoming tag record", record); 67 + return false; 68 + } 69 + 70 + if (!record.color.match("^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$")) { 71 + Logger.verbose( 72 + "Invalid hexadecimal color for incoming tag record", 73 + record, 74 + ); 75 + return false; 76 + } 77 + } 78 + 79 + return true; 80 + } 81 + 82 + export async function validateClip( 83 + record: SocialClipprFeedClip.Main, 84 + ): Promise<boolean> { 85 + if (record.url.length > 2000) { 86 + Logger.verbose("Too long url from incoming clip record", record); 87 + return false; 88 + } 89 + 90 + if (record.title.length > 2048) { 91 + Logger.verbose("Too long title from incoming clip record", record); 92 + return false; 93 + } 94 + 95 + if (record.description.length > 4096) { 96 + Logger.verbose("Too long description from incoming clip record", record); 97 + return false; 98 + } 99 + 100 + if (record.notes) { 101 + if (record.notes.length > 10000) { 102 + Logger.verbose("Too long notes from incoming clip record", record); 103 + return false; 104 + } 105 + } 106 + 107 + if (record.tags) { 108 + if (record.tags.some((tag) => tag.$type !== "com.atproto.repo.strongRef")) { 109 + Logger.verbose( 110 + "A tag ref from incoming clip record is not a strongRef", 111 + record, 112 + ); 113 + return false; 114 + } 115 + 116 + // There should be more tests here, but I'm not exactly sure what to add... 117 + } 118 + 119 + if (typeof record.unlisted !== "boolean") { 120 + Logger.verbose( 121 + "Unlisted value from incoming clip record is not a boolean", 122 + record, 123 + ); 124 + return false; 125 + } 126 + 127 + // Same with "unread" but it's not required so 128 + 129 + if (record.languages) { 130 + if (record.languages.some((lang) => !isLanguageCode(lang))) { 131 + Logger.verbose( 132 + "An item in the incoming clip record's languages array is not a valid language code", 133 + record, 134 + ); 135 + return false; 136 + } 137 + } 138 + 139 + if (!isDatetime(record.createdAt)) { 140 + Logger.verbose( 141 + "Invalid createdAt timestamp for incoming clip record", 142 + record, 143 + ); 144 + return false; 145 + } 146 + 147 + return true; 148 + }
+1 -1
backend/src/server.ts
··· 5 5 */ 6 6 7 7 import { Hono } from "hono"; 8 - import misc from "./routes/misc.ts"; 8 + import misc from "./routes/misc.js"; 9 9 import Logger from "./logger.js"; 10 10 import { logger } from "hono/logger"; 11 11
-1
backend/tsconfig.json
··· 1 1 { 2 2 "compilerOptions": { 3 - "allowImportingTsExtensions": true, 4 3 "esModuleInterop": true, 5 4 "skipLibCheck": true, 6 5 "target": "ESNext",
+2 -1
lexdocs/clippr/social/clippr/actor/profile.json
··· 9 9 "record": { 10 10 "type": "object", 11 11 "required": [ 12 - "createdAt" 12 + "createdAt", 13 + "displayName" 13 14 ], 14 15 "properties": { 15 16 "displayName": {
+4 -6
lexicons/lib/lexicons/types/social/clippr/actor/profile.ts
··· 18 18 /*#__PURE__*/ v.stringGraphemes(0, 500), 19 19 ]), 20 20 ), 21 - displayName: /*#__PURE__*/ v.optional( 22 - /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.string(), [ 23 - /*#__PURE__*/ v.stringLength(0, 640), 24 - /*#__PURE__*/ v.stringGraphemes(0, 64), 25 - ]), 26 - ), 21 + displayName: /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.string(), [ 22 + /*#__PURE__*/ v.stringLength(0, 640), 23 + /*#__PURE__*/ v.stringGraphemes(0, 64), 24 + ]), 27 25 }), 28 26 ); 29 27
+1 -1
lexicons/package.json
··· 1 1 { 2 2 "type": "module", 3 3 "name": "@clipprjs/lexicons", 4 - "version": "0.1.2", 4 + "version": "0.1.3", 5 5 "description": "Clippr schema definitions", 6 6 "license": "AGPL-3.0-only", 7 7 "private": false,