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/intake.mjs
ADDED
|
@@ -0,0 +1,1710 @@
|
|
|
1
|
+
// intake.mjs — drains the remote pod's public-append inbox and applies side
|
|
2
|
+
// effects. Inbound authenticity: LDN bodies don't carry the delivery's
|
|
3
|
+
// HTTP-Signature headers, so instead of verifying signatures we VERIFY BY
|
|
4
|
+
// DEREFERENCING — re-fetch the claimed object/actor from its origin (signed
|
|
5
|
+
// GET, so authorized-fetch instances answer) and trust only what the origin
|
|
6
|
+
// itself serves.
|
|
7
|
+
//
|
|
8
|
+
// Failure policy: a REJECTED item (verification says no) goes to the
|
|
9
|
+
// dead-letter store and leaves the inbox; a FAILING item (exception —
|
|
10
|
+
// network, remote 5xx) stays in the inbox for the next drain, and moves to
|
|
11
|
+
// the dead-letter store after MAX_ITEM_ATTEMPTS. Nothing is silently
|
|
12
|
+
// destroyed.
|
|
13
|
+
//
|
|
14
|
+
// Wake-up: WebSocketChannel2023 push on the inbox container (probe P4), plus
|
|
15
|
+
// a poll every POLL_MS as fallback, plus a drain at startup.
|
|
16
|
+
|
|
17
|
+
import * as $rdf from 'rdflib';
|
|
18
|
+
import { USER_AGENT } from './ua.mjs';
|
|
19
|
+
import { PUBLIC } from './wire.mjs';
|
|
20
|
+
import { HTTP_TIMEOUT_MS, readCapped } from './safefetch.mjs';
|
|
21
|
+
import { dropFollower } from './store.mjs';
|
|
22
|
+
|
|
23
|
+
const RDF = $rdf.Namespace('http://www.w3.org/1999/02/22-rdf-syntax-ns#');
|
|
24
|
+
const NOTIFY = $rdf.Namespace('http://www.w3.org/ns/solid/notifications#');
|
|
25
|
+
|
|
26
|
+
const POLL_MS = 2 * 60_000; // fallback cadence when there is no push
|
|
27
|
+
// With a live socket the poll is pure redundancy: it exists for the case where
|
|
28
|
+
// push is down, so it slows right down while push is up.
|
|
29
|
+
const POLL_PUSH_OK_MS = 10 * 60_000;
|
|
30
|
+
// The channel a subscription returns outlives a dropped socket, so reconnecting
|
|
31
|
+
// reuses it. Creating a new one per reconnect is what buried solidcommunity.net
|
|
32
|
+
// in channel records they then had to sweep.
|
|
33
|
+
const CHANNEL_DOC = 'inbox-channel.json';
|
|
34
|
+
// A flapping socket used to POST a NEW WebSocketChannel2023 channel every two
|
|
35
|
+
// seconds — hundreds an hour against a server that is already struggling, and
|
|
36
|
+
// channel churn its operators have to sweep up. Backs off instead, and an open
|
|
37
|
+
// only triggers a sweep if we have not just swept.
|
|
38
|
+
const RECONNECT_MIN_MS = 2_000;
|
|
39
|
+
const RECONNECT_MAX_MS = 5 * 60_000;
|
|
40
|
+
// How long a socket must stay up before the backoff counts it as a success and
|
|
41
|
+
// resets. Shorter than that is a flap, not a connection.
|
|
42
|
+
const RECONNECT_STABLE_MS = 60_000;
|
|
43
|
+
const OPEN_DRAIN_MIN_GAP_MS = 30_000;
|
|
44
|
+
// A container that times out will time out again in two minutes, and each
|
|
45
|
+
// attempt holds one of the pod's workers for the full timeout. Sweeping stops
|
|
46
|
+
// for a while instead, doubling up to half an hour.
|
|
47
|
+
const DRAIN_COOLDOWN_MIN_MS = 2 * 60_000;
|
|
48
|
+
const DRAIN_COOLDOWN_MAX_MS = 30 * 60_000;
|
|
49
|
+
// Our DELETEs take the same container write lock as the deliveries arriving
|
|
50
|
+
// into it — a gap between them keeps a sweep from convoying against inbound.
|
|
51
|
+
const DELETE_GAP_MS = 150;
|
|
52
|
+
const CHAIN_GAP_MS = 5_000; // pause between chained backlog sweeps
|
|
53
|
+
// How many handled items ride on one commit before they are deleted. Small
|
|
54
|
+
// enough that a crash re-does little, large enough that a flood of fast
|
|
55
|
+
// rejections does not become a pod write per item.
|
|
56
|
+
const DELETE_BATCH = 10;
|
|
57
|
+
// Attempt counts live in pod state, not in memory: a restart used to hand every
|
|
58
|
+
// poison item five fresh tries, and under a crash loop that is unbounded.
|
|
59
|
+
const ATTEMPTS_DOC = 'intake-attempts.json';
|
|
60
|
+
const ATTEMPTS_TTL_MS = 7 * 24 * 60 * 60_000;
|
|
61
|
+
const MAX_ITEM_ATTEMPTS = 5;
|
|
62
|
+
const MAX_ITEMS_PER_DRAIN = 50;
|
|
63
|
+
// A note's replies collection is rewritten WHOLE every time one is added, so
|
|
64
|
+
// without a cap the bytes are quadratic in a number a stranger chooses.
|
|
65
|
+
const MAX_REPLIES_RECORDED = 500;
|
|
66
|
+
// Ids of activities already forwarded to our followers (§7.1.2), so a re-drain
|
|
67
|
+
// never re-broadcasts one. A ceiling on the record, not on forwarding.
|
|
68
|
+
const MAX_FORWARDED = 2000;
|
|
69
|
+
// How many posts may wait for a group operator's decision. A ceiling, not a
|
|
70
|
+
// window: full refuses the newest rather than dropping the oldest.
|
|
71
|
+
const MAX_PENDING_REVIEW = 500;
|
|
72
|
+
// A group's membership, cached: it changes when someone joins or leaves, and
|
|
73
|
+
// re-reading it on every arriving post would spend one stranger's fetch per
|
|
74
|
+
// message on a list that moves in days.
|
|
75
|
+
const CO_MEMBER_TTL_MS = 24 * 60 * 60_000;
|
|
76
|
+
const CO_MEMBER_MAX = 5000;
|
|
77
|
+
// An activity is a few kB. This is generous by two orders of magnitude and
|
|
78
|
+
// still bounds what one Append can make us hold in memory.
|
|
79
|
+
const MAX_ITEM_BYTES = 512 * 1024;
|
|
80
|
+
// The AS2 actor types. A group is as much an actor as a person is.
|
|
81
|
+
const ACTOR_TYPES = new Set(['Person', 'Group', 'Service', 'Application', 'Organization']);
|
|
82
|
+
// The AS2 types the fediverse actually posts. `Note` alone is Mastodon's world
|
|
83
|
+
// and not the fediverse's: an Article is a Plume or WriteFreely post, a
|
|
84
|
+
// Question is a poll, a Video is PeerTube, a Page is Lemmy, an Audio is
|
|
85
|
+
// Funkwhale. Insisting on Note dead-lettered every one of them as "not a
|
|
86
|
+
// verifiable Note" — from people the owner had chosen to follow, silently.
|
|
87
|
+
//
|
|
88
|
+
// They share the shape this code reads: attributedTo, content, published,
|
|
89
|
+
// inReplyTo, tag, attachment. A poll's options are dropped, which is a
|
|
90
|
+
// degraded rendering rather than a lost post.
|
|
91
|
+
const CONTENT_TYPES = new Set(['Note', 'Article', 'Question', 'Page', 'Video', 'Audio', 'Image', 'Event']);
|
|
92
|
+
|
|
93
|
+
// A Question is a poll: its options live in oneOf (pick one) or anyOf (pick
|
|
94
|
+
// several), each carrying the tally its author's server maintains.
|
|
95
|
+
// Custom emojis ride the tag list; the images live at the author's server and
|
|
96
|
+
// the client fetches them from there.
|
|
97
|
+
function emojisOf(note) {
|
|
98
|
+
return [].concat(note?.tag || [])
|
|
99
|
+
.filter(t => t?.type === 'Emoji' && t.icon?.url && t.name)
|
|
100
|
+
.map(t => ({ shortcode: String(t.name).replace(/^:|:$/g, ''), url: String(t.icon.url) }));
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function pollOf(note) {
|
|
104
|
+
const opts = note?.oneOf || note?.anyOf;
|
|
105
|
+
if (!Array.isArray(opts) || !opts.length) return null;
|
|
106
|
+
return {
|
|
107
|
+
multiple: !!note.anyOf,
|
|
108
|
+
expiresAt: note.endTime || null,
|
|
109
|
+
closed: !!note.closed,
|
|
110
|
+
options: opts.map(o => ({
|
|
111
|
+
title: String(o?.name ?? ''),
|
|
112
|
+
votes: Number(o?.replies?.totalItems) || 0,
|
|
113
|
+
})),
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
export const isContentType = (t) => CONTENT_TYPES.has(t);
|
|
117
|
+
const ACCEPT_AP = 'application/activity+json, application/ld+json; profile="https://www.w3.org/ns/activitystreams"';
|
|
118
|
+
|
|
119
|
+
export function sameOrigin(a, b) {
|
|
120
|
+
try { return new URL(a).origin === new URL(b).origin; } catch { return false; }
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Is this socket URL the pod's own? The scheme has to be the socket form of the
|
|
124
|
+
// pod's — wss for https, ws for http — so a downgrade to plaintext from an https
|
|
125
|
+
// pod is somewhere else, not the same place unencrypted.
|
|
126
|
+
//
|
|
127
|
+
// The host may be the pod's, or a PARENT of it. Not a loosening for
|
|
128
|
+
// convenience: a CSS server that gives every pod a subdomain answers
|
|
129
|
+
// notifications from the server root, so jeff-zucker.teamid.live is served by
|
|
130
|
+
// wss://teamid.live/.notifications/… — which is the deployment this project
|
|
131
|
+
// actually runs on. Requiring an exact match dropped it to polling, and the
|
|
132
|
+
// live agents are how that was found rather than the suite.
|
|
133
|
+
//
|
|
134
|
+
// A sibling subdomain is still refused: only a suffix of our own host passes,
|
|
135
|
+
// and two labels minimum so `.live` cannot pose as everyone's parent. Not a
|
|
136
|
+
// public-suffix list — that is a dependency and a data file to keep current,
|
|
137
|
+
// and the party this guards against is the pod you already chose to trust.
|
|
138
|
+
export function sameSocketOrigin(socketUrl, podBase) {
|
|
139
|
+
let s, p;
|
|
140
|
+
try { s = new URL(socketUrl); p = new URL(podBase); } catch { return false; }
|
|
141
|
+
if (s.protocol !== (p.protocol === 'https:' ? 'wss:' : 'ws:')) return false;
|
|
142
|
+
if (s.host === p.host) return true;
|
|
143
|
+
const parent = s.hostname.toLowerCase();
|
|
144
|
+
return parent.split('.').length >= 2
|
|
145
|
+
&& s.port === p.port
|
|
146
|
+
&& p.hostname.toLowerCase().endsWith('.' + parent);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function httpUrl(u) {
|
|
150
|
+
try {
|
|
151
|
+
const p = new URL(String(u)).protocol;
|
|
152
|
+
return p === 'https:' || p === 'http:';
|
|
153
|
+
} catch { return false; }
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Who a note is BY. A document may only speak for an actor at its own origin.
|
|
157
|
+
// `attributedTo` used to be taken at face value, so a note served anywhere
|
|
158
|
+
// could name anyone: one at a host the attacker controls, claiming to be by
|
|
159
|
+
// someone the owner follows, passed every check we had — the envelope's
|
|
160
|
+
// sameOrigin compares the ACTIVITY to its object, never the object to its
|
|
161
|
+
// author — and landed in the home timeline and in the pod as them. For a group
|
|
162
|
+
// it went further still, because amplify() gates on the author's membership,
|
|
163
|
+
// so the group signed an Announce of it and delivered it to every member.
|
|
164
|
+
//
|
|
165
|
+
// `delivered` is the actor that brought it, used only when the note names no
|
|
166
|
+
// author of its own; it has to clear the same test, which is why a boost of an
|
|
167
|
+
// unattributed note is refused rather than credited to the booster.
|
|
168
|
+
//
|
|
169
|
+
// Returns the author, or null when nothing at the note's origin vouches for one.
|
|
170
|
+
export function authorOf(note, delivered = null) {
|
|
171
|
+
const claimed = [].concat(note?.attributedTo || [])
|
|
172
|
+
.map(a => (typeof a === 'string' ? a : a?.id)).find(Boolean) || null;
|
|
173
|
+
const author = claimed || delivered;
|
|
174
|
+
if (!author) return null;
|
|
175
|
+
return sameOrigin(note?.id, author) ? author : null;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export class Intake {
|
|
179
|
+
constructor({ config, urls, remote, local, store, deliverer, publisher, log = console.log, lease = null, archive = null, push = true, pollSeconds = null }) {
|
|
180
|
+
Object.assign(this, { config, urls, remote, local, store, deliverer, publisher, log, lease, archive, push, pollSeconds });
|
|
181
|
+
this.serial = Date.now();
|
|
182
|
+
this.stopped = false;
|
|
183
|
+
// (attempt counts are kept in pod state — see _bumpAttempt)
|
|
184
|
+
this.lastDrain = null;
|
|
185
|
+
this.lastDrainAtMs = 0;
|
|
186
|
+
this.reconnectTries = 0;
|
|
187
|
+
this.drainCooldownUntil = 0;
|
|
188
|
+
this.drainFailures = 0;
|
|
189
|
+
this.wsState = 'never-connected';
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Draining is a destructive read — an item is gone from the pod once we
|
|
193
|
+
// DELETE it — so the result of handling it must be on disk first.
|
|
194
|
+
//
|
|
195
|
+
// This used to be skipped whenever the state and the inbox shared an origin,
|
|
196
|
+
// on the reasoning that a pod we cannot write to is a pod we cannot list
|
|
197
|
+
// either, so the drain never starts. That covers the pod being unreachable
|
|
198
|
+
// and nothing else: it does not cover a crash inside the 300ms debounce
|
|
199
|
+
// window, and it does not cover a pod that refuses a write while still
|
|
200
|
+
// serving reads and deletes — a quota, a 507, a 403 on one document. In
|
|
201
|
+
// either case every item drained since the last successful write is gone,
|
|
202
|
+
// and what goes is the mentions, replies, join requests and dead-letter
|
|
203
|
+
// records that nothing else can rebuild.
|
|
204
|
+
async _persisted() {
|
|
205
|
+
return this.store.commit();
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
_backOff(why) {
|
|
209
|
+
this.drainFailures++;
|
|
210
|
+
const capped = Math.min(DRAIN_COOLDOWN_MIN_MS * 2 ** (this.drainFailures - 1), DRAIN_COOLDOWN_MAX_MS);
|
|
211
|
+
this.drainCooldownUntil = Date.now() + Math.round(capped * (0.85 + Math.random() * 0.3));
|
|
212
|
+
this.log(`${why} — next sweep in ${Math.round(capped / 1000)}s`);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Sweep cadence: a configured interval wins, otherwise a live push channel —
|
|
216
|
+
// a notification socket, or the store's own change events in-process — is
|
|
217
|
+
// what makes the fallback poll a slow one.
|
|
218
|
+
_pollMs() {
|
|
219
|
+
if (this.pollSeconds) return this.pollSeconds * 1000;
|
|
220
|
+
return this.wsState === 'open' || this.wsState === 'in-process' ? POLL_PUSH_OK_MS : POLL_MS;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
async start() {
|
|
224
|
+
this.stopped = false; // restartable across demote/takeover cycles
|
|
225
|
+
await this.drain().catch(e => this.log(`drain: ${e.message}`));
|
|
226
|
+
const tick = () => {
|
|
227
|
+
this.pollTimer = setTimeout(() => {
|
|
228
|
+
this.drain().catch(e => this.log(`drain: ${e.message}`)).finally(() => { if (!this.stopped) tick(); });
|
|
229
|
+
}, Math.round(this._pollMs() * (0.85 + Math.random() * 0.3)));
|
|
230
|
+
this.pollTimer.unref?.();
|
|
231
|
+
};
|
|
232
|
+
tick();
|
|
233
|
+
// Embedded in the pod server, a notification socket back to that same
|
|
234
|
+
// server buys nothing — the store's own change events wake the drain.
|
|
235
|
+
if (this.push) this.subscribe().catch(e => this.log(`subscribe: ${e.message}`));
|
|
236
|
+
else this.wsState = 'in-process';
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
stop() { this.stopped = true; clearTimeout(this.pollTimer); clearTimeout(this.resubTimer); this.ws?.close(); }
|
|
240
|
+
|
|
241
|
+
// Attempt bookkeeping, persisted. Written only when an item fails, so a
|
|
242
|
+
// healthy inbox never touches this document.
|
|
243
|
+
_bumpAttempt(url, message) {
|
|
244
|
+
const all = this.store.read(ATTEMPTS_DOC, {});
|
|
245
|
+
const rec = all[url] || { n: 0 };
|
|
246
|
+
rec.n += 1;
|
|
247
|
+
rec.at = new Date().toISOString();
|
|
248
|
+
rec.last = String(message || '').slice(0, 200);
|
|
249
|
+
all[url] = rec;
|
|
250
|
+
this.store.write(ATTEMPTS_DOC, all);
|
|
251
|
+
return rec.n;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
_clearAttempt(url) {
|
|
255
|
+
const all = this.store.read(ATTEMPTS_DOC, {});
|
|
256
|
+
if (!all[url]) return;
|
|
257
|
+
delete all[url];
|
|
258
|
+
this.store.write(ATTEMPTS_DOC, all);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// Items deleted long ago would otherwise accumulate here forever.
|
|
262
|
+
_pruneAttempts() {
|
|
263
|
+
const all = this.store.read(ATTEMPTS_DOC, {});
|
|
264
|
+
const cutoff = Date.now() - ATTEMPTS_TTL_MS;
|
|
265
|
+
let dropped = 0;
|
|
266
|
+
for (const [url, rec] of Object.entries(all)) {
|
|
267
|
+
if (!rec?.at || Date.parse(rec.at) < cutoff) { delete all[url]; dropped++; }
|
|
268
|
+
}
|
|
269
|
+
if (dropped) { this.store.write(ATTEMPTS_DOC, all); this.log(`pruned ${dropped} stale inbox attempt record(s)`); }
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// Jittered exponential, floor to ceiling, reset by a successful open.
|
|
273
|
+
_reconnectDelay() {
|
|
274
|
+
const capped = Math.min(RECONNECT_MIN_MS * 2 ** this.reconnectTries, RECONNECT_MAX_MS);
|
|
275
|
+
this.reconnectTries++;
|
|
276
|
+
return Math.round(capped * (0.8 + Math.random() * 0.4));
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// --- push ---
|
|
280
|
+
// Any failure in here used to end push for the life of the process: the
|
|
281
|
+
// retry lived only in the "server refused the subscription" branch, so a
|
|
282
|
+
// network blip left wsState at never-connected and the agent silently on
|
|
283
|
+
// polling. Every path now schedules a retry on the same backoff.
|
|
284
|
+
async subscribe() {
|
|
285
|
+
try {
|
|
286
|
+
await this._subscribeOnce();
|
|
287
|
+
} catch (e) {
|
|
288
|
+
this.wsState = 'subscribe-error';
|
|
289
|
+
const wait = this._reconnectDelay();
|
|
290
|
+
this.log(`subscribe failed (${e.message}) — retrying in ${Math.round(wait / 1000)}s (polling meanwhile)`);
|
|
291
|
+
if (!this.stopped) {
|
|
292
|
+
this.resubTimer = setTimeout(() => this.subscribe().catch(() => {}), wait);
|
|
293
|
+
this.resubTimer.unref?.();
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
async _subscribeOnce() {
|
|
299
|
+
// Reuse a channel we already have rather than asking for another one.
|
|
300
|
+
const saved = this.store.read(CHANNEL_DOC, null);
|
|
301
|
+
if (saved?.receiveFrom && (!saved.endAt || Date.parse(saved.endAt) - Date.now() > 60_000)) {
|
|
302
|
+
this._openSocket(saved.receiveFrom, true);
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
const descRes = await fetch(this.urls.base + '.well-known/solid', {
|
|
306
|
+
headers: { accept: 'text/turtle', 'user-agent': USER_AGENT },
|
|
307
|
+
signal: AbortSignal.timeout(HTTP_TIMEOUT_MS),
|
|
308
|
+
});
|
|
309
|
+
// The service description is RDF; ask rdflib which subject is the
|
|
310
|
+
// WebSocketChannel2023 service rather than pattern-matching the document.
|
|
311
|
+
const descUrl = this.urls.base + '.well-known/solid';
|
|
312
|
+
const g = $rdf.graph();
|
|
313
|
+
try { $rdf.parse(await readCapped(descRes), g, descUrl, 'text/turtle'); }
|
|
314
|
+
catch (e) { this.wsState = 'unavailable'; this.log(`service description unparsable (${e.message}) — polling only`); return; }
|
|
315
|
+
const channel = g.each(null, RDF('type'), NOTIFY('WebSocketChannel2023'), null)
|
|
316
|
+
.map(n => n.value)
|
|
317
|
+
.find(Boolean)
|
|
318
|
+
|| g.each(null, NOTIFY('channelType'), NOTIFY('WebSocketChannel2023'), null)
|
|
319
|
+
.map(n => n.value).find(Boolean);
|
|
320
|
+
if (!channel) { this.wsState = 'unavailable'; this.log('no WebSocketChannel2023 service — polling only'); return; }
|
|
321
|
+
const sub = await this.remote.fetch(channel, {
|
|
322
|
+
method: 'POST',
|
|
323
|
+
headers: { 'content-type': 'application/ld+json' },
|
|
324
|
+
body: JSON.stringify({
|
|
325
|
+
'@context': ['https://www.w3.org/ns/solid/notification/v1'],
|
|
326
|
+
type: 'http://www.w3.org/ns/solid/notifications#WebSocketChannel2023',
|
|
327
|
+
topic: this.urls.inbox,
|
|
328
|
+
}),
|
|
329
|
+
});
|
|
330
|
+
const body = await readCapped(sub).then(JSON.parse).catch(() => null);
|
|
331
|
+
if (!body?.receiveFrom) {
|
|
332
|
+
this.wsState = `subscribe-failed-${sub.status}`;
|
|
333
|
+
const wait = this._reconnectDelay();
|
|
334
|
+
this.log(`subscription failed (${sub.status}) — retrying in ${Math.round(wait / 1000)}s (polling meanwhile)`);
|
|
335
|
+
if (!this.stopped) {
|
|
336
|
+
this.resubTimer = setTimeout(() => this.subscribe().catch(e => this.log(`resubscribe: ${e.message}`)), wait);
|
|
337
|
+
this.resubTimer.unref?.();
|
|
338
|
+
}
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
this.store.write(CHANNEL_DOC, { receiveFrom: body.receiveFrom, endAt: body.endAt || null });
|
|
342
|
+
this._openSocket(body.receiveFrom, false);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// The socket URL arrives in the pod's own subscription response, and it was
|
|
346
|
+
// the one outbound address in the project that reached the network without
|
|
347
|
+
// passing anything — safefetch guards every fetch, and `new WebSocket()` is
|
|
348
|
+
// not a fetch. A pod that answered with somebody else's address had us open a
|
|
349
|
+
// long-lived connection there and treat what came back as our inbox waking up.
|
|
350
|
+
//
|
|
351
|
+
// Same origin as the pod, not assertPublicUrl: a pod on this machine is a
|
|
352
|
+
// documented setup and its socket is legitimately ws://localhost:3000, which
|
|
353
|
+
// a public-address check would refuse.
|
|
354
|
+
_openSocket(receiveFrom, reused) {
|
|
355
|
+
if (!sameSocketOrigin(receiveFrom, this.urls.base)) {
|
|
356
|
+
this.wsState = 'refused';
|
|
357
|
+
this.log(`subscription named ${receiveFrom}, which is not this pod — polling only`);
|
|
358
|
+
if (reused) this.store.write(CHANNEL_DOC, null);
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
361
|
+
this.ws = new WebSocket(receiveFrom);
|
|
362
|
+
this.ws.onopen = () => {
|
|
363
|
+
this.wsState = 'open';
|
|
364
|
+
if (!this._announcedPush) { this.log('inbox push subscription active'); this._announcedPush = true; }
|
|
365
|
+
this._openedAt = Date.now();
|
|
366
|
+
// Anything that arrived while the socket was down is waiting — sweep it,
|
|
367
|
+
// unless a sweep just ran: a flapping socket must not re-list the inbox
|
|
368
|
+
// on every open.
|
|
369
|
+
if (Date.now() - this.lastDrainAtMs > OPEN_DRAIN_MIN_GAP_MS) {
|
|
370
|
+
this.drain().catch(e => this.log(`drain: ${e.message}`));
|
|
371
|
+
}
|
|
372
|
+
};
|
|
373
|
+
this.ws.onmessage = () => this.drain().catch(e => this.log(`drain: ${e.message}`));
|
|
374
|
+
this.ws.onclose = () => {
|
|
375
|
+
this.wsState = 'closed';
|
|
376
|
+
// Only a connection that STAYED up counts as a success. Resetting on open
|
|
377
|
+
// alone meant the 2026-07-29 failure — a server that accepts the upgrade
|
|
378
|
+
// and then drops the socket on a crash cycle — reconnected at the 2s
|
|
379
|
+
// floor indefinitely: every cycle "succeeded", so the exponential cap was
|
|
380
|
+
// never reached, and each open also drained the inbox.
|
|
381
|
+
if (this._openedAt && Date.now() - this._openedAt >= RECONNECT_STABLE_MS) this.reconnectTries = 0;
|
|
382
|
+
this._openedAt = 0;
|
|
383
|
+
if (!this.stopped) {
|
|
384
|
+
this.resubTimer = setTimeout(() => this.subscribe().catch(e => this.log(`resubscribe: ${e.message}`)), this._reconnectDelay());
|
|
385
|
+
this.resubTimer.unref?.();
|
|
386
|
+
}
|
|
387
|
+
};
|
|
388
|
+
this.ws.onerror = () => {
|
|
389
|
+
this.wsState = 'error';
|
|
390
|
+
// A channel we reused may simply be gone: forget it so the next attempt
|
|
391
|
+
// asks for a fresh one instead of retrying a dead URL forever.
|
|
392
|
+
if (reused) this.store.write(CHANNEL_DOC, null);
|
|
393
|
+
};
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// --- drain + dispatch ---
|
|
397
|
+
// Serialized: push events, polls, and manual /drain calls can fire
|
|
398
|
+
// concurrently, and overlapping sweeps double-process items (observed as
|
|
399
|
+
// duplicate Accepts/timeline writes). One sweep at a time; callers that
|
|
400
|
+
// arrive mid-sweep get one follow-up sweep.
|
|
401
|
+
async drain() {
|
|
402
|
+
if (this._draining) { this._drainAgain = true; return this._draining; }
|
|
403
|
+
// Held for the whole sweep, released however it ends. The debounce cannot
|
|
404
|
+
// coalesce a drain — every handler awaits somebody else's server first — so
|
|
405
|
+
// the writes are left to the commit boundaries the drain already has. See
|
|
406
|
+
// PodStore.hold.
|
|
407
|
+
// Optional: this is a throughput hint, not part of the commit-before-delete
|
|
408
|
+
// invariant — commit() flushes whatever is pending either way — so a store
|
|
409
|
+
// that does not implement it behaves exactly as before.
|
|
410
|
+
this._inSweep = true;
|
|
411
|
+
this.store.hold?.();
|
|
412
|
+
this._draining = this._drainOnce().finally(async () => {
|
|
413
|
+
this._inSweep = false;
|
|
414
|
+
await this._publishPending();
|
|
415
|
+
this.store.release?.();
|
|
416
|
+
this._draining = null;
|
|
417
|
+
if (this._drainAgain) {
|
|
418
|
+
this._drainAgain = false;
|
|
419
|
+
// Paced, not immediate: chained sweeps put a ceiling on work per unit
|
|
420
|
+
// time, so a delivery flood cannot run the drain back-to-back.
|
|
421
|
+
const t = setTimeout(() => this.drain().catch(e => this.log(`drain: ${e.message}`)), CHAIN_GAP_MS);
|
|
422
|
+
t.unref?.();
|
|
423
|
+
}
|
|
424
|
+
});
|
|
425
|
+
return this._draining;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// Discard the content waiting in the inbox from before `before`, on the
|
|
429
|
+
// owner's say-so — the admin page asks, this does it. NOT a blind sweep:
|
|
430
|
+
// every item is read, because the type is the only thing that decides its
|
|
431
|
+
// fate and size does not predict it. A Follow, Undo, Accept or Delete is
|
|
432
|
+
// APPLIED whatever it weighs, so the follow graph stays correct and a post
|
|
433
|
+
// its author retracted still goes; only a Create is dropped. Judging by size
|
|
434
|
+
// instead saved one request per item and lost any control activity that
|
|
435
|
+
// happened to be large, silently and permanently.
|
|
436
|
+
//
|
|
437
|
+
// An item past the drain's own byte cap is deleted unread: it could not be
|
|
438
|
+
// handled if it were read, so there is nothing to lose by not reading it.
|
|
439
|
+
//
|
|
440
|
+
// `keepConcerning` narrows the discard to noise: every item is passed to
|
|
441
|
+
// handle(), which ingests a Create only when concernsUs passes — addressed to
|
|
442
|
+
// us, a mention, a reply to ours, or from someone we follow — and drops the
|
|
443
|
+
// rest.
|
|
444
|
+
async prune({ before, keepConcerning = false } = {}) {
|
|
445
|
+
const cutoff = Date.parse(before);
|
|
446
|
+
if (!Number.isFinite(cutoff)) throw new Error(`"${before}" is not a date`);
|
|
447
|
+
const all = await this.remote.listContainer(this.urls.inbox);
|
|
448
|
+
const older = all.filter(e => !e.url.endsWith('.keep')
|
|
449
|
+
&& e.modified && Date.parse(e.modified) < cutoff);
|
|
450
|
+
const out = { considered: older.length, applied: 0, dropped: 0, discarded: 0, failed: 0 };
|
|
451
|
+
|
|
452
|
+
for (const item of older) {
|
|
453
|
+
try {
|
|
454
|
+
if (item.size > MAX_ITEM_BYTES) {
|
|
455
|
+
await this.remote.delete(item.url); // unreadable by the drain either way
|
|
456
|
+
out.discarded++;
|
|
457
|
+
} else {
|
|
458
|
+
const res = await this.remote.fetch(item.url, { headers: { accept: '*/*' } });
|
|
459
|
+
// Same rule as the drain: a read we could not make is not a Create to
|
|
460
|
+
// be dropped. Let it count as failed and stay for the next pass.
|
|
461
|
+
if (res.status >= 400 && res.status !== 404) throw new Error(`inbox item GET → ${res.status}`);
|
|
462
|
+
const activity = res.status < 400 ? await res.json().catch(() => null) : null;
|
|
463
|
+
// A Create is the content the owner just asked to be rid of. Anything
|
|
464
|
+
// else changes state and is applied exactly as a drain would.
|
|
465
|
+
if (keepConcerning) {
|
|
466
|
+
const rejection = activity ? await this.handle(activity) : 'unparsable JSON';
|
|
467
|
+
if (rejection) out.dropped++; else out.applied++;
|
|
468
|
+
} else if (activity && activity.type !== 'Create') {
|
|
469
|
+
await this.handle(activity);
|
|
470
|
+
out.applied++;
|
|
471
|
+
} else {
|
|
472
|
+
out.dropped++;
|
|
473
|
+
}
|
|
474
|
+
if (!await this._persisted()) {
|
|
475
|
+
this.log(`state not written — stopping the prune with ${older.length - out.applied - out.dropped - out.discarded} left`);
|
|
476
|
+
break;
|
|
477
|
+
}
|
|
478
|
+
await this.remote.delete(item.url);
|
|
479
|
+
}
|
|
480
|
+
this._clearAttempt(item.url);
|
|
481
|
+
await new Promise(r => setTimeout(r, DELETE_GAP_MS));
|
|
482
|
+
} catch (e) {
|
|
483
|
+
out.failed++;
|
|
484
|
+
this.log(`prune ${item.url}: ${e.message}`);
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
this.log(`pruned before ${before}: applied ${out.applied}, dropped ${out.dropped} `
|
|
488
|
+
+ `small Create(s), discarded ${out.discarded} unread${out.failed ? `, ${out.failed} failed` : ''}`);
|
|
489
|
+
await this.store.flush();
|
|
490
|
+
// Adjust the measurement in place rather than kicking a drain to re-take
|
|
491
|
+
// it: an un-awaited drain would still be running when this returns, which
|
|
492
|
+
// races whoever called us. The poll picks the rest up soon enough.
|
|
493
|
+
const removed = out.applied + out.dropped + out.discarded;
|
|
494
|
+
if (this.inboxStats && removed) {
|
|
495
|
+
this.inboxStats = { ...this.inboxStats, count: Math.max(0, this.inboxStats.count - removed) };
|
|
496
|
+
}
|
|
497
|
+
return out;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
// The sender's original bytes, kept after the activity was verified and
|
|
501
|
+
// applied and before the pod DELETE erases the only other copy. The filename
|
|
502
|
+
// is the content's own hash, so a re-delivered activity lands on the same
|
|
503
|
+
// file instead of duplicating. Best-effort history: a failed write logs and
|
|
504
|
+
// the drain goes on — mail must never stall on its own receipt. This is the
|
|
505
|
+
// one category of account data that lives only in the private half; the pod
|
|
506
|
+
// cannot rebuild it because the pod never kept it.
|
|
507
|
+
//
|
|
508
|
+
// The record is JSON-LD: a cnt:ContentAsText whose cnt:chars are the raw
|
|
509
|
+
// bytes, stamped prov:generatedAtTime / prov:wasDerivedFrom / as:actor —
|
|
510
|
+
// plain JSON to everything here, a graph to any RDF reader.
|
|
511
|
+
async _archive(sourceUrl, raw, activity) {
|
|
512
|
+
try {
|
|
513
|
+
if (!this.archive || this.store.getConfig()?.archiveInbox === false) return;
|
|
514
|
+
const { createHash } = await import('node:crypto');
|
|
515
|
+
const hash = createHash('sha256').update(raw).digest('hex').slice(0, 16);
|
|
516
|
+
const receivedAt = new Date().toISOString();
|
|
517
|
+
const rec = {
|
|
518
|
+
'@context': {
|
|
519
|
+
prov: 'http://www.w3.org/ns/prov#',
|
|
520
|
+
cnt: 'http://www.w3.org/2011/content#',
|
|
521
|
+
as: 'https://www.w3.org/ns/activitystreams#',
|
|
522
|
+
xsd: 'http://www.w3.org/2001/XMLSchema#',
|
|
523
|
+
receivedAt: { '@id': 'prov:generatedAtTime', '@type': 'xsd:dateTime' },
|
|
524
|
+
source: { '@id': 'prov:wasDerivedFrom', '@type': '@id' },
|
|
525
|
+
actor: { '@id': 'as:actor', '@type': '@id' },
|
|
526
|
+
raw: 'cnt:chars',
|
|
527
|
+
},
|
|
528
|
+
'@id': '',
|
|
529
|
+
'@type': 'cnt:ContentAsText',
|
|
530
|
+
receivedAt,
|
|
531
|
+
actor: typeof activity?.actor === 'string' ? activity.actor : activity?.actor?.id || null,
|
|
532
|
+
source: sourceUrl,
|
|
533
|
+
raw,
|
|
534
|
+
};
|
|
535
|
+
const w = await this.archive.write(`${receivedAt.slice(0, 7)}/${hash}.json`,
|
|
536
|
+
JSON.stringify(rec, null, 2), 'application/ld+json');
|
|
537
|
+
if (!w.ok) this.log(`inbox archive: ${w.why || 'write failed'}`);
|
|
538
|
+
} catch (e) {
|
|
539
|
+
this.log(`inbox archive: ${e.message}`);
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
async _drainOnce() {
|
|
544
|
+
const cooling = this.drainCooldownUntil - Date.now();
|
|
545
|
+
if (cooling > 0) {
|
|
546
|
+
this.log(`inbox sweep skipped — backing off for another ${Math.ceil(cooling / 1000)}s`);
|
|
547
|
+
return;
|
|
548
|
+
}
|
|
549
|
+
// Draining DELETES from the pod, so it must not run on a lease that has
|
|
550
|
+
// quietly expired. renewOnce notices at its own cadence — up to ~117s — and
|
|
551
|
+
// after the TTL another agent is entitled to start draining the same inbox.
|
|
552
|
+
if (this.lease && !this.lease.stillHeld()) {
|
|
553
|
+
this.log('lease is no longer held — not draining');
|
|
554
|
+
return;
|
|
555
|
+
}
|
|
556
|
+
this.lastDrain = new Date().toISOString();
|
|
557
|
+
this.lastDrainAtMs = Date.now();
|
|
558
|
+
this._pruneAttempts();
|
|
559
|
+
let all;
|
|
560
|
+
try {
|
|
561
|
+
all = await this.remote.listContainer(this.urls.inbox);
|
|
562
|
+
this.drainFailures = 0;
|
|
563
|
+
} catch (e) {
|
|
564
|
+
this._backOff(`inbox unreadable (${e.message})`);
|
|
565
|
+
return;
|
|
566
|
+
}
|
|
567
|
+
// What is waiting, measured from the listing we already fetched: no extra
|
|
568
|
+
// request, and it is what /status reports and what the admin page prompts
|
|
569
|
+
// on. The listing arrives oldest-first (lib/remote.mjs).
|
|
570
|
+
const real = all.filter(e => !e.url.endsWith('.keep'));
|
|
571
|
+
this.inboxStats = {
|
|
572
|
+
count: real.length,
|
|
573
|
+
bytes: real.reduce((n, e) => n + e.size, 0),
|
|
574
|
+
oldest: real[0]?.modified || null,
|
|
575
|
+
newest: real[real.length - 1]?.modified || null,
|
|
576
|
+
at: new Date().toISOString(),
|
|
577
|
+
};
|
|
578
|
+
// The inbox is public-Append: a flood must not turn one sweep into an
|
|
579
|
+
// unbounded run. But stopping there is why a backlog never cleared — 50
|
|
580
|
+
// items every two minutes does not converge on an agent that is only
|
|
581
|
+
// running while a laptop is open. So a sweep that made progress and left
|
|
582
|
+
// work behind goes straight round again.
|
|
583
|
+
const items = all.slice(0, MAX_ITEMS_PER_DRAIN);
|
|
584
|
+
if (all.length > items.length) this.log(`inbox has ${all.length} items — processing ${items.length} this sweep`);
|
|
585
|
+
let handled = 0;
|
|
586
|
+
// Deletes are batched behind ONE commit rather than a commit per item.
|
|
587
|
+
// Per-item, the 300ms debounce that coalesces a sweep's writes never gets
|
|
588
|
+
// to do its job: on a flood of fast rejections that is fifty writes of
|
|
589
|
+
// deadletter.json where one would do, and a flood is exactly when the pod
|
|
590
|
+
// should be asked for less rather than more.
|
|
591
|
+
const pending = [];
|
|
592
|
+
const flush = async () => {
|
|
593
|
+
if (!pending.length) return true;
|
|
594
|
+
// Written down before any of them leaves the mailbox. A failure here
|
|
595
|
+
// leaves them where they are: the next sweep sees them again, and a
|
|
596
|
+
// re-delivered activity is handled idempotently.
|
|
597
|
+
if (!await this._persisted()) {
|
|
598
|
+
this._backOff(`state not written — ${pending.length} item(s) left in the inbox`);
|
|
599
|
+
pending.length = 0;
|
|
600
|
+
return false;
|
|
601
|
+
}
|
|
602
|
+
for (const url of pending.splice(0)) {
|
|
603
|
+
if (!await this.remote.delete(url)) {
|
|
604
|
+
// Still in the mailbox. Handling is idempotent so seeing it again is
|
|
605
|
+
// harmless, but counting it would clear the attempt record and report
|
|
606
|
+
// progress that did not happen.
|
|
607
|
+
this.log(`inbox item ${url} was handled but NOT removed — it will be seen again`);
|
|
608
|
+
continue;
|
|
609
|
+
}
|
|
610
|
+
this._clearAttempt(url);
|
|
611
|
+
handled++;
|
|
612
|
+
await new Promise(r => setTimeout(r, DELETE_GAP_MS));
|
|
613
|
+
}
|
|
614
|
+
return true;
|
|
615
|
+
};
|
|
616
|
+
|
|
617
|
+
for (const { url, size } of items) {
|
|
618
|
+
if (url.endsWith('.keep')) continue;
|
|
619
|
+
// The listing already carries every child's size, so this costs nothing
|
|
620
|
+
// to ask. An activity is a few kB; anything of this order is not one, and
|
|
621
|
+
// reading it with an unbounded res.text() buffers whatever a stranger
|
|
622
|
+
// chose to Append into memory.
|
|
623
|
+
// A cheap pre-filter only: listContainer coerces a missing posix:size to
|
|
624
|
+
// 0, so a pod that does not publish sizes would wave everything through.
|
|
625
|
+
// The real bound is readCapped on the body below.
|
|
626
|
+
if (size > MAX_ITEM_BYTES) {
|
|
627
|
+
this.store.addDeadLetter({ inboxUrl: url, reason: `oversized (${size} bytes)`, activity: null });
|
|
628
|
+
pending.push(url);
|
|
629
|
+
continue;
|
|
630
|
+
}
|
|
631
|
+
let activity = null;
|
|
632
|
+
try {
|
|
633
|
+
const res = await this.remote.fetch(url, { headers: { accept: '*/*' } });
|
|
634
|
+
// A pod that would not GIVE us the item has told us nothing about it.
|
|
635
|
+
// Reading a 500 as an empty body made it "unparsable JSON", which is a
|
|
636
|
+
// REJECTION: dead-lettered with both `activity` and `raw` null, and then
|
|
637
|
+
// DELETEd — a delivery destroyed by a transient fault, with no record of
|
|
638
|
+
// what it was. Throwing puts it on the retry path the header promises.
|
|
639
|
+
// 404 is the exception: the item is already gone, so deleting is right.
|
|
640
|
+
if (res.status >= 400 && res.status !== 404) throw new Error(`inbox item GET → ${res.status}`);
|
|
641
|
+
// Capped rather than res.text(): the listing's size is advisory, and the
|
|
642
|
+
// inbox is public-Append, so the body is whatever a stranger chose.
|
|
643
|
+
const raw = res.status < 400 ? await readCapped(res, MAX_ITEM_BYTES) : null;
|
|
644
|
+
try { activity = raw ? JSON.parse(raw) : null; } catch { /* kept raw for the dead letter */ }
|
|
645
|
+
// A gateway that verified this delivery left a receipt beside it. Read
|
|
646
|
+
// it only when a gateway is configured (no config → no fetch, so an
|
|
647
|
+
// install with no gateway pays nothing); a missing or HMAC-invalid
|
|
648
|
+
// receipt reads as null, which is exactly today's unverified behavior.
|
|
649
|
+
const receipt = activity ? await this._readReceipt(url) : null;
|
|
650
|
+
if (activity && this.gatewaySecret()) this._bumpGatewayStat(!!receipt?.verified);
|
|
651
|
+
const rejection = activity ? await this.handle(activity, receipt) : 'unparsable JSON';
|
|
652
|
+
if (!rejection && raw) await this._archive(url, raw, activity);
|
|
653
|
+
if (!rejection) await this._maybeForward(activity); // §7.1.2, only what we accepted
|
|
654
|
+
if (rejection) {
|
|
655
|
+
this.store.addDeadLetter({
|
|
656
|
+
inboxUrl: url, reason: rejection, activity,
|
|
657
|
+
...(activity ? {} : { raw: raw?.slice(0, 2000) ?? null }),
|
|
658
|
+
});
|
|
659
|
+
this.log(`rejected (${rejection}) — dead-lettered: ${url}`);
|
|
660
|
+
}
|
|
661
|
+
pending.push(url);
|
|
662
|
+
} catch (e) {
|
|
663
|
+
const n = this._bumpAttempt(url, e.message);
|
|
664
|
+
this.log(`inbox item ${url} attempt ${n}/${MAX_ITEM_ATTEMPTS}: ${e.message}`);
|
|
665
|
+
if (n >= MAX_ITEM_ATTEMPTS) {
|
|
666
|
+
this.store.addDeadLetter({ inboxUrl: url, reason: `failed ${n}x: ${e.message}`, activity });
|
|
667
|
+
// The dead letter IS the record of this item — deleting before it is
|
|
668
|
+
// written down would lose the only evidence it ever arrived, so it
|
|
669
|
+
// goes through the same commit-then-delete batch as everything else.
|
|
670
|
+
pending.push(url);
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
if (pending.length >= DELETE_BATCH && !await flush()) return;
|
|
674
|
+
}
|
|
675
|
+
if (!await this._finishSweep(flush)) return;
|
|
676
|
+
// Made progress and there is more waiting: go straight round rather than
|
|
677
|
+
// sleeping. Gated on progress so a sweep that achieved nothing — a
|
|
678
|
+
// cooldown, an unwritable store, poison at the head — cannot spin.
|
|
679
|
+
if (handled > 0 && all.length > items.length && !this.stopped) this._drainAgain = true;
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
// The end of a sweep: publish whatever the follow graph did ONCE, then flush.
|
|
683
|
+
//
|
|
684
|
+
// publishCollections used to run per handled item — every Follow, Undo,
|
|
685
|
+
// Accept, Reject, admit and eject — and each one is a full GET of the pod's
|
|
686
|
+
// followers collection plus a PUT of it. Fifty follows in a sweep were a
|
|
687
|
+
// hundred requests where two would do, and the answer they arrive at is the
|
|
688
|
+
// same either way, because it is built from contacts.json in memory.
|
|
689
|
+
async _finishSweep(flush) {
|
|
690
|
+
await this._publishPending();
|
|
691
|
+
return flush();
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
// Idempotent: it clears what it takes, so the drain's own exit path calling
|
|
695
|
+
// it again after a sweep that bailed early — an unwritable store, a delete
|
|
696
|
+
// that failed — is a no-op in the ordinary case and the difference between
|
|
697
|
+
// "published" and "waiting for a sweep that may never come" in the other.
|
|
698
|
+
async _publishPending() {
|
|
699
|
+
const want = this._republish;
|
|
700
|
+
this._republish = null;
|
|
701
|
+
if (!want) return;
|
|
702
|
+
try { await this.publisher.publishCollections(want); }
|
|
703
|
+
catch (e) { this.log(`publishing collections: ${e.message}`); }
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
// Ask for a collection to be republished at the end of this sweep. Outside a
|
|
707
|
+
// sweep there is no boundary to wait for, so it happens now.
|
|
708
|
+
async republish(which) {
|
|
709
|
+
if (!this._inSweep) return this.publisher.publishCollections(which);
|
|
710
|
+
this._republish = { ...(this._republish || {}), ...which };
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
async fetchAP(url) {
|
|
714
|
+
const res = await this.deliverer.signedFetch(url, { headers: { accept: ACCEPT_AP } });
|
|
715
|
+
if (res.status >= 400) return null;
|
|
716
|
+
// Remote servers are untrusted: read with a byte budget rather than
|
|
717
|
+
// letting res.json() buffer whatever they choose to send.
|
|
718
|
+
const { readCapped } = await import('./safefetch.mjs');
|
|
719
|
+
// Plenty of servers answer 200 text/html however politely we ask for AS2 —
|
|
720
|
+
// people reply to ordinary web pages, and their id is that page. Say so,
|
|
721
|
+
// rather than handing the HTML to JSON.parse and logging the parser's
|
|
722
|
+
// complaint about an unexpected `<`.
|
|
723
|
+
// Not logged: people reply to ordinary web pages, so the reply's object id
|
|
724
|
+
// is that page and this is the expected answer, not a fault. Only a server
|
|
725
|
+
// that CLAIMS to be sending JSON and then does not is worth a line.
|
|
726
|
+
const ct = (res.headers.get('content-type') || '').split(';')[0].trim().toLowerCase();
|
|
727
|
+
if (ct && !ct.endsWith('json')) return null;
|
|
728
|
+
let doc = null;
|
|
729
|
+
try { doc = JSON.parse(await readCapped(res)); }
|
|
730
|
+
catch (e) { this.log(`fetch ${url}: unreadable as JSON — ${e.message}`); return null; }
|
|
731
|
+
// Every actor type, not just Person. A Group was fetched, used and thrown
|
|
732
|
+
// away, so nothing knew its preferredUsername — and a client rendering it
|
|
733
|
+
// fell back to the last path segment of the actor URL, which is the literal
|
|
734
|
+
// word `actor`. That is where @actor@host came from.
|
|
735
|
+
// Under the id the document CLAIMS, but only when its own origin vouches
|
|
736
|
+
// for that id. A stranger's actor document naming someone else's id used to
|
|
737
|
+
// overwrite that actor's cached name, bio, avatar and Person/Group flag —
|
|
738
|
+
// one appended Follow was enough, and the id-mismatch checks in onFollow
|
|
739
|
+
// and ingestNote both run after this line and never undid it.
|
|
740
|
+
//
|
|
741
|
+
// Same origin rather than exact equality: signedFetch follows redirects
|
|
742
|
+
// without reporting where it landed, so a server that redirects its own
|
|
743
|
+
// canonical actor URL would otherwise stop being cached at all.
|
|
744
|
+
if (ACTOR_TYPES.has(doc?.type) && doc.id && sameOrigin(doc.id, url)) {
|
|
745
|
+
this.store.cacheActor(doc.id, doc);
|
|
746
|
+
}
|
|
747
|
+
return doc;
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
sameOrigin(a, b) { return sameOrigin(a, b); }
|
|
751
|
+
|
|
752
|
+
// Have we ever heard of this actor or object? Answered entirely from local
|
|
753
|
+
// state, so asking costs nothing. It is what stops a stranger's Delete or
|
|
754
|
+
// Update — of which Mastodon broadcasts a great many, and of which anyone at
|
|
755
|
+
// all can Append one — turning into a signed request to a host they chose.
|
|
756
|
+
known(id) {
|
|
757
|
+
const c = this.store.getContacts();
|
|
758
|
+
return c.followers.some(f => f.actor === id)
|
|
759
|
+
|| c.following.some(f => f.actor === id)
|
|
760
|
+
|| this.store.getStatuses().some(s => s.noteId === id || s.actor === id)
|
|
761
|
+
|| !!this.store.getActors()[id];
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
// Returns a rejection reason string, or undefined when handled.
|
|
765
|
+
// The gateway's shared HMAC secret, or null when no gateway is configured.
|
|
766
|
+
// Its absence is what makes the whole receipt path dormant by default.
|
|
767
|
+
gatewaySecret() {
|
|
768
|
+
return this.store.getConfig()?.gateway?.hmacSecret || null;
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
// Shadow-mode measurement: how much real traffic actually verified. The one
|
|
772
|
+
// number an operator needs before trusting receipts. Only touched while a
|
|
773
|
+
// gateway is configured, so it costs a non-gateway install nothing.
|
|
774
|
+
_bumpGatewayStat(verified) {
|
|
775
|
+
const s = this.store.read('gateway-stats.json', { verified: 0, unverified: 0 });
|
|
776
|
+
if (verified) s.verified++; else s.unverified++;
|
|
777
|
+
s.lastAt = new Date().toISOString();
|
|
778
|
+
this.store.write('gateway-stats.json', s);
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
// Read and authenticate the receipt a gateway wrote beside an inbox item.
|
|
782
|
+
// Returns the receipt object only when its HMAC verifies against our secret;
|
|
783
|
+
// null otherwise (no gateway, no receipt, a stranger's forged one, or a read
|
|
784
|
+
// failure) — and null means "unverified", the pre-gateway behavior.
|
|
785
|
+
async _readReceipt(itemUrl) {
|
|
786
|
+
const secret = this.gatewaySecret();
|
|
787
|
+
if (!secret) return null;
|
|
788
|
+
try {
|
|
789
|
+
const res = await this.remote.fetch(itemUrl + '.receipt.json', { headers: { accept: 'application/json' } });
|
|
790
|
+
if (res.status >= 400) return null;
|
|
791
|
+
const { readCapped } = await import('./safefetch.mjs');
|
|
792
|
+
const receipt = JSON.parse(await readCapped(res, 64 * 1024));
|
|
793
|
+
const { verifyReceipt } = await import('./httpsig.mjs');
|
|
794
|
+
return verifyReceipt(receipt, secret) ? receipt : null;
|
|
795
|
+
} catch { return null; }
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
async handle(activity, receipt = null) { // eslint-disable-line no-unused-vars
|
|
799
|
+
const actor = typeof activity.actor === 'string' ? activity.actor : activity.actor?.id;
|
|
800
|
+
if (!actor) return 'no actor';
|
|
801
|
+
// An actor is a URL that can be dereferenced. Most arms here go on to fetch
|
|
802
|
+
// it, and safefetch refuses a bad scheme there — but Like and Announce on
|
|
803
|
+
// one of our own notes record a notification without dereferencing
|
|
804
|
+
// anything, so `javascript:` and `data:` reached the client as an account
|
|
805
|
+
// url. The store already guards avatars this way (safeUrl); actors were
|
|
806
|
+
// simply never put through it.
|
|
807
|
+
if (!httpUrl(actor)) return `actor is not an http(s) URL (${actor})`;
|
|
808
|
+
if (this.store.isBlocked(actor)) return `blocked sender (${actor})`;
|
|
809
|
+
|
|
810
|
+
// FEP-1b12 moderation from a LISTED moderator. A delivery proves nothing
|
|
811
|
+
// about its sender, which is why these are QUEUED for the operator rather
|
|
812
|
+
// than run on arrival — the queue is where a claimed moderator's word
|
|
813
|
+
// waits for the one person who can vouch for it. Everything else about
|
|
814
|
+
// the activity falls through to the ordinary arms.
|
|
815
|
+
if (this.config.kind === 'group'
|
|
816
|
+
&& (this.config.moderators || []).includes(actor)
|
|
817
|
+
&& this.isModerationAsk(activity)) {
|
|
818
|
+
return this.queueModeration(activity, actor);
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
// A gateway receipt is trusted only in `trust` mode — in `shadow` it is
|
|
822
|
+
// measured but never acted on, which is what makes the shadow phase safe.
|
|
823
|
+
const trusted = !!receipt?.verified && this.store.getConfig()?.gateway?.mode === 'trust';
|
|
824
|
+
|
|
825
|
+
switch (activity.type) {
|
|
826
|
+
case 'Follow': return this.onFollow(activity, actor, { trusted });
|
|
827
|
+
case 'Undo': return this.onUndo(activity, actor, { trusted });
|
|
828
|
+
case 'Create': return this.onCreate(activity, actor);
|
|
829
|
+
case 'Accept': return this.onAccept(activity, actor);
|
|
830
|
+
case 'Like': case 'Announce': {
|
|
831
|
+
// FEP-1b12: a group Announces the member's whole Create, not the note.
|
|
832
|
+
// Without unwrapping we try to ingest a Create as if it were a Note and
|
|
833
|
+
// dead-letter every post a group ever carries — including our own.
|
|
834
|
+
const wrapped = activity.object;
|
|
835
|
+
// FEP-1b12: a group announces its moderation too. The one act a
|
|
836
|
+
// follower can honor without trusting anyone new is a Delete of a
|
|
837
|
+
// post that same group carried to us — the carrier unsaying its carry.
|
|
838
|
+
if (activity.type === 'Announce' && wrapped && typeof wrapped === 'object'
|
|
839
|
+
&& wrapped.type === 'Delete') {
|
|
840
|
+
return this.onAnnouncedDelete(actor, wrapped);
|
|
841
|
+
}
|
|
842
|
+
const inner = (wrapped && typeof wrapped === 'object'
|
|
843
|
+
&& (wrapped.type === 'Create' || wrapped.type === 'Update')) ? wrapped.object : wrapped;
|
|
844
|
+
const objectId = typeof inner === 'string' ? inner : inner?.id;
|
|
845
|
+
this.log(`${activity.type} from ${actor} on ${objectId}`);
|
|
846
|
+
if (objectId && objectId.startsWith(this.urls.notes)) {
|
|
847
|
+
// Nothing vouches for this actor: a Like carries no signature and,
|
|
848
|
+
// unlike a Create, has no object at the sender's origin to re-read.
|
|
849
|
+
// `known()` is answered from local state and costs nothing — a
|
|
850
|
+
// stranger's favourite is still recorded, it is just the first thing
|
|
851
|
+
// the cap evicts, so a flood cannot push out real history.
|
|
852
|
+
this.store.addNotification({
|
|
853
|
+
type: activity.type === 'Like' ? 'favourite' : 'reblog', actor, noteId: objectId,
|
|
854
|
+
...(this.known(actor) ? {} : { unverified: true }),
|
|
855
|
+
});
|
|
856
|
+
return;
|
|
857
|
+
}
|
|
858
|
+
if (activity.type === 'Announce') return this.onAnnounce(activity, actor, objectId);
|
|
859
|
+
return;
|
|
860
|
+
}
|
|
861
|
+
case 'Delete': return this.onDelete(activity, actor);
|
|
862
|
+
case 'Update': return this.onUpdate(activity, actor);
|
|
863
|
+
case 'Reject': return this.onReject(activity, actor);
|
|
864
|
+
case 'Move': return this.onMove(activity, actor);
|
|
865
|
+
case 'Add': case 'Remove': return this.onAddRemove(activity, actor);
|
|
866
|
+
default: this.log(`ignored ${activity.type} from ${actor}`);
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
// Which inbound activities count as a moderator's ask: a ban, an unban, a
|
|
871
|
+
// post removal, or a roster change naming OUR moderators collection. A
|
|
872
|
+
// moderator's ordinary traffic (their posts, likes, follows) is not
|
|
873
|
+
// moderation and takes the normal arms.
|
|
874
|
+
isModerationAsk(activity) {
|
|
875
|
+
if (activity.type === 'Block') return true;
|
|
876
|
+
if (activity.type === 'Undo') {
|
|
877
|
+
return typeof activity.object === 'object' && activity.object?.type === 'Block';
|
|
878
|
+
}
|
|
879
|
+
if (activity.type === 'Delete') {
|
|
880
|
+
const id = typeof activity.object === 'string' ? activity.object : activity.object?.id;
|
|
881
|
+
const s = id && this.store.getStatuses().find(x => x.noteId === id);
|
|
882
|
+
// Only a post the group holds and did not author — removing those is
|
|
883
|
+
// moderation; everything else is the author's own Delete.
|
|
884
|
+
return !!s && s.kind !== 'post';
|
|
885
|
+
}
|
|
886
|
+
if (activity.type === 'Add' || activity.type === 'Remove') {
|
|
887
|
+
const target = typeof activity.target === 'string' ? activity.target : activity.target?.id;
|
|
888
|
+
return target === this.urls.moderators;
|
|
889
|
+
}
|
|
890
|
+
return false;
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
// Held, not run: one entry per distinct ask, capped, waiting for the
|
|
894
|
+
// operator to apply or dismiss it (social.applyModeration).
|
|
895
|
+
queueModeration(activity, actor) {
|
|
896
|
+
const q = this.store.read('modqueue.json', []);
|
|
897
|
+
const objectId = typeof activity.object === 'string' ? activity.object : activity.object?.id;
|
|
898
|
+
const key = [activity.type, actor, objectId || JSON.stringify(activity.object || null)].join(' ');
|
|
899
|
+
if (q.some(e => e.key === key)) return;
|
|
900
|
+
q.unshift({
|
|
901
|
+
key, id: (this.serial++).toString(36) + '-' + q.length,
|
|
902
|
+
type: activity.type, moderator: actor, activity, at: new Date().toISOString(),
|
|
903
|
+
});
|
|
904
|
+
this.store.write('modqueue.json', q.slice(0, 200));
|
|
905
|
+
this.log(`moderation queued from ${actor}: ${activity.type} ${objectId || ''}`);
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
// §7.6 Add / §7.9 Remove. The side effect would be to add or remove the object
|
|
909
|
+
// to/from the collection named in `target` — but only a collection we own AND
|
|
910
|
+
// that the sender is authorised to modify. No remote is granted write to our
|
|
911
|
+
// collections (membership is Follow/Undo, pins are ours to set), so there is
|
|
912
|
+
// nothing an inbound Add or Remove may change here. It is a valid activity,
|
|
913
|
+
// not garbage: acknowledge it, make no change, and never dead-letter it.
|
|
914
|
+
onAddRemove(activity, actor) {
|
|
915
|
+
const target = typeof activity.target === 'string' ? activity.target : activity.target?.id;
|
|
916
|
+
const ours = target && [this.urls.followers, this.urls.following, this.urls.featured]
|
|
917
|
+
.filter(Boolean).includes(target);
|
|
918
|
+
this.log(ours
|
|
919
|
+
? `${activity.type} from ${actor} targets our ${target} — no remote may modify it; acknowledged`
|
|
920
|
+
: `${activity.type} from ${actor} targets ${target || 'no collection of ours'}; nothing here to change`);
|
|
921
|
+
return; // accepted, no side effect
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
async onFollow(activity, actor, { trusted = false } = {}) {
|
|
925
|
+
const doc = await this.fetchAP(actor); // origin must vouch for the actor
|
|
926
|
+
if (!doc) return `actor fetch failed (${actor})`;
|
|
927
|
+
if (doc.id !== actor) return `actor id mismatch (${actor} vs ${doc.id})`;
|
|
928
|
+
if (!doc.inbox) return `actor has no inbox (${actor})`;
|
|
929
|
+
const contacts = this.store.getContacts();
|
|
930
|
+
const existing = contacts.followers.find(f => f.actor === actor);
|
|
931
|
+
// NOTHING binds a delivered Follow to the actor it names. LDN bodies carry
|
|
932
|
+
// no signature, and unlike Create, Delete and Update there is no object at
|
|
933
|
+
// the origin to re-fetch and compare — dereferencing the actor proves only
|
|
934
|
+
// that the actor EXISTS. So anyone at all could Append a Follow naming
|
|
935
|
+
// anyone at all, and we would sign an Accept, deliver it to that person,
|
|
936
|
+
// and send them everything published from then on.
|
|
937
|
+
//
|
|
938
|
+
// Until deliveries terminate somewhere their signature survives, a follow
|
|
939
|
+
// we cannot verify is a REQUEST, waiting in the same queue a gated group
|
|
940
|
+
// uses. The requester's client shows "Requested", which is the ordinary
|
|
941
|
+
// locked-account state that manuallyApprovesFollowers tells it to expect.
|
|
942
|
+
// `autoAcceptFollows: true` in config restores the old behaviour.
|
|
943
|
+
// A GROUP is left alone: `approveJoins: false` is its operator saying, in
|
|
944
|
+
// as many words, that anyone may join, and mute/eject are the remedy there.
|
|
945
|
+
// A person has no such setting, so this is their default.
|
|
946
|
+
//
|
|
947
|
+
// A gateway-verified Follow (trust mode) is no longer unverifiable — the
|
|
948
|
+
// door proved the sender — so it does not need the OK that unverifiability
|
|
949
|
+
// alone demanded. An explicit `approveJoins` still holds: verified or not,
|
|
950
|
+
// the operator asked to see joins.
|
|
951
|
+
const unverifiedNeedsOk = this.config.kind !== 'group' && !this.config.autoAcceptFollows && !trusted;
|
|
952
|
+
const mustApprove = this.config.approveJoins || unverifiedNeedsOk;
|
|
953
|
+
if (mustApprove && !existing) {
|
|
954
|
+
const reqs = this.store.getRequests();
|
|
955
|
+
if (!reqs.some(r => r.actor === actor)) {
|
|
956
|
+
reqs.unshift({
|
|
957
|
+
actor, inbox: doc.inbox, sharedInbox: doc.endpoints?.sharedInbox,
|
|
958
|
+
activity, at: new Date().toISOString(),
|
|
959
|
+
});
|
|
960
|
+
this.store.setRequests(reqs.slice(0, 500));
|
|
961
|
+
this.store.addNotification({ type: 'follow-request', actor });
|
|
962
|
+
await this.republish({ pending: true });
|
|
963
|
+
}
|
|
964
|
+
this.log(`join requested: ${actor}`);
|
|
965
|
+
return;
|
|
966
|
+
}
|
|
967
|
+
if (existing) {
|
|
968
|
+
// Deliberately NOT updating followId. An inbound Follow is unverifiable —
|
|
969
|
+
// that is what the queue above exists for — so letting one rewrite the id
|
|
970
|
+
// of a follower we already hold hands an attacker the exact value onUndo
|
|
971
|
+
// matches on: POST a Follow naming any follower in the published
|
|
972
|
+
// collection, then POST an Undo naming the id you just chose, and they are
|
|
973
|
+
// gone permanently. A genuine refollow needs nothing from us but the
|
|
974
|
+
// Accept below, which is idempotent.
|
|
975
|
+
} else {
|
|
976
|
+
// A Bluesky member who bridges later arrives here as a second, different
|
|
977
|
+
// actor: the bridge follows on their behalf from bsky.brid.gy/ap/<did>,
|
|
978
|
+
// while the native join is recorded under bsky.app/profile/<did>. Left
|
|
979
|
+
// alone that is one person listed twice, carried twice, and ejectable
|
|
980
|
+
// only half at a time. The bridged record supersedes the native one —
|
|
981
|
+
// it reaches the fediverse side, which the native one never could.
|
|
982
|
+
const bridgedDid = /^https:\/\/bsky\.brid\.gy\/ap\/(did:[^/]+)$/.exec(actor)?.[1];
|
|
983
|
+
if (bridgedDid) {
|
|
984
|
+
const before = contacts.followers.length;
|
|
985
|
+
contacts.followers = contacts.followers.filter(f => f.bsky?.did !== bridgedDid);
|
|
986
|
+
if (contacts.followers.length < before) {
|
|
987
|
+
this.log(`bluesky member ${bridgedDid} is bridged now — the native record gives way to it`);
|
|
988
|
+
}
|
|
989
|
+
}
|
|
990
|
+
contacts.followers.push({
|
|
991
|
+
actor, inbox: doc.inbox, sharedInbox: doc.endpoints?.sharedInbox, followId: activity.id,
|
|
992
|
+
...(bridgedDid ? { bsky: { did: bridgedDid, bridged: true } } : {}),
|
|
993
|
+
});
|
|
994
|
+
this.store.setContacts(contacts);
|
|
995
|
+
this.store.addNotification({ type: 'follow', actor });
|
|
996
|
+
await this.republish({ followers: true });
|
|
997
|
+
this.log(`new follower: ${actor}`);
|
|
998
|
+
}
|
|
999
|
+
const { acceptActivity } = await import('./wire.mjs');
|
|
1000
|
+
await this.deliverer.deliver(doc.inbox,
|
|
1001
|
+
acceptActivity({ urls: this.urls, followActivity: activity, serial: this.serial++ }));
|
|
1002
|
+
this.log(`Accept sent → ${doc.inbox}`);
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
async onUndo(activity, actor, { trusted = false } = {}) {
|
|
1006
|
+
// AS2 allows `object` to be a bare IRI, and that IRI is exactly the Follow
|
|
1007
|
+
// id we stored. Reading `.type` off a string gives undefined, so the whole
|
|
1008
|
+
// Undo was dropped — silently, since handle() reads that as handled, so no
|
|
1009
|
+
// dead letter was kept and the item was DELETEd. The follower stayed, we
|
|
1010
|
+
// kept delivering to them, and their server had recorded the unfollow as
|
|
1011
|
+
// done and would never resend. Only a TYPED non-Follow is not ours.
|
|
1012
|
+
if (typeof activity.object === 'object' && activity.object?.type
|
|
1013
|
+
&& activity.object.type !== 'Follow') return;
|
|
1014
|
+
// And it must NAME something. Widening the type test to admit a bare IRI
|
|
1015
|
+
// also admitted `object: undefined`, `null` and `{}` — which land on the
|
|
1016
|
+
// no-followId carve-out below and evict, which is the very hole the
|
|
1017
|
+
// followId check was added to close. An Undo that identifies nothing is
|
|
1018
|
+
// not an Undo of ours.
|
|
1019
|
+
const named = typeof activity.object === 'string' ? activity.object : activity.object?.id;
|
|
1020
|
+
// A gateway-verified Undo need not name a stored id — the door proved the
|
|
1021
|
+
// sender, so an Undo{Follow} that names us as its object is enough. An
|
|
1022
|
+
// unverified one must still identify something (the eviction-hole guard).
|
|
1023
|
+
if (!named && !trusted) return;
|
|
1024
|
+
// Deliveries arrive unordered: an Undo may land AFTER the refollow it
|
|
1025
|
+
// predates. It names the Follow id it revokes — only honor it when it
|
|
1026
|
+
// matches the follow we currently hold for that actor.
|
|
1027
|
+
const undoneId = typeof activity.object === 'string' ? activity.object : activity.object?.id;
|
|
1028
|
+
const contacts = this.store.getContacts();
|
|
1029
|
+
const rec = contacts.followers.find(f => f.actor === actor);
|
|
1030
|
+
// Withdrawing a request that was never answered: drop it, or it sits in the
|
|
1031
|
+
// operator's queue forever asking about someone who left.
|
|
1032
|
+
if (!rec) {
|
|
1033
|
+
const reqs = this.store.getRequests();
|
|
1034
|
+
if (reqs.some(r => r.actor === actor)) {
|
|
1035
|
+
this.store.setRequests(reqs.filter(r => r.actor !== actor));
|
|
1036
|
+
await this.republish({ pending: true });
|
|
1037
|
+
this.log(`join request withdrawn: ${actor}`);
|
|
1038
|
+
}
|
|
1039
|
+
return;
|
|
1040
|
+
}
|
|
1041
|
+
// An Undo must NAME the Follow it revokes, and name the one we hold.
|
|
1042
|
+
//
|
|
1043
|
+
// The follow id is the ONLY thing binding an Undo to the follower. LDN
|
|
1044
|
+
// bodies carry no signature, and unlike every other inbound type this path
|
|
1045
|
+
// dereferences nothing, so there is no origin to disagree. Matching works
|
|
1046
|
+
// because the id was chosen by their server and delivered in a Follow we
|
|
1047
|
+
// accepted: we publish the followers collection, but never the ids.
|
|
1048
|
+
//
|
|
1049
|
+
// Which means a record with NO id cannot be matched at all — and the
|
|
1050
|
+
// carve-out that used to let those through turned "we cannot tell" into
|
|
1051
|
+
// "anyone may evict". reconcileFollowers writes exactly such records when a
|
|
1052
|
+
// restored machine recovers its followers from the pod, so after a restore
|
|
1053
|
+
// every follower could be removed by one unauthenticated POST, permanently:
|
|
1054
|
+
// dropFollower leaves a mark and the next reconcile will not bring them
|
|
1055
|
+
// back, their server recorded no unfollow so it never resends, and neither
|
|
1056
|
+
// side has anything to notice.
|
|
1057
|
+
//
|
|
1058
|
+
// Unmatchable is refused now. The cost is a follower who really did leave
|
|
1059
|
+
// staying on the list until the operator ejects them, which is the right way
|
|
1060
|
+
// round: `eject` is one command, and the alternative was silent, permanent,
|
|
1061
|
+
// and available to anyone.
|
|
1062
|
+
// A gateway-verified Undo carries the sender's proof, so it is honored on
|
|
1063
|
+
// its own — the followId match below exists only because an UNVERIFIED Undo
|
|
1064
|
+
// is otherwise unbindable. A verified one needs no such crutch.
|
|
1065
|
+
if (trusted) {
|
|
1066
|
+
dropFollower(contacts, actor, 'undo-follow');
|
|
1067
|
+
this.store.setContacts(contacts);
|
|
1068
|
+
await this.republish({ followers: true });
|
|
1069
|
+
this.log(`unfollowed by ${actor} (gateway-verified)`);
|
|
1070
|
+
return;
|
|
1071
|
+
}
|
|
1072
|
+
if (!rec.followId) {
|
|
1073
|
+
this.log(`Undo from ${actor} cannot be matched — this follower was `
|
|
1074
|
+
+ `${rec.recovered ? 'recovered from the pod' : 'recorded before follow ids were kept'}, `
|
|
1075
|
+
+ `so its follow id is unknown. Ignored; \`fedipod eject ${actor}\` if they did leave.`);
|
|
1076
|
+
return;
|
|
1077
|
+
}
|
|
1078
|
+
if (undoneId !== rec.followId) {
|
|
1079
|
+
this.log(`Undo from ${actor} does not name the follow we hold `
|
|
1080
|
+
+ `(revokes ${undoneId || 'nothing'}, current is ${rec.followId}) — ignored`);
|
|
1081
|
+
return;
|
|
1082
|
+
}
|
|
1083
|
+
dropFollower(contacts, actor, 'undo-follow');
|
|
1084
|
+
this.store.setContacts(contacts);
|
|
1085
|
+
await this.republish({ followers: true });
|
|
1086
|
+
this.log(`unfollowed by ${actor}`);
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
// Does this activity/note concern us at all? Either it comes from someone
|
|
1090
|
+
// we follow, or it names us (to/cc, mention tag) or replies to one of our
|
|
1091
|
+
// notes. Anything else is a stranger blasting inboxes — refuse it before
|
|
1092
|
+
// spending a dereference on it.
|
|
1093
|
+
concernsUs(doc, actor) {
|
|
1094
|
+
if (this.store.getContacts().following.some(f => f.actor === actor && f.accepted)) return true;
|
|
1095
|
+
const audience = []
|
|
1096
|
+
.concat(doc?.to || [], doc?.cc || [], doc?.bto || [], doc?.bcc || [], doc?.audience || [])
|
|
1097
|
+
.map(v => (typeof v === 'string' ? v : v?.id)).filter(Boolean);
|
|
1098
|
+
if (audience.includes(this.urls.actor) || audience.includes(this.urls.followers)) return true;
|
|
1099
|
+
const tagged = [].concat(doc?.tag || [])
|
|
1100
|
+
.some(t => t?.type === 'Mention' && (t.href === this.urls.actor || t.name?.includes(this.urls.actor)));
|
|
1101
|
+
if (tagged) return true;
|
|
1102
|
+
const inReplyTo = typeof doc?.inReplyTo === 'string' ? doc.inReplyTo : doc?.inReplyTo?.id;
|
|
1103
|
+
if (!inReplyTo) return false;
|
|
1104
|
+
if (String(inReplyTo).startsWith(this.urls.notes)) return true;
|
|
1105
|
+
// A group also owns the conversation under anything it carried. Without
|
|
1106
|
+
// this, a reply that lost the group's mention on its way round the
|
|
1107
|
+
// fediverse is refused, and the thread breaks for everyone who was only
|
|
1108
|
+
// ever following the group.
|
|
1109
|
+
return this.config.kind === 'group'
|
|
1110
|
+
&& this.store.getStatuses().some(s => s.noteId === String(inReplyTo));
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
// §7.1.2 Forwarding from the inbox. A reply into one of our threads reaches
|
|
1114
|
+
// only the servers the replier's server chose to deliver to — never our
|
|
1115
|
+
// followers on servers it has never heard of. As the actor those followers
|
|
1116
|
+
// follow, WE close that gap: an activity addressed to our followers collection
|
|
1117
|
+
// that names one of our objects is re-delivered to our followers' inboxes.
|
|
1118
|
+
//
|
|
1119
|
+
// Only what was addressed to the followers COLLECTION is carried — bto/bcc are
|
|
1120
|
+
// never read here, so a direct message (addressed to a person) never qualifies
|
|
1121
|
+
// and is never rebroadcast. And this runs only after handle() accepted the
|
|
1122
|
+
// activity, so anything blocked or muted was already refused upstream and is
|
|
1123
|
+
// never forwarded.
|
|
1124
|
+
async _maybeForward(activity) {
|
|
1125
|
+
if (!activity || typeof activity !== 'object') return;
|
|
1126
|
+
try {
|
|
1127
|
+
const audience = []
|
|
1128
|
+
.concat(activity.to || [], activity.cc || [], activity.audience || [])
|
|
1129
|
+
.map(v => (typeof v === 'string' ? v : v?.id)).filter(Boolean);
|
|
1130
|
+
if (!audience.includes(this.urls.followers)) return; // not for our followers
|
|
1131
|
+
if (!this._referencesOurObject(activity)) return; // not into a thread of ours
|
|
1132
|
+
const actor = typeof activity.actor === 'string' ? activity.actor : activity.actor?.id;
|
|
1133
|
+
if (actor === this.urls.actor) return; // our own; nothing to forward
|
|
1134
|
+
const id = typeof activity.id === 'string' ? activity.id : null;
|
|
1135
|
+
if (!id) return;
|
|
1136
|
+
const forwarded = this.store.read('forwarded.json', []);
|
|
1137
|
+
if (forwarded.includes(id)) return; // already done
|
|
1138
|
+
|
|
1139
|
+
const inboxes = [...new Set(this.store.getContacts().followers
|
|
1140
|
+
.filter(f => !f.bsky) // Bluesky members are not AP inboxes
|
|
1141
|
+
.map(f => f.sharedInbox || f.inbox)
|
|
1142
|
+
.filter(Boolean))];
|
|
1143
|
+
if (!inboxes.length) return;
|
|
1144
|
+
await this.deliverer.deliverToAll(inboxes, activity);
|
|
1145
|
+
this.store.write('forwarded.json', [...forwarded, id].slice(-MAX_FORWARDED));
|
|
1146
|
+
this.log(`forwarded ${activity.type} ${id} to ${inboxes.length} follower inbox(es)`);
|
|
1147
|
+
} catch (e) {
|
|
1148
|
+
this.log(`inbox forwarding: ${e.message}`); // never stalls the drain
|
|
1149
|
+
}
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
// The "objects owned by the server" half of §7.1.2: does the activity reply
|
|
1153
|
+
// to, like, boost or otherwise name one of our own objects?
|
|
1154
|
+
_referencesOurObject(activity) {
|
|
1155
|
+
const refs = [];
|
|
1156
|
+
const add = (v) => { const id = typeof v === 'string' ? v : v?.id; if (id) refs.push(String(id)); };
|
|
1157
|
+
const obj = activity.object;
|
|
1158
|
+
if (obj && typeof obj === 'object') add(obj.inReplyTo); // a reply's parent
|
|
1159
|
+
add(activity.inReplyTo);
|
|
1160
|
+
add(activity.object); // a Like/Announce of our note
|
|
1161
|
+
add(activity.target);
|
|
1162
|
+
if (refs.some(r => r.startsWith(this.urls.notes))) return true;
|
|
1163
|
+
return this.config.kind === 'group'
|
|
1164
|
+
&& refs.some(r => this.store.getStatuses().some(s => s.noteId === r));
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
async onCreate(activity, actor) {
|
|
1168
|
+
const objectId = typeof activity.object === 'string' ? activity.object : activity.object?.id;
|
|
1169
|
+
if (!objectId) return 'Create without object id';
|
|
1170
|
+
if (this.store.isBlocked(objectId)) return `blocked domain (${objectId})`;
|
|
1171
|
+
if (!this.sameOrigin(objectId, actor)) return `object/actor origin mismatch (${objectId})`;
|
|
1172
|
+
// The delivered copy is untrusted for CONTENT, but its addressing is
|
|
1173
|
+
// enough to decide whether to bother fetching the origin's copy.
|
|
1174
|
+
const envelope = typeof activity.object === 'object' ? { ...activity, ...activity.object } : activity;
|
|
1175
|
+
if (!this.concernsUs(envelope, actor)) return `not addressed to us (${objectId})`;
|
|
1176
|
+
// The check onAnnounce has had all along. A re-delivered Create — a remote
|
|
1177
|
+
// retry, a group fan-out, or our own sweep seeing an item whose DELETE was
|
|
1178
|
+
// refused — cost a fresh signed GET to the origin and rewrote the private
|
|
1179
|
+
// RDF note every time, because addStatus only dedupes AFTER the deref.
|
|
1180
|
+
// Gated around the INGEST alone: a group must still reach amplify below,
|
|
1181
|
+
// which is separately idempotent on announcedAt.
|
|
1182
|
+
// Only a status that came from an INGEST counts as already done. TagFeed
|
|
1183
|
+
// writes a bare `kind:'tag'` row straight into the index — no pod RDF note,
|
|
1184
|
+
// no mention notification, no replies-collection entry — so treating that
|
|
1185
|
+
// as ingested loses all three when the same note is then delivered to us.
|
|
1186
|
+
const ingested = this.store.getStatuses()
|
|
1187
|
+
.some(x => x.noteId === objectId && (x.kind === 'timeline' || x.kind === 'mention'));
|
|
1188
|
+
if (!ingested) {
|
|
1189
|
+
const rejected = await this.ingestNote(objectId, actor);
|
|
1190
|
+
if (rejected) return rejected;
|
|
1191
|
+
}
|
|
1192
|
+
// A group carries its members' posts onward. Only reached from Create, so an
|
|
1193
|
+
// inbound Announce is never re-announced. The activity is passed through
|
|
1194
|
+
// untouched — FEP-1b12 wants the original wrapped, not a summary of it.
|
|
1195
|
+
if (this.config.kind === 'group') await this.amplify(objectId, { activity });
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1198
|
+
// Anyone can Append to a public inbox, so arriving is not the same as being
|
|
1199
|
+
// carried to every follower. Membership is the gate: you cannot post to a
|
|
1200
|
+
// group you have not joined, and declining to carry a member is the only
|
|
1201
|
+
// moderation a group can actually enforce.
|
|
1202
|
+
async amplify(noteId, { approved = false, activity = null } = {}) {
|
|
1203
|
+
const s = this.store.getStatuses().find(x => x.noteId === noteId);
|
|
1204
|
+
if (!s) return;
|
|
1205
|
+
if (s.announcedAt) return; // a re-delivered Create announces once
|
|
1206
|
+
// A DM to the group, or a followers-only post it happened to receive, was
|
|
1207
|
+
// addressed to less than the world — carrying it would widen the author's
|
|
1208
|
+
// audience for them. A group only ever amplifies public posts.
|
|
1209
|
+
if (s.direct || s.nonPublic) {
|
|
1210
|
+
this.log(`not amplified — ${noteId} was not addressed publicly, and a group never widens a post's audience`);
|
|
1211
|
+
return;
|
|
1212
|
+
}
|
|
1213
|
+
const contacts = this.store.getContacts();
|
|
1214
|
+
if (!contacts.followers.some(f => f.actor === s.actor)) {
|
|
1215
|
+
this.log(`not amplified — ${s.actor} is not a member`);
|
|
1216
|
+
return;
|
|
1217
|
+
}
|
|
1218
|
+
if (this.store.getMuted().actors.includes(s.actor)) {
|
|
1219
|
+
this.log(`not amplified — ${s.actor} is muted`);
|
|
1220
|
+
return;
|
|
1221
|
+
}
|
|
1222
|
+
// A reviewed group carries nothing until its operator says so.
|
|
1223
|
+
if (this.config.review && !approved) {
|
|
1224
|
+
const pending = this.store.getPending();
|
|
1225
|
+
if (!pending.some(p => p.noteId === noteId)) {
|
|
1226
|
+
// Full means refuse the new one, not evict the oldest. `slice(0, 500)`
|
|
1227
|
+
// dropped from the tail, so one member posting 500 notes silently
|
|
1228
|
+
// discarded everything the operator was still deciding about — the
|
|
1229
|
+
// posts were never carried, never refused, and left no record that they
|
|
1230
|
+
// had ever arrived. Becoming a member costs one Follow when joins are
|
|
1231
|
+
// unmoderated, which is the default.
|
|
1232
|
+
//
|
|
1233
|
+
// Not carrying it is what a reviewed group does with anything it has
|
|
1234
|
+
// not approved, so refusing is the same outcome the queue was for.
|
|
1235
|
+
if (pending.length >= MAX_PENDING_REVIEW) {
|
|
1236
|
+
this.log(`review queue is full (${MAX_PENDING_REVIEW}) — ${noteId} not held. `
|
|
1237
|
+
+ 'Approve or decline what is waiting and it will be carried on redelivery.');
|
|
1238
|
+
return;
|
|
1239
|
+
}
|
|
1240
|
+
// The activity rides along: approving later still has to wrap the one
|
|
1241
|
+
// the member actually sent, not a reconstruction of it.
|
|
1242
|
+
pending.unshift({ noteId, actor: s.actor, activity, at: new Date().toISOString() });
|
|
1243
|
+
this.store.setPending(pending);
|
|
1244
|
+
}
|
|
1245
|
+
this.log(`held for review: ${noteId}`);
|
|
1246
|
+
return;
|
|
1247
|
+
}
|
|
1248
|
+
// A member's Bluesky post: the carry is a native repost by the group's
|
|
1249
|
+
// account. It reaches AP followers only through the author's own bridge —
|
|
1250
|
+
// the group never fabricates an AP object for someone else's words.
|
|
1251
|
+
if (s.kind === 'bsky') {
|
|
1252
|
+
if (!this.bskyGroup) { this.log(`not amplified — ${noteId} is a bluesky post and no account is connected`); return; }
|
|
1253
|
+
return this.bskyGroup.carry(s);
|
|
1254
|
+
}
|
|
1255
|
+
const held = this.store.getPending().find(p => p.noteId === noteId);
|
|
1256
|
+
const inboxes = this.announceTargets(s.actor);
|
|
1257
|
+
const { announceActivity } = await import('./wire.mjs');
|
|
1258
|
+
// Wrap the member's own activity when we have it; a bare note URL is the
|
|
1259
|
+
// fallback, and renders as a plain boost rather than a group carry. The
|
|
1260
|
+
// group names itself as the audience (FEP-1b12).
|
|
1261
|
+
const act = announceActivity({
|
|
1262
|
+
urls: this.urls, object: activity || held?.activity || noteId, serial: this.serial++,
|
|
1263
|
+
audience: this.urls.actor,
|
|
1264
|
+
});
|
|
1265
|
+
await this.deliverer.deliverToAll(inboxes, act);
|
|
1266
|
+
// Marked carried before recorded: a failed outbox write costs one missing
|
|
1267
|
+
// entry, a failed status write would carry the same post twice.
|
|
1268
|
+
this.store.updateStatus(noteId, { announcedAt: new Date().toISOString(), announceActivity: act });
|
|
1269
|
+
await this.publisher.recordOutbox(act);
|
|
1270
|
+
this.store.setPending(this.store.getPending().filter(p => p.noteId !== noteId));
|
|
1271
|
+
this.log(`amplified ${noteId} → ${inboxes.length} inbox(es)`);
|
|
1272
|
+
// The same carry, shown natively to the group's Bluesky followers.
|
|
1273
|
+
await this.bskyGroup?.mirrorCarry(s)
|
|
1274
|
+
.catch(e => this.log(`bluesky mirror of the carry failed: ${e.message}`));
|
|
1275
|
+
}
|
|
1276
|
+
|
|
1277
|
+
// Is this actor in a group we are in? Each followed Group's membership is a
|
|
1278
|
+
// public collection, read at most once a day and cached — a membership list
|
|
1279
|
+
// is slow-moving, and this runs on arriving mail.
|
|
1280
|
+
async isCoMember(actor) {
|
|
1281
|
+
if (this.config.kind === 'group') return false; // a group has members, not peers
|
|
1282
|
+
const groups = this.store.getContacts().following
|
|
1283
|
+
.filter(f => f.accepted && this.store.getActors()[f.actor]?.type === 'Group')
|
|
1284
|
+
.map(f => f.actor);
|
|
1285
|
+
if (!groups.length) return false;
|
|
1286
|
+
const cache = this.store.read('comembers.json', {});
|
|
1287
|
+
const fresh = Date.now() - CO_MEMBER_TTL_MS;
|
|
1288
|
+
let changed = false;
|
|
1289
|
+
for (const g of groups) {
|
|
1290
|
+
const held = cache[g];
|
|
1291
|
+
if (held && Date.parse(held.at || 0) > fresh) continue;
|
|
1292
|
+
const doc = await this.fetchAP(g).catch(() => null);
|
|
1293
|
+
const list = doc?.followers ? await this.collectionMembers(doc.followers) : null;
|
|
1294
|
+
// A list we could not read keeps whatever we had: losing it would demote
|
|
1295
|
+
// every co-member to a stranger for a day because one fetch failed.
|
|
1296
|
+
if (!list) continue;
|
|
1297
|
+
cache[g] = { at: new Date().toISOString(), members: list };
|
|
1298
|
+
changed = true;
|
|
1299
|
+
}
|
|
1300
|
+
if (changed) this.store.write('comembers.json', cache);
|
|
1301
|
+
return groups.some(g => cache[g]?.members?.includes(actor));
|
|
1302
|
+
}
|
|
1303
|
+
|
|
1304
|
+
// The actor ids in a (possibly paged) public collection, capped.
|
|
1305
|
+
async collectionMembers(url) {
|
|
1306
|
+
const out = [];
|
|
1307
|
+
let next = url;
|
|
1308
|
+
for (let page = 0; next && page < 10 && out.length < CO_MEMBER_MAX; page++) {
|
|
1309
|
+
const doc = await this.fetchAP(next).catch(() => null);
|
|
1310
|
+
if (!doc) return out.length ? out : null;
|
|
1311
|
+
for (const item of doc.orderedItems || doc.items || []) {
|
|
1312
|
+
if (typeof item === 'string') out.push(item);
|
|
1313
|
+
}
|
|
1314
|
+
next = doc.first && page === 0 ? doc.first : doc.next;
|
|
1315
|
+
if (typeof next === 'object') next = next?.id;
|
|
1316
|
+
}
|
|
1317
|
+
return out;
|
|
1318
|
+
}
|
|
1319
|
+
|
|
1320
|
+
// Who an Announce for `author` goes to. Shared with the retract path: an Undo
|
|
1321
|
+
// that reached a different set than the Announce did would leave the post
|
|
1322
|
+
// standing for whoever the two sets disagreed about.
|
|
1323
|
+
// The author's own target is dropped only when it serves nobody else — a
|
|
1324
|
+
// shared inbox carries the whole server's members.
|
|
1325
|
+
announceTargets(author) {
|
|
1326
|
+
const byTarget = new Map();
|
|
1327
|
+
for (const f of this.store.getContacts().followers) {
|
|
1328
|
+
const t = f.sharedInbox || f.inbox;
|
|
1329
|
+
if (!t) continue;
|
|
1330
|
+
if (!byTarget.has(t)) byTarget.set(t, new Set());
|
|
1331
|
+
byTarget.get(t).add(f.actor);
|
|
1332
|
+
}
|
|
1333
|
+
return [...byTarget]
|
|
1334
|
+
.filter(([, who]) => !(who.size === 1 && who.has(author)))
|
|
1335
|
+
.map(([t]) => t);
|
|
1336
|
+
}
|
|
1337
|
+
|
|
1338
|
+
// A boost: ingest the boosted note when the booster is someone we follow —
|
|
1339
|
+
// that's what following means, their boosts widen the timeline. Anything
|
|
1340
|
+
// else is unsolicited and only logged.
|
|
1341
|
+
// A group we follow announces a Delete: the carrier moderating away a post
|
|
1342
|
+
// it carried. Honored only within what the carry itself established — the
|
|
1343
|
+
// announcer is a group we follow AND the post reached us via that same
|
|
1344
|
+
// group — so no new party is trusted and nothing is dereferenced. Our own
|
|
1345
|
+
// posts are never removed by anyone's moderation.
|
|
1346
|
+
async onAnnouncedDelete(actor, del) {
|
|
1347
|
+
const followed = this.store.getContacts().following.some(f => f.actor === actor && f.accepted);
|
|
1348
|
+
if (!followed) { this.log(`announced Delete from unfollowed ${actor} — ignored`); return; }
|
|
1349
|
+
const targetId = typeof del.object === 'string' ? del.object : del.object?.id;
|
|
1350
|
+
if (!targetId) return 'announced Delete without an object';
|
|
1351
|
+
const s = this.store.getStatuses().find(x => x.noteId === targetId);
|
|
1352
|
+
if (!s || s.kind === 'post' || s.via !== actor) return;
|
|
1353
|
+
await this.forget(s);
|
|
1354
|
+
this.log(`moderated away by ${actor}: ${targetId}`);
|
|
1355
|
+
}
|
|
1356
|
+
|
|
1357
|
+
async onAnnounce(activity, actor, objectId) {
|
|
1358
|
+
if (!objectId) return 'Announce without object id';
|
|
1359
|
+
const followed = this.store.getContacts().following.some(f => f.actor === actor && f.accepted);
|
|
1360
|
+
if (!followed) { this.log(`Announce from unfollowed ${actor} — ignored`); return; }
|
|
1361
|
+
if (this.store.isBlocked(objectId)) return `blocked domain (${objectId})`;
|
|
1362
|
+
const existing = this.store.getStatuses().find(s => s.noteId === objectId);
|
|
1363
|
+
if (existing) {
|
|
1364
|
+
// Known, but possibly as a lesser kind — a stranger's mention, a tag or
|
|
1365
|
+
// search mirror — none of which the home timeline shows. A carry from
|
|
1366
|
+
// someone we follow is exactly what promotes it there.
|
|
1367
|
+
if (!['timeline', 'post'].includes(existing.kind)) {
|
|
1368
|
+
this.store.updateStatus(objectId, { kind: 'timeline', via: actor });
|
|
1369
|
+
this.log(`promoted to timeline (carried by ${actor}): ${objectId}`);
|
|
1370
|
+
}
|
|
1371
|
+
return;
|
|
1372
|
+
}
|
|
1373
|
+
return this.ingestNote(objectId, actor, { via: actor });
|
|
1374
|
+
}
|
|
1375
|
+
|
|
1376
|
+
// Shared tail of Create/Announce: deref the note at its origin (never trust
|
|
1377
|
+
// the delivered copy), mirror it into pod RDF + statuses, notify on replies
|
|
1378
|
+
// to our own notes. Returns a rejection reason string, or undefined.
|
|
1379
|
+
async ingestNote(objectId, actor, { via } = {}) {
|
|
1380
|
+
const note = await this.fetchAP(objectId);
|
|
1381
|
+
if (!note) return `object fetch failed (${objectId})`;
|
|
1382
|
+
if (note.id !== objectId || !isContentType(note.type)) return `object not verifiable content (${objectId}, ${note.type})`;
|
|
1383
|
+
const { attachmentsOf, sanitizeHtml } = await import('./wire.mjs');
|
|
1384
|
+
const attachments = attachmentsOf(note);
|
|
1385
|
+
const content = sanitizeHtml(note.content); // hostile markup never reaches pod or client
|
|
1386
|
+
// The delivering actor was checked on arrival; the author is only known once
|
|
1387
|
+
// the note is dereferenced. authorOf refuses an author the note's own origin
|
|
1388
|
+
// does not vouch for — see its comment; this is where a forged attribution
|
|
1389
|
+
// would otherwise become a timeline entry, a pod document, and for a group a
|
|
1390
|
+
// signed Announce to every member.
|
|
1391
|
+
const author = authorOf(note, actor);
|
|
1392
|
+
if (!author) return `object names an author its origin does not vouch for (${objectId})`;
|
|
1393
|
+
// This is the check that catches a blocked actor reaching us through
|
|
1394
|
+
// somebody else's boost, or through a hashtag feed.
|
|
1395
|
+
if (this.store.isBlocked(author)) return `blocked author (${author})`;
|
|
1396
|
+
|
|
1397
|
+
// Anyone can Append to a public inbox, so arriving is not the same as
|
|
1398
|
+
// belonging in the home timeline. Follow Mastodon's split: people you
|
|
1399
|
+
// follow (and their boosts) are HOME; anyone else is a MENTION — kept,
|
|
1400
|
+
// notified, readable in the Mentions view, but out of the timeline, and
|
|
1401
|
+
// mirror-only so unsolicited content never accumulates in the pod.
|
|
1402
|
+
// A group's people are its FOLLOWERS — it follows nobody. Reading the
|
|
1403
|
+
// following list for one filed every member's post as a stranger's mention,
|
|
1404
|
+
// so nothing reached the pod RDF and each post raised a notification.
|
|
1405
|
+
const contacts = this.store.getContacts();
|
|
1406
|
+
const known = this.config.kind === 'group'
|
|
1407
|
+
? contacts.followers.some(f => f.actor === author)
|
|
1408
|
+
: contacts.following.some(f => f.actor === author && f.accepted);
|
|
1409
|
+
// Someone in a group you are in is not a stranger: their reply belongs in
|
|
1410
|
+
// the room, not in the drawer of unsolicited mail. Whose word this is on
|
|
1411
|
+
// is the group's — its published membership — so the group's own door
|
|
1412
|
+
// decides who gets in.
|
|
1413
|
+
const followed = via || known || (!known && await this.isCoMember(author));
|
|
1414
|
+
const kind = followed ? 'timeline' : 'mention';
|
|
1415
|
+
|
|
1416
|
+
// `published` comes from a document at someone else's origin, and its first
|
|
1417
|
+
// ten characters become the leading component of a storage path. A date is
|
|
1418
|
+
// a date or it is not used: a value carrying `../` would otherwise be a
|
|
1419
|
+
// remote party choosing where we write.
|
|
1420
|
+
const day = String(note.published || '').slice(0, 10);
|
|
1421
|
+
const slug = (/^\d{4}-\d{2}-\d{2}$/.test(day) ? day : new Date().toISOString().slice(0, 10)) + '-' +
|
|
1422
|
+
(await import('node:crypto')).createHash('sha256').update(note.id).digest('hex').slice(0, 8);
|
|
1423
|
+
if (kind === 'timeline') {
|
|
1424
|
+
await this.local.writeNote('timeline', slug, {
|
|
1425
|
+
noteId: note.id, actor: author, published: note.published, content,
|
|
1426
|
+
inReplyTo: note.inReplyTo, attachments,
|
|
1427
|
+
});
|
|
1428
|
+
}
|
|
1429
|
+
// Mastodon carries a thread's mentions into every reply, which is the only
|
|
1430
|
+
// reason a reply ever reaches a group. Keep them so our composer can too.
|
|
1431
|
+
const mentions = [].concat(note.tag || [])
|
|
1432
|
+
.filter(t => t?.type === 'Mention' && t.href && t.name)
|
|
1433
|
+
.map(t => ({ href: t.href, name: t.name }));
|
|
1434
|
+
const emojis = emojisOf(note);
|
|
1435
|
+
const poll = pollOf(note);
|
|
1436
|
+
// Explicitly addressed, but to nobody public and to no followers
|
|
1437
|
+
// collection: a direct message, which belongs to the conversations view
|
|
1438
|
+
// rather than a timeline. A note with no addressing at all is NOT direct —
|
|
1439
|
+
// some servers omit to/cc, and vanishing from home is the wrong reading.
|
|
1440
|
+
const audience = [].concat(note.to || [], note.cc || []).map(String);
|
|
1441
|
+
const direct = audience.length > 0
|
|
1442
|
+
&& !audience.includes(PUBLIC) && !audience.some(a => a.endsWith('/followers'));
|
|
1443
|
+
// Addressed to less than the world: whatever else happens to it, a group
|
|
1444
|
+
// must never widen its audience by carrying it.
|
|
1445
|
+
const nonPublic = audience.length > 0 && !audience.includes(PUBLIC);
|
|
1446
|
+
this.store.addStatus({
|
|
1447
|
+
noteId: note.id, actor: author, content,
|
|
1448
|
+
published: note.published, inReplyTo: note.inReplyTo, kind,
|
|
1449
|
+
...(direct ? { direct: true } : {}),
|
|
1450
|
+
...(nonPublic ? { nonPublic: true } : {}),
|
|
1451
|
+
// The author's content warning, shown as one: plain text only.
|
|
1452
|
+
...(note.summary ? { spoiler: String(note.summary).replace(/<[^>]*>/g, '') } : {}),
|
|
1453
|
+
...(poll ? { poll } : {}),
|
|
1454
|
+
...(emojis.length ? { emojis } : {}),
|
|
1455
|
+
...(mentions.length ? { mentions } : {}),
|
|
1456
|
+
...(kind === 'timeline' ? { slug } : {}),
|
|
1457
|
+
...(attachments.length ? { attachments } : {}),
|
|
1458
|
+
...(via ? { via } : {}),
|
|
1459
|
+
});
|
|
1460
|
+
if (!followed || (note.inReplyTo && String(note.inReplyTo).startsWith(this.urls.notes))) {
|
|
1461
|
+
this.store.addNotification({ type: 'mention', actor: author, noteId: note.id });
|
|
1462
|
+
}
|
|
1463
|
+
if (note.inReplyTo && String(note.inReplyTo).startsWith(this.urls.notes)) {
|
|
1464
|
+
await this.addReply(String(note.inReplyTo), note.id)
|
|
1465
|
+
.catch(e => this.log(`replies collection: ${e.message}`));
|
|
1466
|
+
}
|
|
1467
|
+
this.log(`${kind}: ${note.id}${via ? ` (boosted by ${via})` : ''}`);
|
|
1468
|
+
}
|
|
1469
|
+
|
|
1470
|
+
// Is this really gone at its origin? true / false / null when the origin
|
|
1471
|
+
// could not be asked. Delivered bodies carry no signature, so this is how a
|
|
1472
|
+
// Delete is verified — the same verify-by-dereference the rest of intake uses.
|
|
1473
|
+
async isGone(url) {
|
|
1474
|
+
let res;
|
|
1475
|
+
try { res = await this.deliverer.signedFetch(url, { headers: { accept: ACCEPT_AP } }); }
|
|
1476
|
+
catch { return null; }
|
|
1477
|
+
if (res.status === 404 || res.status === 410) return true;
|
|
1478
|
+
if (res.status < 400) {
|
|
1479
|
+
// A Tombstone answers 200 and still means deleted.
|
|
1480
|
+
try {
|
|
1481
|
+
const { readCapped } = await import('./safefetch.mjs');
|
|
1482
|
+
return JSON.parse(await readCapped(res))?.type === 'Tombstone';
|
|
1483
|
+
} catch { return false; }
|
|
1484
|
+
}
|
|
1485
|
+
return null; // 401/403/5xx — no answer, not a denial
|
|
1486
|
+
}
|
|
1487
|
+
|
|
1488
|
+
// Mastodon sends these constantly; ignoring them left deleted posts standing
|
|
1489
|
+
// for good. Two guards, because a forged Delete would otherwise erase anyone's
|
|
1490
|
+
// content: it must come from the object's own origin, and the object must
|
|
1491
|
+
// really be gone there. An origin we cannot reach is a retry, never a delete.
|
|
1492
|
+
async onDelete(activity, actor) {
|
|
1493
|
+
const objectId = typeof activity.object === 'string' ? activity.object : activity.object?.id;
|
|
1494
|
+
if (!objectId) return 'Delete without object id';
|
|
1495
|
+
if (!this.sameOrigin(objectId, actor)) return `Delete crosses origins (${objectId})`;
|
|
1496
|
+
// `objectId === actor` is NOT evidence we care: it is true of EVERY account
|
|
1497
|
+
// deletion, and Mastodon broadcasts those constantly. Taking it as known
|
|
1498
|
+
// meant a signed dereference to a stranger's server for each one.
|
|
1499
|
+
if (!this.known(objectId)) return; // nothing of ours to remove
|
|
1500
|
+
const gone = await this.isGone(objectId);
|
|
1501
|
+
if (gone === null) throw new Error(`cannot confirm ${objectId} is gone — will retry`);
|
|
1502
|
+
if (!gone) return `Delete for something still published (${objectId})`;
|
|
1503
|
+
|
|
1504
|
+
if (objectId === actor) { // the account itself
|
|
1505
|
+
const contacts = this.store.getContacts();
|
|
1506
|
+
dropFollower(contacts, actor, 'account-deleted');
|
|
1507
|
+
contacts.following = contacts.following.filter(f => f.actor !== actor);
|
|
1508
|
+
this.store.setContacts(contacts);
|
|
1509
|
+
// One publish for the lot. Each forget() used to run its own
|
|
1510
|
+
// unrecordOutbox, and each of those republished the outbox — so a group
|
|
1511
|
+
// that had carried M of this actor's posts paid M full page sweeps for a
|
|
1512
|
+
// single inbox item. The Undo deliveries stay per-Announce, because each
|
|
1513
|
+
// Announce needs its own; only the pod write is collected.
|
|
1514
|
+
const retracted = [];
|
|
1515
|
+
for (const s of this.store.getStatuses().filter(s => s.actor === actor)) {
|
|
1516
|
+
await this.forget(s, { collect: retracted });
|
|
1517
|
+
}
|
|
1518
|
+
if (retracted.length) {
|
|
1519
|
+
const gone = new Set(retracted);
|
|
1520
|
+
await this.publisher.unrecordOutbox(i => gone.has(i?.id));
|
|
1521
|
+
}
|
|
1522
|
+
// Both: an account deletion drops them from followers AND following.
|
|
1523
|
+
await this.republish({ followers: true, following: true });
|
|
1524
|
+
this.log(`account deleted upstream: ${actor}`);
|
|
1525
|
+
return;
|
|
1526
|
+
}
|
|
1527
|
+
const s = this.store.getStatuses().find(x => x.noteId === objectId);
|
|
1528
|
+
if (s) await this.forget(s);
|
|
1529
|
+
this.log(`deleted upstream: ${objectId}`);
|
|
1530
|
+
}
|
|
1531
|
+
|
|
1532
|
+
// Drop a post we were holding. A group that carried it also unsays its own
|
|
1533
|
+
// Announce — forwarding the author's Delete would be signed by us and not by
|
|
1534
|
+
// them, which receivers are right to refuse.
|
|
1535
|
+
// `collect` batches the outbox side: retract pushes the Announce id onto it
|
|
1536
|
+
// instead of republishing, and the caller writes once for all of them.
|
|
1537
|
+
async forget(s, { collect = null } = {}) {
|
|
1538
|
+
if (s.announceActivity) {
|
|
1539
|
+
await this.retract(s.noteId, { collect }).catch(e => this.log(`retract: ${e.message}`));
|
|
1540
|
+
}
|
|
1541
|
+
if (s.slug) {
|
|
1542
|
+
// Said out loud rather than swallowed: the status row is about to go, so
|
|
1543
|
+
// a failure here leaves the note document in the private tree with
|
|
1544
|
+
// nothing indexing it, and no other record that it is there.
|
|
1545
|
+
await this.local.delete(this.local.fedi + 'timeline/' + s.slug)
|
|
1546
|
+
.catch(e => this.log(`forget ${s.noteId}: its RDF note could NOT be removed (${e.message})`));
|
|
1547
|
+
}
|
|
1548
|
+
this.store.removeStatus(s.noteId);
|
|
1549
|
+
}
|
|
1550
|
+
|
|
1551
|
+
// Undo an Announce this group made. Shared with the operator's `retract`.
|
|
1552
|
+
async retract(noteId, { collect = null } = {}) {
|
|
1553
|
+
const s = this.store.getStatuses().find(x => x.noteId === noteId);
|
|
1554
|
+
if (!s) throw new Error('no such post');
|
|
1555
|
+
// A Bluesky carry is a repost, and unsaying it is deleting the repost.
|
|
1556
|
+
if (s.repostUri) {
|
|
1557
|
+
if (!this.bskyGroup) throw new Error('no bluesky account connected');
|
|
1558
|
+
return this.bskyGroup.retract(s);
|
|
1559
|
+
}
|
|
1560
|
+
if (!s.announceActivity) throw new Error('that post was never carried');
|
|
1561
|
+
const { undoActivity } = await import('./wire.mjs');
|
|
1562
|
+
const inboxes = this.announceTargets(s.actor);
|
|
1563
|
+
await this.deliverer.deliverToAll(inboxes,
|
|
1564
|
+
undoActivity({ urls: this.urls, activity: s.announceActivity, serial: this.serial++ }));
|
|
1565
|
+
if (collect) collect.push(s.announceActivity.id);
|
|
1566
|
+
else await this.publisher.unrecordOutbox(i => i?.id === s.announceActivity.id);
|
|
1567
|
+
this.store.updateStatus(noteId, {
|
|
1568
|
+
announcedAt: undefined, announceActivity: undefined, retractedAt: new Date().toISOString(),
|
|
1569
|
+
});
|
|
1570
|
+
return { ok: true, noteId, inboxes: inboxes.length };
|
|
1571
|
+
}
|
|
1572
|
+
|
|
1573
|
+
// An edited post, or a changed profile. Verified the only way we can: by
|
|
1574
|
+
// refetching at the origin and believing that, not the delivered copy.
|
|
1575
|
+
async onUpdate(activity, actor) {
|
|
1576
|
+
const objectId = typeof activity.object === 'string' ? activity.object : activity.object?.id;
|
|
1577
|
+
if (!objectId) return 'Update without object id';
|
|
1578
|
+
if (!this.sameOrigin(objectId, actor)) return `Update crosses origins (${objectId})`;
|
|
1579
|
+
if (objectId === actor) { // display name, avatar, bio
|
|
1580
|
+
// The same guard onDelete has, for the same reason: the inbox is
|
|
1581
|
+
// public-Append, so without it anyone can name any host and make us spend
|
|
1582
|
+
// a signed GET on it — and because the failure below THROWS rather than
|
|
1583
|
+
// returning a rejection, one planted item buys five of them, plus five
|
|
1584
|
+
// pod reads and the head of the inbox held for five sweeps.
|
|
1585
|
+
if (!this.known(actor)) return; // nothing of ours to update
|
|
1586
|
+
const doc = await this.fetchAP(actor);
|
|
1587
|
+
if (!doc) throw new Error(`cannot refetch ${actor} — will retry`);
|
|
1588
|
+
this.store.cacheActor(actor, doc); // fetchAP caches Persons; Groups too
|
|
1589
|
+
this.log(`profile updated: ${actor}`);
|
|
1590
|
+
return;
|
|
1591
|
+
}
|
|
1592
|
+
const s = this.store.getStatuses().find(x => x.noteId === objectId);
|
|
1593
|
+
if (!s) return; // not one we hold
|
|
1594
|
+
const note = await this.fetchAP(objectId);
|
|
1595
|
+
if (!note) throw new Error(`cannot refetch ${objectId} — will retry`);
|
|
1596
|
+
if (note.id !== objectId || !isContentType(note.type)) return `object not verifiable content (${objectId}, ${note.type})`;
|
|
1597
|
+
const { attachmentsOf, sanitizeHtml } = await import('./wire.mjs');
|
|
1598
|
+
const content = sanitizeHtml(note.content);
|
|
1599
|
+
const attachments = attachmentsOf(note);
|
|
1600
|
+
const freshPoll = pollOf(note);
|
|
1601
|
+
const freshEmojis = emojisOf(note);
|
|
1602
|
+
this.store.updateStatus(objectId, {
|
|
1603
|
+
content, ...(attachments.length ? { attachments } : {}),
|
|
1604
|
+
emojis: freshEmojis.length ? freshEmojis : undefined,
|
|
1605
|
+
// The edit's own stamp when the note carries one; tallies and the
|
|
1606
|
+
// content warning follow the edit too. A poll refresh keeps our vote.
|
|
1607
|
+
editedAt: note.updated || new Date().toISOString(),
|
|
1608
|
+
spoiler: note.summary ? String(note.summary).replace(/<[^>]*>/g, '') : undefined,
|
|
1609
|
+
...(freshPoll ? {
|
|
1610
|
+
poll: { ...freshPoll, voted: !!s.poll?.voted, ownVotes: s.poll?.ownVotes || [] },
|
|
1611
|
+
} : {}),
|
|
1612
|
+
});
|
|
1613
|
+
if (s.slug) {
|
|
1614
|
+
await this.local.writeNote('timeline', s.slug, {
|
|
1615
|
+
noteId: note.id, actor: s.actor, published: note.published, content,
|
|
1616
|
+
inReplyTo: note.inReplyTo, attachments,
|
|
1617
|
+
}).catch(e => this.log(`rewrite ${s.slug}: ${e.message}`));
|
|
1618
|
+
}
|
|
1619
|
+
this.log(`edited upstream: ${objectId}`);
|
|
1620
|
+
}
|
|
1621
|
+
|
|
1622
|
+
// Read-modify-write, and the drain is serialized, so two replies in one sweep
|
|
1623
|
+
// do not race. Nothing else writes this document.
|
|
1624
|
+
// MAX_REPLIES_RECORDED caps the collection. It is a discovery aid — a client
|
|
1625
|
+
// reading a thread — and the statuses index is what actually holds the
|
|
1626
|
+
// replies, so dropping the oldest costs a hop, not a record.
|
|
1627
|
+
async addReply(parentId, replyId) {
|
|
1628
|
+
// The parent has to be a post we actually made. The only check used to be
|
|
1629
|
+
// that the id started with our notes prefix, and `inReplyTo` is read off a
|
|
1630
|
+
// document at the sender's own origin — so a stranger could name a note we
|
|
1631
|
+
// never wrote, and we would CREATE a document on the pod at a URL of their
|
|
1632
|
+
// choosing and then grow it, one whole re-PUT per reply, with no cap. Four
|
|
1633
|
+
// pod requests each and bytes quadratic in the number of replies.
|
|
1634
|
+
if (!this.store.getStatuses().some(s => s.noteId === parentId && s.kind === 'post')) {
|
|
1635
|
+
this.log(`reply names ${parentId}, which is not a post of ours — not recorded`);
|
|
1636
|
+
return;
|
|
1637
|
+
}
|
|
1638
|
+
const { repliesId, collection } = await import('./wire.mjs');
|
|
1639
|
+
const url = repliesId(parentId);
|
|
1640
|
+
// Deliberately NOT caught: a read we could not make is not an empty
|
|
1641
|
+
// collection, and rewriting on top of one erases every reply already
|
|
1642
|
+
// recorded. getJson returns null only for a genuine 404 — the document does
|
|
1643
|
+
// not exist yet — and throws otherwise, which the caller logs and retries.
|
|
1644
|
+
const cur = await this.remote.getJson(url);
|
|
1645
|
+
const items = Array.isArray(cur?.items) ? cur.items : [];
|
|
1646
|
+
if (items.includes(replyId)) return;
|
|
1647
|
+
items.push(replyId);
|
|
1648
|
+
await this.remote.putJson(url, collection(url, items.slice(-MAX_REPLIES_RECORDED)));
|
|
1649
|
+
this.log(`reply recorded on ${parentId}`);
|
|
1650
|
+
}
|
|
1651
|
+
|
|
1652
|
+
// The other answer to a Follow, and it was dropped on the floor. Their server
|
|
1653
|
+
// has recorded that we do not follow them; ours went on saying we did, and
|
|
1654
|
+
// published it — so the two disagreed permanently, and a retry would never
|
|
1655
|
+
// come because as far as they are concerned the question was answered.
|
|
1656
|
+
async onReject(activity, actor) {
|
|
1657
|
+
if (activity.object?.type && activity.object.type !== 'Follow') return;
|
|
1658
|
+
const contacts = this.store.getContacts();
|
|
1659
|
+
const rec = contacts.following.find(f => f.actor === actor);
|
|
1660
|
+
if (!rec) return; // nothing of ours to undo
|
|
1661
|
+
contacts.following = contacts.following.filter(f => f.actor !== actor);
|
|
1662
|
+
this.store.setContacts(contacts);
|
|
1663
|
+
await this.republish({ following: true, pending: true });
|
|
1664
|
+
this.log(`follow rejected by ${actor}`);
|
|
1665
|
+
}
|
|
1666
|
+
|
|
1667
|
+
// Someone we follow has moved. Their server will stop delivering from the old
|
|
1668
|
+
// actor, so without this we keep an entry that can never produce another post
|
|
1669
|
+
// and never learn where they went. The new account is not followed
|
|
1670
|
+
// automatically — that is a Follow only the owner should send — but it is
|
|
1671
|
+
// recorded and raised, so it can be acted on.
|
|
1672
|
+
async onMove(activity, actor) {
|
|
1673
|
+
const target = typeof activity.target === 'string' ? activity.target : activity.target?.id;
|
|
1674
|
+
if (!target) return 'Move without a target';
|
|
1675
|
+
const contacts = this.store.getContacts();
|
|
1676
|
+
const rec = contacts.following.find(f => f.actor === actor);
|
|
1677
|
+
if (!rec) return; // not someone we follow
|
|
1678
|
+
// Believed only if the actor we follow says so at its OWN origin: a Move is
|
|
1679
|
+
// otherwise a redirect anyone could Append.
|
|
1680
|
+
const doc = await this.fetchAP(actor);
|
|
1681
|
+
if (!doc) throw new Error(`cannot confirm ${actor} moved — will retry`);
|
|
1682
|
+
const movedTo = typeof doc.movedTo === 'string' ? doc.movedTo : doc.movedTo?.id;
|
|
1683
|
+
if (movedTo !== target) return `Move not corroborated by ${actor} (says ${movedTo || 'nothing'})`;
|
|
1684
|
+
rec.movedTo = target;
|
|
1685
|
+
this.store.setContacts(contacts);
|
|
1686
|
+
this.store.addNotification({ type: 'move', actor, target });
|
|
1687
|
+
this.log(`${actor} moved to ${target} — follow the new account to keep seeing them`);
|
|
1688
|
+
}
|
|
1689
|
+
|
|
1690
|
+
async onAccept(activity, actor) {
|
|
1691
|
+
const contacts = this.store.getContacts();
|
|
1692
|
+
const rec = contacts.following.find(f => f.actor === actor);
|
|
1693
|
+
// It has to answer the Follow we actually sent. followActor stores that
|
|
1694
|
+
// activity for the later Undo, so the id is here to compare against; without
|
|
1695
|
+
// the check any Accept from an actor we happen to follow flips the flag,
|
|
1696
|
+
// including one answering a Follow we never made.
|
|
1697
|
+
const named = typeof activity.object === 'string' ? activity.object : activity.object?.id;
|
|
1698
|
+
const ours = rec?.followActivity?.id;
|
|
1699
|
+
if (ours && named && named !== ours) {
|
|
1700
|
+
this.log(`Accept from ${actor} answers ${named}, not the follow we sent — ignored`);
|
|
1701
|
+
return;
|
|
1702
|
+
}
|
|
1703
|
+
if (rec && !rec.accepted) {
|
|
1704
|
+
rec.accepted = true;
|
|
1705
|
+
this.store.setContacts(contacts);
|
|
1706
|
+
await this.republish({ following: true, pending: true });
|
|
1707
|
+
this.log(`follow accepted by ${actor}`);
|
|
1708
|
+
}
|
|
1709
|
+
}
|
|
1710
|
+
}
|