fedipod 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +123 -0
- package/architecture.svg +89 -0
- package/bin/fedipod.mjs +2303 -0
- package/cli.md +243 -0
- package/groups.md +85 -0
- package/gui.md +99 -0
- package/lib/account.mjs +87 -0
- package/lib/admin.mjs +1606 -0
- package/lib/atproto.mjs +310 -0
- package/lib/bskyfeed.mjs +166 -0
- package/lib/bskygroup.mjs +162 -0
- package/lib/c2s.mjs +305 -0
- package/lib/certs.mjs +265 -0
- package/lib/deliver.mjs +249 -0
- package/lib/directory.mjs +90 -0
- package/lib/embed.mjs +214 -0
- package/lib/export-collections.mjs +161 -0
- package/lib/front-core.mjs +322 -0
- package/lib/gateway-core.mjs +115 -0
- package/lib/guard.mjs +213 -0
- package/lib/home.mjs +161 -0
- package/lib/httpsig.mjs +109 -0
- package/lib/import.mjs +357 -0
- package/lib/intake.mjs +1710 -0
- package/lib/keys.mjs +139 -0
- package/lib/lease.mjs +190 -0
- package/lib/localapi.mjs +87 -0
- package/lib/mastoapi.mjs +1588 -0
- package/lib/migrate.mjs +127 -0
- package/lib/oidc-auth.mjs +53 -0
- package/lib/podrdf.mjs +132 -0
- package/lib/ports.mjs +27 -0
- package/lib/publisher.mjs +1003 -0
- package/lib/remote.mjs +303 -0
- package/lib/safefetch.mjs +235 -0
- package/lib/setup.mjs +262 -0
- package/lib/social.mjs +505 -0
- package/lib/storage.mjs +170 -0
- package/lib/store.mjs +470 -0
- package/lib/streaming.mjs +154 -0
- package/lib/tagfeed.mjs +158 -0
- package/lib/ua.mjs +29 -0
- package/lib/update.mjs +91 -0
- package/lib/webpush.mjs +98 -0
- package/lib/wire.mjs +762 -0
- package/package.json +60 -0
- package/phanpy/dist/404.html +40 -0
- package/phanpy/dist/_headers +2 -0
- package/phanpy/dist/apple-touch-icon.png +0 -0
- package/phanpy/dist/assets/ICONS-DIPJeU0C.js +2 -0
- package/phanpy/dist/assets/ICONS-DIPJeU0C.js.map +1 -0
- package/phanpy/dist/assets/ScheduledAtField-D-Abfo4J.js +183 -0
- package/phanpy/dist/assets/ScheduledAtField-D-Abfo4J.js.map +1 -0
- package/phanpy/dist/assets/Temml-CCjoacWt.woff2 +0 -0
- package/phanpy/dist/assets/__vite-browser-external-C7iut881.js +2 -0
- package/phanpy/dist/assets/__vite-browser-external-C7iut881.js.map +1 -0
- package/phanpy/dist/assets/boosts-carousel-BiOaNdMT.jpg +0 -0
- package/phanpy/dist/assets/catch-up-O5q5C75Z.png +0 -0
- package/phanpy/dist/assets/chunk-aKtaBQYM.js +1 -0
- package/phanpy/dist/assets/compose-CFEx7wb4.js +2 -0
- package/phanpy/dist/assets/compose-CFEx7wb4.js.map +1 -0
- package/phanpy/dist/assets/compose-ZJN5xSb4.js +35 -0
- package/phanpy/dist/assets/compose-ZJN5xSb4.js.map +1 -0
- package/phanpy/dist/assets/debug.module-YFQgwnIr.js +52 -0
- package/phanpy/dist/assets/debug.module-YFQgwnIr.js.map +1 -0
- package/phanpy/dist/assets/dom-p1YcJ5Rw.js +2 -0
- package/phanpy/dist/assets/dom-p1YcJ5Rw.js.map +1 -0
- package/phanpy/dist/assets/home-mobile-dark@2x-Dc-J8-SM.png +0 -0
- package/phanpy/dist/assets/home-mobile-light@2x-Bd3fod8m.png +0 -0
- package/phanpy/dist/assets/home-tablet-dark@2x-BgcNI8le.png +0 -0
- package/phanpy/dist/assets/home-tablet-light@2x-BW2RV9T6.png +0 -0
- package/phanpy/dist/assets/icons/add-circle-line-WD0mwORD.js +1 -0
- package/phanpy/dist/assets/icons/alert-line-CpFUMf9s.js +1 -0
- package/phanpy/dist/assets/icons/android-2-line-Bzxnrd3S.js +1 -0
- package/phanpy/dist/assets/icons/announcement-line-5nwaJFJm.js +1 -0
- package/phanpy/dist/assets/icons/arrow-down-circle-line-a7Id9-Iv.js +1 -0
- package/phanpy/dist/assets/icons/arrow-down-line-B8uzXzq6.js +1 -0
- package/phanpy/dist/assets/icons/arrow-left-line-CGrGT9C-.js +1 -0
- package/phanpy/dist/assets/icons/arrow-right-line-CE-HERfX.js +1 -0
- package/phanpy/dist/assets/icons/arrow-to-up-line-CvdQjmcy.js +1 -0
- package/phanpy/dist/assets/icons/arrow-up-circle-line-DEparSNF.js +1 -0
- package/phanpy/dist/assets/icons/arrow-up-line-C7yBeugc.js +1 -0
- package/phanpy/dist/assets/icons/arrows-right-line-BtbXmJGP.js +1 -0
- package/phanpy/dist/assets/icons/at-line-CF66Bznk.js +1 -0
- package/phanpy/dist/assets/icons/attachment-line-BY8It-l9.js +1 -0
- package/phanpy/dist/assets/icons/blockquote-line-BS6pU_hm.js +1 -0
- package/phanpy/dist/assets/icons/board-line-C0UWXyA6.js +1 -0
- package/phanpy/dist/assets/icons/bookmark-line-1CtX5JLF.js +1 -0
- package/phanpy/dist/assets/icons/building-5-line-Dd59MzbO.js +1 -0
- package/phanpy/dist/assets/icons/bus-2-line-6_dvA0GG.js +1 -0
- package/phanpy/dist/assets/icons/calendar-day-line-Crpl_fmu.js +1 -0
- package/phanpy/dist/assets/icons/calendar-month-line-vommts2C.js +1 -0
- package/phanpy/dist/assets/icons/calendar-time-add-line-BU_DTlQk.js +1 -0
- package/phanpy/dist/assets/icons/camera-line-Cv69MEei.js +1 -0
- package/phanpy/dist/assets/icons/celebrate-line-B8DvIoi0.js +1 -0
- package/phanpy/dist/assets/icons/chart-bar-line-FK-QOmtF.js +1 -0
- package/phanpy/dist/assets/icons/chart-line-line-BGcJAFWw.js +1 -0
- package/phanpy/dist/assets/icons/chat-3-line-YFK2-hwz.js +1 -0
- package/phanpy/dist/assets/icons/check-circle-line-DsAxFr5-.js +1 -0
- package/phanpy/dist/assets/icons/clipboard-line-BBp364gJ.js +1 -0
- package/phanpy/dist/assets/icons/close-circle-line-CICdiuGz.js +1 -0
- package/phanpy/dist/assets/icons/close-line-DYrFWcjv.js +1 -0
- package/phanpy/dist/assets/icons/cloud-line-tiQAbah8.js +1 -0
- package/phanpy/dist/assets/icons/code-line-fAl8iiV3.js +1 -0
- package/phanpy/dist/assets/icons/comment-2-line-CR3g7WeB.js +1 -0
- package/phanpy/dist/assets/icons/copy-2-line-CLW69yF5.js +1 -0
- package/phanpy/dist/assets/icons/delete-2-line-B8xmbr4n.js +1 -0
- package/phanpy/dist/assets/icons/document-3-line-DBGNhAg2.js +1 -0
- package/phanpy/dist/assets/icons/document-line-C7mb9sb7.js +1 -0
- package/phanpy/dist/assets/icons/down-line-BGqSSThd.js +1 -0
- package/phanpy/dist/assets/icons/edit-4-line-DcgGaEhx.js +1 -0
- package/phanpy/dist/assets/icons/emoji-2-line-D8YgnM-y.js +1 -0
- package/phanpy/dist/assets/icons/exit-line-Dc07BS-x.js +1 -0
- package/phanpy/dist/assets/icons/external-link-line-BvpSkmjO.js +1 -0
- package/phanpy/dist/assets/icons/eye-2-line-CiWXx-f_.js +1 -0
- package/phanpy/dist/assets/icons/eye-close-line-BgTaSJ4P.js +1 -0
- package/phanpy/dist/assets/icons/filter-2-line-B_mR2usy.js +1 -0
- package/phanpy/dist/assets/icons/filter-line-C_nm-3cU.js +1 -0
- package/phanpy/dist/assets/icons/flag-1-line-C2HB05I0.js +1 -0
- package/phanpy/dist/assets/icons/forbid-circle-line-D3LTT9yd.js +1 -0
- package/phanpy/dist/assets/icons/formula-line-DLrt2d6j.js +1 -0
- package/phanpy/dist/assets/icons/grid-line-CgUmEikf.js +1 -0
- package/phanpy/dist/assets/icons/group-line-C5fnX0wU.js +1 -0
- package/phanpy/dist/assets/icons/hand-finger-2-line-BGgxbuL9.js +1 -0
- package/phanpy/dist/assets/icons/hashtag-line-CyTEEXBb.js +1 -0
- package/phanpy/dist/assets/icons/heart-crack-line-BZddbO2C.js +1 -0
- package/phanpy/dist/assets/icons/heart-line-CJ7K47J4.js +1 -0
- package/phanpy/dist/assets/icons/history-2-line-kWWvFzqv.js +1 -0
- package/phanpy/dist/assets/icons/history-line-bGQiwbTH.js +1 -0
- package/phanpy/dist/assets/icons/home-3-line-fRU1zeLG.js +1 -0
- package/phanpy/dist/assets/icons/information-line-iNk6-sOY.js +1 -0
- package/phanpy/dist/assets/icons/keyboard-line-DsCyuQNz.js +1 -0
- package/phanpy/dist/assets/icons/layout-4-line-CZkOX52Z.js +1 -0
- package/phanpy/dist/assets/icons/layout-5-line-B_znt5wO.js +1 -0
- package/phanpy/dist/assets/icons/left-line-eUw0tya4.js +1 -0
- package/phanpy/dist/assets/icons/lightning-line-DaKoDdix.js +1 -0
- package/phanpy/dist/assets/icons/link-2-line-DCqLmMA7.js +1 -0
- package/phanpy/dist/assets/icons/list-check-line-BAxosBh9.js +1 -0
- package/phanpy/dist/assets/icons/lock-line-CPoaXeUW.js +1 -0
- package/phanpy/dist/assets/icons/mail-line-B0P8aa1L.js +1 -0
- package/phanpy/dist/assets/icons/moon-line-CEqNAToP.js +1 -0
- package/phanpy/dist/assets/icons/more-1-fill-Dw2e5xab.js +1 -0
- package/phanpy/dist/assets/icons/more-3-line-BIiVjYTz.js +1 -0
- package/phanpy/dist/assets/icons/notification-line-BZZq2Gtu.js +1 -0
- package/phanpy/dist/assets/icons/pencil-line-CKh8-A1v.js +1 -0
- package/phanpy/dist/assets/icons/photo-album-line-Bnfg2u40.js +1 -0
- package/phanpy/dist/assets/icons/pin-line-DpTolyhs.js +1 -0
- package/phanpy/dist/assets/icons/play-fill-B0-G_8lX.js +1 -0
- package/phanpy/dist/assets/icons/qrcode-2-line-B6SPCaHn.js +1 -0
- package/phanpy/dist/assets/icons/quill-pen-line-B6LUrnCP.js +1 -0
- package/phanpy/dist/assets/icons/quote-left-fill-hQZ1cj_6.js +1 -0
- package/phanpy/dist/assets/icons/radar-line-CKyroTth.js +1 -0
- package/phanpy/dist/assets/icons/react-line-CoIKhhtK.js +1 -0
- package/phanpy/dist/assets/icons/refresh-2-line-D0mTF97S.js +1 -0
- package/phanpy/dist/assets/icons/right-line-B3KWJaqc.js +1 -0
- package/phanpy/dist/assets/icons/rocket-line-4KZl537D.js +1 -0
- package/phanpy/dist/assets/icons/round-fill-C69NTrZ0.js +1 -0
- package/phanpy/dist/assets/icons/round-line-B9k_1uo-.js +1 -0
- package/phanpy/dist/assets/icons/route-line-B28ReHcE.js +1 -0
- package/phanpy/dist/assets/icons/rows-4-line-CrBniagM.js +1 -0
- package/phanpy/dist/assets/icons/scan-line-DSf74YdP.js +1 -0
- package/phanpy/dist/assets/icons/search-2-line-B6geIU1z.js +1 -0
- package/phanpy/dist/assets/icons/settings-3-line-azJwK47w.js +1 -0
- package/phanpy/dist/assets/icons/settings-6-line-C_5zc5YR.js +1 -0
- package/phanpy/dist/assets/icons/share-2-line-D5MxWjrm.js +1 -0
- package/phanpy/dist/assets/icons/share-forward-line-Cp61fZzZ.js +1 -0
- package/phanpy/dist/assets/icons/sparkles-2-line-DYYyXTaW.js +1 -0
- package/phanpy/dist/assets/icons/sparkles-line-C2hY1s6I.js +1 -0
- package/phanpy/dist/assets/icons/time-line-BF3eet1v.js +1 -0
- package/phanpy/dist/assets/icons/transfer-4-line-oisfvTBw.js +1 -0
- package/phanpy/dist/assets/icons/translate-line-BFWol2Ce.js +1 -0
- package/phanpy/dist/assets/icons/unlock-line-X_x5vwv3.js +1 -0
- package/phanpy/dist/assets/icons/upload-3-line-DgzwUJeW.js +1 -0
- package/phanpy/dist/assets/icons/user-4-line-BVSynZCp.js +1 -0
- package/phanpy/dist/assets/icons/user-add-2-line-BlGS5Q8q.js +1 -0
- package/phanpy/dist/assets/icons/user-add-line-BbXdHG4z.js +1 -0
- package/phanpy/dist/assets/icons/user-edit-line-BFAHOV0X.js +1 -0
- package/phanpy/dist/assets/icons/user-follow-line-BXux0wSd.js +1 -0
- package/phanpy/dist/assets/icons/user-star-line-DdYlpHLU.js +1 -0
- package/phanpy/dist/assets/icons/user-warning-line-Cj43j0_E.js +1 -0
- package/phanpy/dist/assets/icons/user-x-line-DIDgNlPH.js +1 -0
- package/phanpy/dist/assets/icons/volume-line-Bs390KQt.js +1 -0
- package/phanpy/dist/assets/icons/volume-mute-line-DzlzIqFZ.js +1 -0
- package/phanpy/dist/assets/icons/walk-line-yNXfB-md.js +1 -0
- package/phanpy/dist/assets/icons/world-2-line-CenkGIwj.js +1 -0
- package/phanpy/dist/assets/icons/zoom-in-line-CaENUphf.js +1 -0
- package/phanpy/dist/assets/icons/zoom-out-line-BDNk0EWo.js +1 -0
- package/phanpy/dist/assets/instances-BNPptnn-.json +333 -0
- package/phanpy/dist/assets/locales/ar-SA-CfheRbCu.js +1 -0
- package/phanpy/dist/assets/locales/ca-ES-DHRW86Tz.js +1 -0
- package/phanpy/dist/assets/locales/cs-CZ-CKoqEuA1.js +1 -0
- package/phanpy/dist/assets/locales/de-DE-ByRPMRFy.js +1 -0
- package/phanpy/dist/assets/locales/eo-UY-CPC7wLCS.js +1 -0
- package/phanpy/dist/assets/locales/es-ES-dJa0Fd9t.js +1 -0
- package/phanpy/dist/assets/locales/eu-ES-C3djlPR2.js +1 -0
- package/phanpy/dist/assets/locales/fa-IR-DTT490Ck.js +1 -0
- package/phanpy/dist/assets/locales/fi-FI-B23MCPuz.js +1 -0
- package/phanpy/dist/assets/locales/fr-FR-BcaQsO5C.js +1 -0
- package/phanpy/dist/assets/locales/gl-ES-BR-CVKBN.js +1 -0
- package/phanpy/dist/assets/locales/he-IL-CfheRbCu.js +1 -0
- package/phanpy/dist/assets/locales/hu-HU-CfheRbCu.js +1 -0
- package/phanpy/dist/assets/locales/it-IT-C1136UFV.js +1 -0
- package/phanpy/dist/assets/locales/ja-JP-CRZmRAhD.js +1 -0
- package/phanpy/dist/assets/locales/kab-Cz8xH_TT.js +1 -0
- package/phanpy/dist/assets/locales/ko-KR-CG9UiY4o.js +1 -0
- package/phanpy/dist/assets/locales/lt-LT-CJcI6sHZ.js +1 -0
- package/phanpy/dist/assets/locales/nb-NO-CfheRbCu.js +1 -0
- package/phanpy/dist/assets/locales/nl-NL-DxN4Yc1t.js +1 -0
- package/phanpy/dist/assets/locales/oc-FR-CfheRbCu.js +1 -0
- package/phanpy/dist/assets/locales/pl-PL-DkwGXf6P.js +1 -0
- package/phanpy/dist/assets/locales/pseudo-LOCALE-CQ4XxkQ0.js +1 -0
- package/phanpy/dist/assets/locales/pt-BR-nGL0HdFS.js +1 -0
- package/phanpy/dist/assets/locales/pt-PT-Cp17aj2z.js +1 -0
- package/phanpy/dist/assets/locales/ru-RU-0Y8cIoaK.js +1 -0
- package/phanpy/dist/assets/locales/th-TH-CfheRbCu.js +1 -0
- package/phanpy/dist/assets/locales/tok-CfheRbCu.js +1 -0
- package/phanpy/dist/assets/locales/tr-TR-CfheRbCu.js +1 -0
- package/phanpy/dist/assets/locales/uk-UA-C5E3dFBf.js +1 -0
- package/phanpy/dist/assets/locales/zh-CN-BHJi7_et.js +1 -0
- package/phanpy/dist/assets/locales/zh-TW-CfheRbCu.js +1 -0
- package/phanpy/dist/assets/main-BdqNbG-a.js +26 -0
- package/phanpy/dist/assets/main-BdqNbG-a.js.map +1 -0
- package/phanpy/dist/assets/mock-home-DGhR__He.js +2 -0
- package/phanpy/dist/assets/mock-home-DGhR__He.js.map +1 -0
- package/phanpy/dist/assets/multi-column-ROck0NVt.jpg +0 -0
- package/phanpy/dist/assets/multi-hashtag-timeline-DH5INVHi.jpg +0 -0
- package/phanpy/dist/assets/nested-comments-thread-hv59kZLc.jpg +0 -0
- package/phanpy/dist/assets/polyfill-force-H8sUkLGp.js +2 -0
- package/phanpy/dist/assets/polyfill-force-H8sUkLGp.js.map +1 -0
- package/phanpy/dist/assets/pwa-viewport-ogKbF51K.js +20 -0
- package/phanpy/dist/assets/pwa-viewport-ogKbF51K.js.map +1 -0
- package/phanpy/dist/assets/style-B1iLPTPR.css +2 -0
- package/phanpy/dist/assets/temml-DuyIQMei.js +97 -0
- package/phanpy/dist/assets/temml-DuyIQMei.js.map +1 -0
- package/phanpy/dist/assets/tinyld.light.node-DtbBhLYC.js +2 -0
- package/phanpy/dist/assets/tinyld.light.node-DtbBhLYC.js.map +1 -0
- package/phanpy/dist/assets/year-in-posts-B2lywqdx.png +0 -0
- package/phanpy/dist/assets/year-in-posts-DJv_star.js +3 -0
- package/phanpy/dist/assets/year-in-posts-DJv_star.js.map +1 -0
- package/phanpy/dist/compose/index.html +20 -0
- package/phanpy/dist/favicon.ico +0 -0
- package/phanpy/dist/index.html +73 -0
- package/phanpy/dist/logo-192.png +0 -0
- package/phanpy/dist/logo-512.png +0 -0
- package/phanpy/dist/logo-badge-72.png +0 -0
- package/phanpy/dist/logo-maskable-512.png +0 -0
- package/phanpy/dist/logo-monochrome-512.png +0 -0
- package/phanpy/dist/logo-monochrome-maskable-512.png +0 -0
- package/phanpy/dist/manifest.webmanifest +1 -0
- package/phanpy/dist/og-image-2.jpg +0 -0
- package/phanpy/dist/og-image.png +0 -0
- package/phanpy/dist/robots.txt +2 -0
- package/phanpy/dist/sw.js +25 -0
- package/phanpy/dist/version.json +1 -0
- package/run-agent.mjs +805 -0
- package/scripts/build-dist.mjs +37 -0
- package/scripts/verify-ui.mjs +60 -0
- package/ui/README.md +23 -0
- package/vendor/gate.cjs +132 -0
- package/vendor/idp-grant.cjs +394 -0
- package/vendor/jfetch.cjs +16 -0
- package/web/admin/admin.js +958 -0
- package/web/admin/bar.css +64 -0
- package/web/admin/bar.js +61 -0
- package/web/admin/client/client.js +66 -0
- package/web/admin/client/index.html +42 -0
- package/web/admin/index.html +493 -0
- package/web/admin/setup/index.html +172 -0
- package/web/admin/setup/setup.js +268 -0
- package/web/admin/window.css +55 -0
- package/web/admin/window.js +136 -0
- package/web/front/install.sh +69 -0
- package/web/front/new-account.html +424 -0
- package/web/front/run.html +138 -0
- package/web/front/solid-client-authn.bundle.js +2 -0
package/lib/store.mjs
ADDED
|
@@ -0,0 +1,470 @@
|
|
|
1
|
+
// store.mjs — PodStore: the agent's operational state as JSON documents in a
|
|
2
|
+
// container. The container is a Storage (lib/storage.mjs) — a pod over HTTP or
|
|
3
|
+
// a directory — and this file does not care which. The sync read/write surface
|
|
4
|
+
// of dk's fs Store is preserved so every consumer (publisher, intake, facade,
|
|
5
|
+
// …) is unchanged: reads come from an in-memory cache loaded once at boot,
|
|
6
|
+
// writes update the cache and are flushed through a serialized, debounced,
|
|
7
|
+
// retrying queue. Everything here is rebuildable from the /fediverse/ RDF, so
|
|
8
|
+
// a lost write degrades, it doesn't destroy. Without a storage the store is
|
|
9
|
+
// pure memory (tests, unconfigured boot).
|
|
10
|
+
|
|
11
|
+
import crypto from 'node:crypto';
|
|
12
|
+
import { sanitizeHtml } from './wire.mjs';
|
|
13
|
+
|
|
14
|
+
// A display name is text, not markup: strip tags rather than allow a subset,
|
|
15
|
+
// because there is no tag that belongs in one.
|
|
16
|
+
const plainText = (s) => sanitizeHtml(String(s || '')).replace(/<[^>]*>/g, '').trim();
|
|
17
|
+
|
|
18
|
+
// An avatar or header URL is written straight into an <img src> by the client.
|
|
19
|
+
// http(s) only — `javascript:` and `data:` have no business being an avatar.
|
|
20
|
+
const safeUrl = (u) => {
|
|
21
|
+
if (!u) return u;
|
|
22
|
+
try {
|
|
23
|
+
const parsed = new URL(String(u));
|
|
24
|
+
return (parsed.protocol === 'https:' || parsed.protocol === 'http:') ? String(u) : null;
|
|
25
|
+
} catch { return null; }
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
// Remote text arrives with no length of its own. `readCapped` bounds a document
|
|
29
|
+
// at 5 MB, which is a sane ceiling for a fetch and an absurd one for a display
|
|
30
|
+
// name — and whatever survives is then written into a document we serialize
|
|
31
|
+
// WHOLE on every change. One planted actor with a megabyte of bio permanently
|
|
32
|
+
// inflated actors.json, and a Follow flood re-PUT it once per commit batch.
|
|
33
|
+
// Mastodon's own limits are the shape of the real world; a little over them is
|
|
34
|
+
// generous rather than lossy.
|
|
35
|
+
const MAX_NAME = 500;
|
|
36
|
+
const MAX_SUMMARY = 5_000;
|
|
37
|
+
const MAX_CONTENT = 100_000;
|
|
38
|
+
const clamp = (s, n) => (typeof s === 'string' && s.length > n ? s.slice(0, n) : s);
|
|
39
|
+
|
|
40
|
+
const PUT_DEBOUNCE_MS = 300;
|
|
41
|
+
const PUT_RETRIES = 5;
|
|
42
|
+
// This is a CACHE of other people's actor documents — every search, every
|
|
43
|
+
// timeline post from a stranger, every hashtag sweep adds one, and nothing ever
|
|
44
|
+
// took one out. Bounded by dropping the least recently fetched, except for
|
|
45
|
+
// anyone we actually have a relationship with: losing a follower's display name
|
|
46
|
+
// would be re-fetching it on the next render for nothing.
|
|
47
|
+
const ACTOR_CACHE_MAX = 2000;
|
|
48
|
+
const NOTIFICATIONS_MAX = 500;
|
|
49
|
+
|
|
50
|
+
function prune(actors, max, contacts) {
|
|
51
|
+
const urls = Object.keys(actors);
|
|
52
|
+
if (urls.length <= max) return actors;
|
|
53
|
+
const keep = new Set([
|
|
54
|
+
...contacts.followers.map(f => f.actor),
|
|
55
|
+
...contacts.following.map(f => f.actor),
|
|
56
|
+
]);
|
|
57
|
+
const droppable = urls.filter(u => !keep.has(u))
|
|
58
|
+
.sort((a, b) => String(actors[a].fetchedAt || '').localeCompare(String(actors[b].fetchedAt || '')));
|
|
59
|
+
for (const u of droppable.slice(0, urls.length - max)) delete actors[u];
|
|
60
|
+
return actors;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Removing a follower is a DECISION — they unfollowed, or were ejected, or the
|
|
64
|
+
// account is gone. Reconciling the local list against the pod's published one
|
|
65
|
+
// would otherwise bring every one of them back, so each removal leaves a mark.
|
|
66
|
+
// Bounded: the same actor leaving twice is still one entry.
|
|
67
|
+
export function dropFollower(contacts, actor, why) {
|
|
68
|
+
contacts.followers = contacts.followers.filter(f => f.actor !== actor);
|
|
69
|
+
const gone = (contacts.removedFollowers || []).filter(r => r.actor !== actor);
|
|
70
|
+
gone.push({ actor, why, at: new Date().toISOString() });
|
|
71
|
+
contacts.removedFollowers = gone.slice(-500);
|
|
72
|
+
return contacts;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export class PodStore {
|
|
76
|
+
constructor({ storage = null, log = console.log } = {}) {
|
|
77
|
+
this.storage = storage;
|
|
78
|
+
this.log = log;
|
|
79
|
+
this.cache = new Map(); // name → parsed value
|
|
80
|
+
this.etags = new Map(); // name (or '') → last ETag, for revalidation
|
|
81
|
+
this.timers = new Map(); // name → debounce timer
|
|
82
|
+
this.dirty = new Set(); // written while held; flushed by commit/release
|
|
83
|
+
this._held = 0;
|
|
84
|
+
this.chain = Promise.resolve(); // serialized writes
|
|
85
|
+
this.verdicts = new Map(); // name → did its last write land? (read by commit)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
get base() { return this.storage?.base || null; }
|
|
89
|
+
|
|
90
|
+
attach(storage) {
|
|
91
|
+
// A different tree is different state: carrying the old cache and its
|
|
92
|
+
// ETags across would serve one container's documents as another's.
|
|
93
|
+
if (this.storage && this.storage.base !== storage.base) { this.cache.clear(); this.etags.clear(); }
|
|
94
|
+
this.storage = storage;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Load every state doc in the container into the cache. Missing container
|
|
98
|
+
// (first run) is fine — the cache just starts empty.
|
|
99
|
+
//
|
|
100
|
+
// `force` skips the container short-circuit below. A container's ETag says
|
|
101
|
+
// its CHILDREN have not changed; it does not vouch for their contents, and a
|
|
102
|
+
// peer agent rewriting a document it already had changes no containment
|
|
103
|
+
// triple. That is a fine trade for a viewer refreshing a display, and a bad
|
|
104
|
+
// one at the moment a viewer is promoted and starts acting on what it holds.
|
|
105
|
+
// Throws when the container cannot be read. An empty cache MUST mean "no
|
|
106
|
+
// state yet", never "the pod was unreachable" — the caller treats the
|
|
107
|
+
// former as a fresh install, and silently conflating them would look like
|
|
108
|
+
// an un-set-up agent every time the pod hiccups.
|
|
109
|
+
async load({ force = false } = {}) {
|
|
110
|
+
if (!this.storage) return;
|
|
111
|
+
// Revalidate rather than re-download: a viewer reloads this every few
|
|
112
|
+
// minutes and it is almost always unchanged. (Only a pod answers 304; a
|
|
113
|
+
// directory has nothing to revalidate against, so it always relists —
|
|
114
|
+
// which is a readdir, not a request.)
|
|
115
|
+
const listing = await this.storage.list('', { etag: force ? null : this.etags.get('') });
|
|
116
|
+
if (listing.notModified) return; // cannot happen when forced: no etag was sent
|
|
117
|
+
// A 404'd container over HTTP is "could not read", not "no state yet":
|
|
118
|
+
// setup creates this container, so once a credential exists it should
|
|
119
|
+
// answer — and a proxy fronting a dead pod 404s in exactly the same shape.
|
|
120
|
+
// A directory has no proxy to lie for it, so its absence stays an answer.
|
|
121
|
+
if (listing.missing && this.storage.kind === 'pod') {
|
|
122
|
+
throw new Error(`state container missing (HTTP 404) at ${this.base} — pod unreachable or state gone`);
|
|
123
|
+
}
|
|
124
|
+
this.etags.set('', listing.etag);
|
|
125
|
+
const names = listing.names.filter(n => n.endsWith('.json'));
|
|
126
|
+
let fetched = 0;
|
|
127
|
+
const skipped = [];
|
|
128
|
+
for (const name of names) {
|
|
129
|
+
const etag = force ? null : this.etags.get(name);
|
|
130
|
+
const r = await this.storage.read(name, { etag: etag && this.cache.has(name) ? etag : null });
|
|
131
|
+
if (r.notModified) continue; // ours is current
|
|
132
|
+
if (!r.ok) {
|
|
133
|
+
// One unreadable document must not restart the whole sweep — skip it,
|
|
134
|
+
// keep whatever we already hold, and try again next load (no etag is
|
|
135
|
+
// recorded, so the retry is unconditional). config.json is the
|
|
136
|
+
// exception: without it the caller cannot tell "never set up" from
|
|
137
|
+
// "could not read", and would tell the user to run setup.
|
|
138
|
+
if (name === 'config.json' && !this.cache.has(name)) {
|
|
139
|
+
throw new Error(`state doc ${name} unreadable (HTTP ${r.status})`);
|
|
140
|
+
}
|
|
141
|
+
skipped.push(`${name} (HTTP ${r.status})`);
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
fetched++;
|
|
145
|
+
this.etags.set(name, r.etag);
|
|
146
|
+
try { this.cache.set(name, JSON.parse(r.body)); }
|
|
147
|
+
catch (e) { this.log(`state load ${name}: unparsable (${e.message})`); }
|
|
148
|
+
}
|
|
149
|
+
if (skipped.length) this.log(`state load skipped ${skipped.length}: ${skipped.join(', ')}`);
|
|
150
|
+
this.log(`state loaded: ${this.cache.size} doc(s) from ${this.base} (${fetched} re-fetched)`);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
has(name) { return this.cache.has(name); }
|
|
154
|
+
|
|
155
|
+
read(name, fallback) {
|
|
156
|
+
return this.cache.has(name) ? structuredClone(this.cache.get(name)) : fallback;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
write(name, obj) {
|
|
160
|
+
this.cache.set(name, structuredClone(obj));
|
|
161
|
+
if (!this.storage) return;
|
|
162
|
+
// Held: record it and leave the writing to the commit boundary the caller
|
|
163
|
+
// already has. See hold().
|
|
164
|
+
if (this._held) { this.dirty.add(name); return; }
|
|
165
|
+
this._arm(name);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
_arm(name) {
|
|
169
|
+
clearTimeout(this.timers.get(name));
|
|
170
|
+
this.timers.set(name, setTimeout(() => { this.timers.delete(name); this._put(name); }, PUT_DEBOUNCE_MS));
|
|
171
|
+
this.timers.get(name).unref?.();
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Suspend the debounce for the length of a sweep.
|
|
175
|
+
//
|
|
176
|
+
// 300ms coalesces writes that arrive together, and the inbox drain's never
|
|
177
|
+
// do: every handler awaits a signed fetch to somebody else's server first, so
|
|
178
|
+
// each item's timer fires before the next item is even read. statuses.json,
|
|
179
|
+
// actors.json and notifications.json were therefore serialized and written
|
|
180
|
+
// WHOLE once per item — fifty times in a fifty-item sweep, where the drain
|
|
181
|
+
// already commits every ten.
|
|
182
|
+
//
|
|
183
|
+
// Nested, because a drain can run a handler that starts another. release()
|
|
184
|
+
// re-arms anything still dirty rather than dropping it, so a write made by
|
|
185
|
+
// something else while the drain held the store is never stranded.
|
|
186
|
+
hold() { this._held = (this._held || 0) + 1; }
|
|
187
|
+
|
|
188
|
+
release() {
|
|
189
|
+
if (this._held) this._held -= 1;
|
|
190
|
+
if (this._held) return;
|
|
191
|
+
for (const name of [...this.dirty]) { this.dirty.delete(name); this._arm(name); }
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Resolves true when the document is on the pod, false when it is not — a
|
|
195
|
+
// caller that is about to destroy the only other copy of something needs to
|
|
196
|
+
// be able to tell. `chain` stays the bare serializer; the boolean rides on
|
|
197
|
+
// the returned promise so one failure cannot poison the queue.
|
|
198
|
+
_put(name) {
|
|
199
|
+
const done = this.chain.then(async () => {
|
|
200
|
+
const body = JSON.stringify(this.cache.get(name), null, 2) + '\n';
|
|
201
|
+
for (let attempt = 1; attempt <= PUT_RETRIES; attempt++) {
|
|
202
|
+
// A storage that throws is a bug, not a hiccup — but it must not
|
|
203
|
+
// escape into the write queue, where it would look like success.
|
|
204
|
+
const r = await this.storage.write(name, body, 'application/json')
|
|
205
|
+
.catch(e => ({ ok: false, retry: false, why: e.message }));
|
|
206
|
+
if (r.ok) return true;
|
|
207
|
+
// The storage says whether trying again could possibly help: a pod's
|
|
208
|
+
// 4xx is an answer rather than a hiccup, and a directory's EACCES will
|
|
209
|
+
// still be an EACCES in two seconds.
|
|
210
|
+
if (!r.retry) { this.log(`state write ${name} refused (${r.why}) — not retrying`); return false; }
|
|
211
|
+
if (attempt === PUT_RETRIES) { this.log(`state write ${name} gave up: ${r.why}`); return false; }
|
|
212
|
+
const ladder = Math.min(attempt * 2000, 30_000);
|
|
213
|
+
await new Promise(res => setTimeout(res, r.retryAfterMs || Math.round(ladder * (0.8 + Math.random() * 0.4))));
|
|
214
|
+
}
|
|
215
|
+
return false;
|
|
216
|
+
});
|
|
217
|
+
// Record the outcome. A write whose debounce fires on its own is not in
|
|
218
|
+
// any caller's hands: `chain` waits for it but throws its result away, so
|
|
219
|
+
// commit() reported a clean sweep for a document that had been refused —
|
|
220
|
+
// and commit()'s one caller is the inbox drain, about to delete the pod's
|
|
221
|
+
// only copy of what that document describes.
|
|
222
|
+
const tracked = done.then((ok) => { this.verdicts.set(name, ok); return ok; });
|
|
223
|
+
this.chain = tracked.then(() => {}, () => {});
|
|
224
|
+
return tracked;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Remove a state doc from the cache AND the pod (used when key material
|
|
228
|
+
// migrates to the local machine — leaving the copy behind would defeat it).
|
|
229
|
+
async remove(name) {
|
|
230
|
+
this.cache.delete(name);
|
|
231
|
+
clearTimeout(this.timers.get(name));
|
|
232
|
+
this.timers.delete(name);
|
|
233
|
+
if (!this.storage) return true;
|
|
234
|
+
return this.storage.remove(name);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Force every pending write out NOW and say whether they all landed.
|
|
238
|
+
// The caller that needs this is the inbox drain: taking an item out of the
|
|
239
|
+
// pod's inbox is a destructive read, so it must not happen until the result
|
|
240
|
+
// of handling it is written down. With no storage the store is pure memory
|
|
241
|
+
// and there is nothing to land, so that counts as written.
|
|
242
|
+
async commit() {
|
|
243
|
+
const pending = [];
|
|
244
|
+
// Held names too, or a sweep that suspended the debounce would delete the
|
|
245
|
+
// pod's copy of an item whose result had not been written down — the one
|
|
246
|
+
// thing commit() exists to prevent.
|
|
247
|
+
for (const name of new Set([...this.timers.keys(), ...this.dirty])) {
|
|
248
|
+
const t = this.timers.get(name);
|
|
249
|
+
if (t) { clearTimeout(t); this.timers.delete(name); }
|
|
250
|
+
this.dirty.delete(name);
|
|
251
|
+
pending.push(this._put(name));
|
|
252
|
+
}
|
|
253
|
+
await this.chain; // includes anything already in flight
|
|
254
|
+
await Promise.all(pending);
|
|
255
|
+
// Every verdict since the last commit, forced and self-fired alike. Cleared
|
|
256
|
+
// once read: a refusal is reported to the caller that can act on it, and
|
|
257
|
+
// does not then condemn every later commit.
|
|
258
|
+
const landed = [...this.verdicts.values()].every(Boolean);
|
|
259
|
+
this.verdicts.clear();
|
|
260
|
+
return landed;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// Flush pending debounced writes (shutdown path). Same work, result ignored.
|
|
264
|
+
async flush() { await this.commit(); }
|
|
265
|
+
|
|
266
|
+
// ---- the domain helpers, unchanged from dk's Store ----
|
|
267
|
+
|
|
268
|
+
// config: { remotePod, handle, name, issuer } (credential lives ONLY in
|
|
269
|
+
// the local credential file, never in pod state)
|
|
270
|
+
getConfig() { return this.read('config.json', null); }
|
|
271
|
+
setConfig(cfg) { this.write('config.json', cfg); }
|
|
272
|
+
|
|
273
|
+
// queue: [{ inbox, activity, attempts, nextAt }]
|
|
274
|
+
getQueue() { return this.read('queue.json', []); }
|
|
275
|
+
setQueue(q) { this.write('queue.json', q); }
|
|
276
|
+
|
|
277
|
+
// blocklist: { domains: ["spam.example", ...], actors: ["https://host/actor", ...] }
|
|
278
|
+
// Two granularities because a whole instance is usually the wrong unit: one
|
|
279
|
+
// bad neighbour should not cost you everyone else on their server.
|
|
280
|
+
getBlocklist() {
|
|
281
|
+
const b = this.read('blocklist.json', {});
|
|
282
|
+
return { domains: b.domains || [], actors: b.actors || [] };
|
|
283
|
+
}
|
|
284
|
+
setBlocklist(b) { this.write('blocklist.json', b); }
|
|
285
|
+
// Takes an actor URL or an object URL: the actor list only ever matches the
|
|
286
|
+
// former, the domain list matches either.
|
|
287
|
+
isBlocked(url) {
|
|
288
|
+
let host;
|
|
289
|
+
try { host = new URL(url).hostname; } catch { return true; } // unparsable → treat as hostile
|
|
290
|
+
const { domains, actors } = this.getBlocklist();
|
|
291
|
+
if (actors.includes(url)) return true;
|
|
292
|
+
return domains.some(d => host === d || host.endsWith('.' + d));
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// contacts: { followers: [{actor, inbox, sharedInbox}], following: [{actor, inbox, accepted}] }
|
|
296
|
+
getContacts() { return this.read('contacts.json', { followers: [], following: [] }); }
|
|
297
|
+
setContacts(c) { this.write('contacts.json', c); }
|
|
298
|
+
|
|
299
|
+
// muted: { actors: [...] } — members whose posts a group declines to carry.
|
|
300
|
+
// A group cannot force an unfollow, so declining to amplify is the only
|
|
301
|
+
// lever it actually holds.
|
|
302
|
+
getMuted() { return this.read('muted.json', { actors: [] }); }
|
|
303
|
+
setMuted(m) { this.write('muted.json', m); }
|
|
304
|
+
|
|
305
|
+
// The client's own reading arrangements — lists, keyword filters, posts
|
|
306
|
+
// waiting to be published. None of it federates.
|
|
307
|
+
getLists() { return this.read('lists.json', []); }
|
|
308
|
+
setLists(l) { this.write('lists.json', l); }
|
|
309
|
+
getFilters() { return this.read('filters.json', []); }
|
|
310
|
+
setFilters(f) { this.write('filters.json', f); }
|
|
311
|
+
getScheduled() { return this.read('scheduled.json', []); }
|
|
312
|
+
setScheduled(s) { this.write('scheduled.json', s); }
|
|
313
|
+
|
|
314
|
+
// pending: [{ noteId, actor, at }] — posts a reviewed group has ingested but
|
|
315
|
+
// not carried, awaiting the operator.
|
|
316
|
+
getPending() { return this.read('pending.json', []); }
|
|
317
|
+
setPending(p) { this.write('pending.json', p); }
|
|
318
|
+
|
|
319
|
+
// requests: [{ actor, inbox, sharedInbox, activity, at }] — Follows a group
|
|
320
|
+
// with approveJoins has neither accepted nor rejected. The whole Follow is
|
|
321
|
+
// kept because the Accept or Reject has to name it.
|
|
322
|
+
getRequests() { return this.read('requests.json', []); }
|
|
323
|
+
setRequests(r) { this.write('requests.json', r); }
|
|
324
|
+
|
|
325
|
+
// dead letters: inbox items that failed verification or exhausted retries —
|
|
326
|
+
// kept for inspection (GET /deadletter) instead of being destroyed.
|
|
327
|
+
getDeadLetters() { return this.read('deadletter.json', []); }
|
|
328
|
+
addDeadLetter(entry) {
|
|
329
|
+
const dl = this.getDeadLetters();
|
|
330
|
+
dl.unshift({ at: new Date().toISOString(), ...entry });
|
|
331
|
+
this.write('deadletter.json', dl.slice(0, 200));
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// statuses index: operational mirror of what lives in the pod as RDF, in
|
|
335
|
+
// arrival order — the Mastodon-API facade serves timelines from this.
|
|
336
|
+
// [{ noteId, actor, content, published, inReplyTo, kind: 'timeline'|'post'|'tag' }]
|
|
337
|
+
getStatuses() { return this.read('statuses.json', []); }
|
|
338
|
+
// A count must not pay for a clone: nodeinfo and the instance document are
|
|
339
|
+
// public and answer strangers, so they read the cache directly.
|
|
340
|
+
countStatuses(kind = null) {
|
|
341
|
+
const all = this.cache.get('statuses.json') || [];
|
|
342
|
+
return kind === null ? all.length : all.reduce((n, s) => n + (s.kind === kind ? 1 : 0), 0);
|
|
343
|
+
}
|
|
344
|
+
addStatus(s) {
|
|
345
|
+
const all = this.getStatuses();
|
|
346
|
+
if (all.some(x => x.noteId === s.noteId)) return;
|
|
347
|
+
// Same reason as cacheActor: statuses.json is serialized whole on every
|
|
348
|
+
// change, and remote content is only bounded by the 5 MB fetch ceiling.
|
|
349
|
+
if (typeof s.content === 'string' && s.content.length > MAX_CONTENT) {
|
|
350
|
+
s = { ...s, content: clamp(s.content, MAX_CONTENT), truncated: true };
|
|
351
|
+
}
|
|
352
|
+
all.unshift(s);
|
|
353
|
+
this.write('statuses.json', all.slice(0, 1000));
|
|
354
|
+
this.onEvent?.('status', s); // streaming subscribers
|
|
355
|
+
}
|
|
356
|
+
updateStatus(noteId, patch) {
|
|
357
|
+
const all = this.getStatuses();
|
|
358
|
+
const i = all.findIndex(x => x.noteId === noteId);
|
|
359
|
+
if (i < 0) return null;
|
|
360
|
+
all[i] = { ...all[i], ...patch };
|
|
361
|
+
this.write('statuses.json', all);
|
|
362
|
+
return all[i];
|
|
363
|
+
}
|
|
364
|
+
removeStatus(noteId) {
|
|
365
|
+
this.write('statuses.json', this.getStatuses().filter(x => x.noteId !== noteId));
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// notifications: what other actors did to us — the facade serves
|
|
369
|
+
// /api/v1/notifications from this. [{ id, type, actor, noteId?, at }]
|
|
370
|
+
// The id is a content hash, so a re-delivered activity dedupes.
|
|
371
|
+
getNotifications() { return this.read('notifications.json', []); }
|
|
372
|
+
|
|
373
|
+
// `unverified` marks one whose actor nothing vouched for — a Like or Announce
|
|
374
|
+
// arrives with no signature and, unlike a Create, has no object at the
|
|
375
|
+
// sender's origin to re-read, so the actor is whatever the body claimed.
|
|
376
|
+
//
|
|
377
|
+
// It decides what the cap evicts. The id is a content hash, so changing one
|
|
378
|
+
// character of the actor gives a fresh entry: ~500 appends into the pod's
|
|
379
|
+
// public-append inbox used to push out every real favourite, boost, mention
|
|
380
|
+
// and follow request, and notifications are the one thing nothing can rebuild
|
|
381
|
+
// — not from the pod, not from the RDF. Unverified entries are dropped first
|
|
382
|
+
// now, so a flood can only ever evict itself, and a stranger's genuine
|
|
383
|
+
// favourite still shows up, which is what recording them at all is for.
|
|
384
|
+
addNotification(n) {
|
|
385
|
+
const all = this.getNotifications();
|
|
386
|
+
const id = crypto.createHash('sha256').update(JSON.stringify(n)).digest('hex').slice(0, 16);
|
|
387
|
+
if (all.some(x => x.id === id)) return;
|
|
388
|
+
const entry = { id, at: new Date().toISOString(), ...n };
|
|
389
|
+
all.unshift(entry);
|
|
390
|
+
let kept = all;
|
|
391
|
+
if (kept.length > NOTIFICATIONS_MAX) {
|
|
392
|
+
const solid = kept.filter(x => !x.unverified);
|
|
393
|
+
const loose = kept.filter(x => x.unverified);
|
|
394
|
+
kept = solid.length >= NOTIFICATIONS_MAX
|
|
395
|
+
? solid.slice(0, NOTIFICATIONS_MAX)
|
|
396
|
+
: [...solid, ...loose.slice(0, NOTIFICATIONS_MAX - solid.length)]
|
|
397
|
+
.sort((a, b) => String(b.at).localeCompare(String(a.at)));
|
|
398
|
+
}
|
|
399
|
+
this.write('notifications.json', kept);
|
|
400
|
+
this.onEvent?.('notification', entry); // streaming subscribers
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// uploaded media registry: opaque id → { url, mediaType, description }
|
|
404
|
+
getMedia() { return this.read('media.json', {}); }
|
|
405
|
+
setMedia(id, entry) {
|
|
406
|
+
const m = this.getMedia();
|
|
407
|
+
m[id] = entry;
|
|
408
|
+
this.write('media.json', m);
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// actor-doc cache for account rendering (display name, avatar).
|
|
412
|
+
//
|
|
413
|
+
// Everything here comes from a document at someone else's origin, and the
|
|
414
|
+
// facade serves `summary` back to the client as a status account's `note`,
|
|
415
|
+
// which every Mastodon client renders as HTML. Note CONTENT was sanitized at
|
|
416
|
+
// all four of its entry points and this, the other thing a remote party
|
|
417
|
+
// writes, was not — so looking anyone up handed their markup to the client.
|
|
418
|
+
// Sanitized at the boundary where it enters our store, so nothing downstream
|
|
419
|
+
// has to remember. Names are plain text and are stripped outright.
|
|
420
|
+
getActors() { return this.read('actors.json', {}); }
|
|
421
|
+
cacheActor(url, doc) {
|
|
422
|
+
const a = this.getActors();
|
|
423
|
+
a[url] = {
|
|
424
|
+
name: clamp(plainText(doc.name || doc.preferredUsername || ''), MAX_NAME),
|
|
425
|
+
preferredUsername: clamp(plainText(doc.preferredUsername || ''), MAX_NAME),
|
|
426
|
+
icon: safeUrl(typeof doc.icon === 'object' ? doc.icon?.url : doc.icon),
|
|
427
|
+
image: safeUrl(typeof doc.image === 'object' ? doc.image?.url : doc.image),
|
|
428
|
+
summary: clamp(sanitizeHtml(doc.summary || ''), MAX_SUMMARY),
|
|
429
|
+
// A Group is an actor too, and a client that cannot tell shows it as a
|
|
430
|
+
// person. The counts are NOT in this document — they are the collections'
|
|
431
|
+
// totalItems, filled in only when someone asks about this actor by name.
|
|
432
|
+
type: doc.type || 'Person',
|
|
433
|
+
followers: doc.followers || null,
|
|
434
|
+
following: doc.following || null,
|
|
435
|
+
...(a[url]?.counts ? { counts: a[url].counts } : {}),
|
|
436
|
+
fetchedAt: new Date().toISOString(),
|
|
437
|
+
};
|
|
438
|
+
this.write('actors.json', prune(a, ACTOR_CACHE_MAX, this.getContacts()));
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// @user@host for an actor we have cached. Null when we have not, rather than
|
|
442
|
+
// a last-segment guess off the URL: that guess is what rendered a group as
|
|
443
|
+
// @actor@host, because our own actors all end in /actor.
|
|
444
|
+
handleOf(actorUrl) {
|
|
445
|
+
const user = this.getActors()[actorUrl]?.preferredUsername;
|
|
446
|
+
if (!user) return null;
|
|
447
|
+
try { return `@${user}@${new URL(actorUrl).host}`; } catch { return null; }
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
// Mastodon-API opaque ids ↔ URLs (snac-style: hashes are fine for clients).
|
|
451
|
+
getIds() { return this.read('ids.json', {}); }
|
|
452
|
+
// The id is a hash OF the url, so the mapping is computable — the scan was
|
|
453
|
+
// only ever finding what the hash already tells us. It ran on the status
|
|
454
|
+
// render path, once per rendered status, over a map that never shrinks.
|
|
455
|
+
//
|
|
456
|
+
// The map is still kept, because urlFor has to answer for ids a client is
|
|
457
|
+
// still holding, and it is still consulted first so an entry written under
|
|
458
|
+
// some older scheme keeps resolving.
|
|
459
|
+
idFor(url) {
|
|
460
|
+
const id = crypto.createHash('sha256').update(url).digest('hex').slice(0, 16);
|
|
461
|
+
const ids = this.getIds();
|
|
462
|
+
if (ids[id] === url) return id; // already known, nothing to write
|
|
463
|
+
const legacy = Object.entries(ids).find(([, u]) => u === url);
|
|
464
|
+
if (legacy) return legacy[0]; // pre-hash entry a client may hold
|
|
465
|
+
ids[id] = url;
|
|
466
|
+
this.write('ids.json', ids);
|
|
467
|
+
return id;
|
|
468
|
+
}
|
|
469
|
+
urlFor(id) { return this.getIds()[id] || null; }
|
|
470
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
// streaming.mjs — the Mastodon streaming API over a hand-rolled WebSocket
|
|
2
|
+
// server (send-mostly; we answer pings and ignore everything else a client
|
|
3
|
+
// frames at us). No dependencies — the handshake is one SHA-1 and text
|
|
4
|
+
// frames are trivial to emit. Clients connect to
|
|
5
|
+
// ws://host/api/v1/streaming?access_token=…&stream=user
|
|
6
|
+
// and receive {stream, event, payload} messages for new statuses and
|
|
7
|
+
// notifications the moment the agent learns of them.
|
|
8
|
+
|
|
9
|
+
import crypto from 'node:crypto';
|
|
10
|
+
|
|
11
|
+
const WS_MAGIC = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11';
|
|
12
|
+
|
|
13
|
+
// One identity's live feed is a handful of that owner's clients, so these are
|
|
14
|
+
// generous. Without them a single token could hold every file descriptor open,
|
|
15
|
+
// and one client that stops reading would grow its write buffer forever.
|
|
16
|
+
const MAX_SOCKETS = 20; // matches the client-token cap
|
|
17
|
+
const MAX_BUFFERED_BYTES = 1024 * 1024; // a consumer this far behind is gone
|
|
18
|
+
|
|
19
|
+
import { checkRequest } from './guard.mjs';
|
|
20
|
+
|
|
21
|
+
export class Streaming {
|
|
22
|
+
constructor({ masto, log = console.log, allowed = null, gate = null, gateOptional = false }) {
|
|
23
|
+
this.masto = masto;
|
|
24
|
+
this.log = log;
|
|
25
|
+
this.allowed = allowed;
|
|
26
|
+
this.gate = gate;
|
|
27
|
+
// A pod-hosted identity is a public instance: a phone app arrives with its
|
|
28
|
+
// bearer and no way to send the operator's header, so there the bearer is
|
|
29
|
+
// the credential. Locally the gate stands in front of the socket as well.
|
|
30
|
+
this.gateOptional = gateOptional;
|
|
31
|
+
this.sockets = new Set(); // entries: { send(string), close() }
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Whether a client may open the live feed, asked of the upgrade request
|
|
35
|
+
// alone. Shared with the pod-server front, which completes the handshake
|
|
36
|
+
// before any of this runs and so must answer by closing rather than refusing.
|
|
37
|
+
authorizeUpgrade(req, url) {
|
|
38
|
+
const proto = String(req.headers['sec-websocket-protocol'] || '').split(',')[0].trim();
|
|
39
|
+
const token = url.searchParams.get('access_token') || proto;
|
|
40
|
+
const hasToken = this.masto.tokens().includes(token);
|
|
41
|
+
// WebSockets are exempt from CORS. A live bearer is the credential — a
|
|
42
|
+
// web client on its own origin connects the way it does to any Mastodon
|
|
43
|
+
// server — so with one, only the Host check stands (it is what stops DNS
|
|
44
|
+
// rebinding); without one the full Host/Origin firewall applies.
|
|
45
|
+
const bad = this.allowed ? checkRequest(req, this.allowed, { ignoreOrigin: hasToken }) : null;
|
|
46
|
+
if (bad) return `streaming refused: ${bad}`;
|
|
47
|
+
// A gate token covers every other route; an upgrade that skipped it was a
|
|
48
|
+
// hole in a control the operator had switched on. A client bearer is the
|
|
49
|
+
// other way in — a phone app has no way to send the operator's header.
|
|
50
|
+
if (this.gate && !this.gate.upgradeOk(req) && !(this.gateOptional && hasToken)) {
|
|
51
|
+
return 'streaming refused: no gate token';
|
|
52
|
+
}
|
|
53
|
+
if (!hasToken) return 'streaming refused: not a live client token';
|
|
54
|
+
if (this.sockets.size >= MAX_SOCKETS) return 'streaming refused: too many live connections';
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Adopt a WebSocket somebody else finished the handshake for — the pod
|
|
59
|
+
// server owns the socket, and hands over an object that already frames.
|
|
60
|
+
adopt(ws) {
|
|
61
|
+
const entry = {
|
|
62
|
+
send: (str) => {
|
|
63
|
+
// A consumer that stops reading must not keep costing memory.
|
|
64
|
+
if ((ws.bufferedAmount ?? 0) > MAX_BUFFERED_BYTES) {
|
|
65
|
+
this.sockets.delete(entry);
|
|
66
|
+
try { ws.close(); } catch { /* already gone */ }
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
ws.send(str);
|
|
70
|
+
},
|
|
71
|
+
close: () => ws.close(),
|
|
72
|
+
};
|
|
73
|
+
this.sockets.add(entry);
|
|
74
|
+
const drop = () => this.sockets.delete(entry);
|
|
75
|
+
ws.on('close', drop);
|
|
76
|
+
ws.on('error', drop);
|
|
77
|
+
return entry;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Let go of every client: the server is stopping, not the connection failing.
|
|
81
|
+
stop() {
|
|
82
|
+
for (const s of this.sockets) { try { s.close(); } catch { /* already gone */ } }
|
|
83
|
+
this.sockets.clear();
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Attach the upgrade handler to an http.Server.
|
|
87
|
+
attach(server) {
|
|
88
|
+
server.on('upgrade', (req, socket) => {
|
|
89
|
+
const url = new URL(req.url, 'http://localhost');
|
|
90
|
+
if (!url.pathname.startsWith('/api/v1/streaming')) { socket.destroy(); return; }
|
|
91
|
+
// WebSockets are exempt from CORS, so the Host/Origin firewall is the
|
|
92
|
+
// only thing standing between a visited page and the live feed.
|
|
93
|
+
const refusal = this.authorizeUpgrade(req, url);
|
|
94
|
+
if (refusal) {
|
|
95
|
+
this.log(refusal);
|
|
96
|
+
socket.write(refusal.includes('refused:') && !refusal.includes('token')
|
|
97
|
+
? 'HTTP/1.1 403 Forbidden\r\n\r\n'
|
|
98
|
+
: 'HTTP/1.1 401 Unauthorized\r\n\r\n');
|
|
99
|
+
socket.destroy();
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
const proto = String(req.headers['sec-websocket-protocol'] || '').split(',')[0].trim();
|
|
103
|
+
const accept = crypto.createHash('sha1')
|
|
104
|
+
.update(req.headers['sec-websocket-key'] + WS_MAGIC).digest('base64');
|
|
105
|
+
socket.write('HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n'
|
|
106
|
+
+ `Sec-WebSocket-Accept: ${accept}\r\n`
|
|
107
|
+
+ (proto ? `Sec-WebSocket-Protocol: ${proto}\r\n` : '') + '\r\n');
|
|
108
|
+
socket.setNoDelay(true);
|
|
109
|
+
const entry = {
|
|
110
|
+
send: (str) => {
|
|
111
|
+
// A consumer that stops reading must not keep costing memory.
|
|
112
|
+
if (socket.writableLength > MAX_BUFFERED_BYTES) {
|
|
113
|
+
this.sockets.delete(entry);
|
|
114
|
+
try { socket.destroy(); } catch { /* already gone */ }
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
socket.write(this.frame(str));
|
|
118
|
+
},
|
|
119
|
+
close: () => { try { socket.end(); } catch { /* already gone */ } },
|
|
120
|
+
};
|
|
121
|
+
this.sockets.add(entry);
|
|
122
|
+
const drop = () => this.sockets.delete(entry);
|
|
123
|
+
socket.on('close', drop);
|
|
124
|
+
socket.on('error', drop);
|
|
125
|
+
socket.on('data', (buf) => {
|
|
126
|
+
if (buf.length && (buf[0] & 0x0f) === 0x9) { // ping → pong
|
|
127
|
+
try { socket.write(Buffer.from([0x8a, 0x00])); } catch { drop(); }
|
|
128
|
+
}
|
|
129
|
+
if (buf.length && (buf[0] & 0x0f) === 0x8) { drop(); try { socket.end(); } catch {} } // close
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Server→client unmasked text frame.
|
|
135
|
+
frame(str) {
|
|
136
|
+
const payload = Buffer.from(str);
|
|
137
|
+
let header;
|
|
138
|
+
if (payload.length < 126) header = Buffer.from([0x81, payload.length]);
|
|
139
|
+
else if (payload.length < 65536) {
|
|
140
|
+
header = Buffer.alloc(4); header[0] = 0x81; header[1] = 126; header.writeUInt16BE(payload.length, 2);
|
|
141
|
+
} else {
|
|
142
|
+
header = Buffer.alloc(10); header[0] = 0x81; header[1] = 127; header.writeBigUInt64BE(BigInt(payload.length), 2);
|
|
143
|
+
}
|
|
144
|
+
return Buffer.concat([header, payload]);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
broadcast(event, payloadObj, streams = ['user']) {
|
|
148
|
+
if (!this.sockets.size) return;
|
|
149
|
+
const message = JSON.stringify({ stream: streams, event, payload: JSON.stringify(payloadObj) });
|
|
150
|
+
for (const s of this.sockets) {
|
|
151
|
+
try { s.send(message); } catch { this.sockets.delete(s); }
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|