fedipod-server 0.9.0 → 0.11.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/dist/claims.js +1 -1
- package/dist/handler.d.ts +1 -1
- package/dist/handler.js +35 -2
- package/dist/handler.jsonld +1 -1
- package/lib/admin.mjs +3 -3
- package/lib/c2s.mjs +27 -2
- package/lib/deliver.mjs +6 -0
- package/lib/embed.mjs +1 -0
- package/lib/export-collections.mjs +1 -1
- package/lib/front-core.mjs +272 -49
- package/lib/gateway-core.mjs +3 -7
- package/lib/httpsig.mjs +33 -2
- package/lib/import.mjs +1 -1
- package/lib/intake.mjs +357 -118
- package/lib/links.mjs +6 -32
- package/lib/mastoapi.mjs +449 -38
- package/lib/migrate.mjs +4 -18
- package/lib/pod/README.md +61 -0
- package/lib/pod/actor.mjs +96 -0
- package/lib/pod/collection.mjs +77 -0
- package/lib/pod/containers.mjs +136 -0
- package/lib/pod/discovery.mjs +46 -0
- package/lib/pod/featured.mjs +18 -0
- package/lib/pod/followers.mjs +24 -0
- package/lib/pod/following.mjs +10 -0
- package/lib/pod/http.mjs +74 -0
- package/lib/pod/inbox.mjs +144 -0
- package/lib/pod/links.mjs +35 -0
- package/lib/pod/media.mjs +27 -0
- package/lib/pod/notes.mjs +80 -0
- package/lib/pod/notifications.mjs +89 -0
- package/lib/pod/outbox.mjs +18 -0
- package/lib/pod/package.json +19 -0
- package/lib/pod/policy.mjs +58 -0
- package/lib/pod/private.mjs +19 -0
- package/lib/pod/root.mjs +63 -0
- package/lib/pod/state.mjs +39 -0
- package/lib/pod/transport.mjs +477 -0
- package/lib/pod/urls.mjs +71 -0
- package/lib/polls.mjs +105 -0
- package/lib/publisher.mjs +357 -188
- package/lib/remote.mjs +30 -395
- package/lib/social.mjs +20 -9
- package/lib/storage.mjs +2 -2
- package/lib/store.mjs +8 -1
- package/lib/tagfeed.mjs +19 -2
- package/lib/wire.mjs +87 -67
- package/package.json +1 -1
- package/run-agent.mjs +20 -39
- package/web/admin/admin.js +141 -74
- package/web/admin/bar.css +9 -13
- package/web/admin/bar.js +2 -2
- package/web/admin/client/client.js +1 -1
- package/web/admin/client/index.html +5 -6
- package/web/admin/index.html +53 -65
- package/web/admin/setup/index.html +2 -1
- package/web/admin/setup/setup.js +1 -1
- package/web/admin/tokens.css +56 -0
- package/web/app/README.md +77 -0
- package/web/app/admin-facade.mjs +549 -0
- package/web/app/agent.mjs +490 -0
- package/web/app/atproto-browser.mjs +86 -0
- package/web/app/boot.mjs +340 -0
- package/web/app/deliver-relay.mjs +72 -0
- package/web/app/dist/boot.js +34067 -0
- package/web/app/dist/boot.js.map +7 -0
- package/web/app/dist/sw.js +53745 -0
- package/web/app/dist/sw.js.map +7 -0
- package/web/app/fediacct-browser.mjs +146 -0
- package/web/app/idb-kv.mjs +35 -0
- package/web/app/index.html +203 -0
- package/web/app/index.html~ +152 -0
- package/web/app/keys-browser.mjs +46 -0
- package/web/app/keystore.mjs +99 -0
- package/web/app/oidc-session.mjs +189 -0
- package/web/app/pod-auth.mjs +248 -0
- package/web/app/pod-remote.mjs +84 -0
- package/web/app/shims/_globals.mjs +2 -0
- package/web/app/shims/fedify-sig.mjs +77 -0
- package/web/app/shims/node-crypto.mjs +113 -0
- package/web/app/shims/node-fs.mjs +17 -0
- package/web/app/shims/node-path.mjs +19 -0
- package/web/app/shims/node-url.mjs +7 -0
- package/web/app/shims/prelude.js +58 -0
- package/web/app/shims/safefetch.mjs +58 -0
- package/web/app/shims/web-push.mjs +14 -0
- package/web/app/signup.mjs +248 -0
- package/web/app/site/_headers +9 -0
- package/web/app/site/_redirects +21 -0
- package/web/app/site/admin/admin.js +1181 -0
- package/web/app/site/admin/bar.css +60 -0
- package/web/app/site/admin/bar.js +61 -0
- package/web/app/site/admin/client/client.js +66 -0
- package/web/app/site/admin/client/index.html +43 -0
- package/web/app/site/admin/index.html +556 -0
- package/web/app/site/admin/setup/index.html +191 -0
- package/web/app/site/admin/setup/setup.js +308 -0
- package/web/app/site/admin/tokens.css +56 -0
- package/web/app/site/admin/window.css +55 -0
- package/web/app/site/admin/window.js +136 -0
- package/web/app/site/app/404.html +40 -0
- package/web/app/site/app/_headers +2 -0
- package/web/app/site/app/apple-touch-icon.png +0 -0
- package/web/app/site/app/assets/ICONS-DIPJeU0C.js +2 -0
- package/web/app/site/app/assets/ICONS-DIPJeU0C.js.map +1 -0
- package/web/app/site/app/assets/ScheduledAtField-D-Abfo4J.js +183 -0
- package/web/app/site/app/assets/ScheduledAtField-D-Abfo4J.js.map +1 -0
- package/web/app/site/app/assets/Temml-CCjoacWt.woff2 +0 -0
- package/web/app/site/app/assets/__vite-browser-external-C7iut881.js +2 -0
- package/web/app/site/app/assets/__vite-browser-external-C7iut881.js.map +1 -0
- package/web/app/site/app/assets/boosts-carousel-BiOaNdMT.jpg +0 -0
- package/web/app/site/app/assets/catch-up-O5q5C75Z.png +0 -0
- package/web/app/site/app/assets/chunk-aKtaBQYM.js +1 -0
- package/web/app/site/app/assets/compose-CFEx7wb4.js +2 -0
- package/web/app/site/app/assets/compose-CFEx7wb4.js.map +1 -0
- package/web/app/site/app/assets/compose-ZJN5xSb4.js +35 -0
- package/web/app/site/app/assets/compose-ZJN5xSb4.js.map +1 -0
- package/web/app/site/app/assets/debug.module-YFQgwnIr.js +52 -0
- package/web/app/site/app/assets/debug.module-YFQgwnIr.js.map +1 -0
- package/web/app/site/app/assets/dom-p1YcJ5Rw.js +2 -0
- package/web/app/site/app/assets/dom-p1YcJ5Rw.js.map +1 -0
- package/web/app/site/app/assets/home-mobile-dark@2x-Dc-J8-SM.png +0 -0
- package/web/app/site/app/assets/home-mobile-light@2x-Bd3fod8m.png +0 -0
- package/web/app/site/app/assets/home-tablet-dark@2x-BgcNI8le.png +0 -0
- package/web/app/site/app/assets/home-tablet-light@2x-BW2RV9T6.png +0 -0
- package/web/app/site/app/assets/icons/add-circle-line-WD0mwORD.js +1 -0
- package/web/app/site/app/assets/icons/alert-line-CpFUMf9s.js +1 -0
- package/web/app/site/app/assets/icons/android-2-line-Bzxnrd3S.js +1 -0
- package/web/app/site/app/assets/icons/announcement-line-5nwaJFJm.js +1 -0
- package/web/app/site/app/assets/icons/arrow-down-circle-line-a7Id9-Iv.js +1 -0
- package/web/app/site/app/assets/icons/arrow-down-line-B8uzXzq6.js +1 -0
- package/web/app/site/app/assets/icons/arrow-left-line-CGrGT9C-.js +1 -0
- package/web/app/site/app/assets/icons/arrow-right-line-CE-HERfX.js +1 -0
- package/web/app/site/app/assets/icons/arrow-to-up-line-CvdQjmcy.js +1 -0
- package/web/app/site/app/assets/icons/arrow-up-circle-line-DEparSNF.js +1 -0
- package/web/app/site/app/assets/icons/arrow-up-line-C7yBeugc.js +1 -0
- package/web/app/site/app/assets/icons/arrows-right-line-BtbXmJGP.js +1 -0
- package/web/app/site/app/assets/icons/at-line-CF66Bznk.js +1 -0
- package/web/app/site/app/assets/icons/attachment-line-BY8It-l9.js +1 -0
- package/web/app/site/app/assets/icons/blockquote-line-BS6pU_hm.js +1 -0
- package/web/app/site/app/assets/icons/board-line-C0UWXyA6.js +1 -0
- package/web/app/site/app/assets/icons/bookmark-line-1CtX5JLF.js +1 -0
- package/web/app/site/app/assets/icons/building-5-line-Dd59MzbO.js +1 -0
- package/web/app/site/app/assets/icons/bus-2-line-6_dvA0GG.js +1 -0
- package/web/app/site/app/assets/icons/calendar-day-line-Crpl_fmu.js +1 -0
- package/web/app/site/app/assets/icons/calendar-month-line-vommts2C.js +1 -0
- package/web/app/site/app/assets/icons/calendar-time-add-line-BU_DTlQk.js +1 -0
- package/web/app/site/app/assets/icons/camera-line-Cv69MEei.js +1 -0
- package/web/app/site/app/assets/icons/celebrate-line-B8DvIoi0.js +1 -0
- package/web/app/site/app/assets/icons/chart-bar-line-FK-QOmtF.js +1 -0
- package/web/app/site/app/assets/icons/chart-line-line-BGcJAFWw.js +1 -0
- package/web/app/site/app/assets/icons/chat-3-line-YFK2-hwz.js +1 -0
- package/web/app/site/app/assets/icons/check-circle-line-DsAxFr5-.js +1 -0
- package/web/app/site/app/assets/icons/clipboard-line-BBp364gJ.js +1 -0
- package/web/app/site/app/assets/icons/close-circle-line-CICdiuGz.js +1 -0
- package/web/app/site/app/assets/icons/close-line-DYrFWcjv.js +1 -0
- package/web/app/site/app/assets/icons/cloud-line-tiQAbah8.js +1 -0
- package/web/app/site/app/assets/icons/code-line-fAl8iiV3.js +1 -0
- package/web/app/site/app/assets/icons/comment-2-line-CR3g7WeB.js +1 -0
- package/web/app/site/app/assets/icons/copy-2-line-CLW69yF5.js +1 -0
- package/web/app/site/app/assets/icons/delete-2-line-B8xmbr4n.js +1 -0
- package/web/app/site/app/assets/icons/document-3-line-DBGNhAg2.js +1 -0
- package/web/app/site/app/assets/icons/document-line-C7mb9sb7.js +1 -0
- package/web/app/site/app/assets/icons/down-line-BGqSSThd.js +1 -0
- package/web/app/site/app/assets/icons/edit-4-line-DcgGaEhx.js +1 -0
- package/web/app/site/app/assets/icons/emoji-2-line-D8YgnM-y.js +1 -0
- package/web/app/site/app/assets/icons/exit-line-Dc07BS-x.js +1 -0
- package/web/app/site/app/assets/icons/external-link-line-BvpSkmjO.js +1 -0
- package/web/app/site/app/assets/icons/eye-2-line-CiWXx-f_.js +1 -0
- package/web/app/site/app/assets/icons/eye-close-line-BgTaSJ4P.js +1 -0
- package/web/app/site/app/assets/icons/filter-2-line-B_mR2usy.js +1 -0
- package/web/app/site/app/assets/icons/filter-line-C_nm-3cU.js +1 -0
- package/web/app/site/app/assets/icons/flag-1-line-C2HB05I0.js +1 -0
- package/web/app/site/app/assets/icons/forbid-circle-line-D3LTT9yd.js +1 -0
- package/web/app/site/app/assets/icons/formula-line-DLrt2d6j.js +1 -0
- package/web/app/site/app/assets/icons/grid-line-CgUmEikf.js +1 -0
- package/web/app/site/app/assets/icons/group-line-C5fnX0wU.js +1 -0
- package/web/app/site/app/assets/icons/hand-finger-2-line-BGgxbuL9.js +1 -0
- package/web/app/site/app/assets/icons/hashtag-line-CyTEEXBb.js +1 -0
- package/web/app/site/app/assets/icons/heart-crack-line-BZddbO2C.js +1 -0
- package/web/app/site/app/assets/icons/heart-line-CJ7K47J4.js +1 -0
- package/web/app/site/app/assets/icons/history-2-line-kWWvFzqv.js +1 -0
- package/web/app/site/app/assets/icons/history-line-bGQiwbTH.js +1 -0
- package/web/app/site/app/assets/icons/home-3-line-fRU1zeLG.js +1 -0
- package/web/app/site/app/assets/icons/information-line-iNk6-sOY.js +1 -0
- package/web/app/site/app/assets/icons/keyboard-line-DsCyuQNz.js +1 -0
- package/web/app/site/app/assets/icons/layout-4-line-CZkOX52Z.js +1 -0
- package/web/app/site/app/assets/icons/layout-5-line-B_znt5wO.js +1 -0
- package/web/app/site/app/assets/icons/left-line-eUw0tya4.js +1 -0
- package/web/app/site/app/assets/icons/lightning-line-DaKoDdix.js +1 -0
- package/web/app/site/app/assets/icons/link-2-line-DCqLmMA7.js +1 -0
- package/web/app/site/app/assets/icons/list-check-line-BAxosBh9.js +1 -0
- package/web/app/site/app/assets/icons/lock-line-CPoaXeUW.js +1 -0
- package/web/app/site/app/assets/icons/mail-line-B0P8aa1L.js +1 -0
- package/web/app/site/app/assets/icons/moon-line-CEqNAToP.js +1 -0
- package/web/app/site/app/assets/icons/more-1-fill-Dw2e5xab.js +1 -0
- package/web/app/site/app/assets/icons/more-3-line-BIiVjYTz.js +1 -0
- package/web/app/site/app/assets/icons/notification-line-BZZq2Gtu.js +1 -0
- package/web/app/site/app/assets/icons/pencil-line-CKh8-A1v.js +1 -0
- package/web/app/site/app/assets/icons/photo-album-line-Bnfg2u40.js +1 -0
- package/web/app/site/app/assets/icons/pin-line-DpTolyhs.js +1 -0
- package/web/app/site/app/assets/icons/play-fill-B0-G_8lX.js +1 -0
- package/web/app/site/app/assets/icons/qrcode-2-line-B6SPCaHn.js +1 -0
- package/web/app/site/app/assets/icons/quill-pen-line-B6LUrnCP.js +1 -0
- package/web/app/site/app/assets/icons/quote-left-fill-hQZ1cj_6.js +1 -0
- package/web/app/site/app/assets/icons/radar-line-CKyroTth.js +1 -0
- package/web/app/site/app/assets/icons/react-line-CoIKhhtK.js +1 -0
- package/web/app/site/app/assets/icons/refresh-2-line-D0mTF97S.js +1 -0
- package/web/app/site/app/assets/icons/right-line-B3KWJaqc.js +1 -0
- package/web/app/site/app/assets/icons/rocket-line-4KZl537D.js +1 -0
- package/web/app/site/app/assets/icons/round-fill-C69NTrZ0.js +1 -0
- package/web/app/site/app/assets/icons/round-line-B9k_1uo-.js +1 -0
- package/web/app/site/app/assets/icons/route-line-B28ReHcE.js +1 -0
- package/web/app/site/app/assets/icons/rows-4-line-CrBniagM.js +1 -0
- package/web/app/site/app/assets/icons/scan-line-DSf74YdP.js +1 -0
- package/web/app/site/app/assets/icons/search-2-line-B6geIU1z.js +1 -0
- package/web/app/site/app/assets/icons/settings-3-line-azJwK47w.js +1 -0
- package/web/app/site/app/assets/icons/settings-6-line-C_5zc5YR.js +1 -0
- package/web/app/site/app/assets/icons/share-2-line-D5MxWjrm.js +1 -0
- package/web/app/site/app/assets/icons/share-forward-line-Cp61fZzZ.js +1 -0
- package/web/app/site/app/assets/icons/sparkles-2-line-DYYyXTaW.js +1 -0
- package/web/app/site/app/assets/icons/sparkles-line-C2hY1s6I.js +1 -0
- package/web/app/site/app/assets/icons/time-line-BF3eet1v.js +1 -0
- package/web/app/site/app/assets/icons/transfer-4-line-oisfvTBw.js +1 -0
- package/web/app/site/app/assets/icons/translate-line-BFWol2Ce.js +1 -0
- package/web/app/site/app/assets/icons/unlock-line-X_x5vwv3.js +1 -0
- package/web/app/site/app/assets/icons/upload-3-line-DgzwUJeW.js +1 -0
- package/web/app/site/app/assets/icons/user-4-line-BVSynZCp.js +1 -0
- package/web/app/site/app/assets/icons/user-add-2-line-BlGS5Q8q.js +1 -0
- package/web/app/site/app/assets/icons/user-add-line-BbXdHG4z.js +1 -0
- package/web/app/site/app/assets/icons/user-edit-line-BFAHOV0X.js +1 -0
- package/web/app/site/app/assets/icons/user-follow-line-BXux0wSd.js +1 -0
- package/web/app/site/app/assets/icons/user-star-line-DdYlpHLU.js +1 -0
- package/web/app/site/app/assets/icons/user-warning-line-Cj43j0_E.js +1 -0
- package/web/app/site/app/assets/icons/user-x-line-DIDgNlPH.js +1 -0
- package/web/app/site/app/assets/icons/volume-line-Bs390KQt.js +1 -0
- package/web/app/site/app/assets/icons/volume-mute-line-DzlzIqFZ.js +1 -0
- package/web/app/site/app/assets/icons/walk-line-yNXfB-md.js +1 -0
- package/web/app/site/app/assets/icons/world-2-line-CenkGIwj.js +1 -0
- package/web/app/site/app/assets/icons/zoom-in-line-CaENUphf.js +1 -0
- package/web/app/site/app/assets/icons/zoom-out-line-BDNk0EWo.js +1 -0
- package/web/app/site/app/assets/instances-BNPptnn-.json +333 -0
- package/web/app/site/app/assets/locales/ar-SA-CfheRbCu.js +1 -0
- package/web/app/site/app/assets/locales/ca-ES-DHRW86Tz.js +1 -0
- package/web/app/site/app/assets/locales/cs-CZ-CKoqEuA1.js +1 -0
- package/web/app/site/app/assets/locales/de-DE-ByRPMRFy.js +1 -0
- package/web/app/site/app/assets/locales/eo-UY-CPC7wLCS.js +1 -0
- package/web/app/site/app/assets/locales/es-ES-dJa0Fd9t.js +1 -0
- package/web/app/site/app/assets/locales/eu-ES-C3djlPR2.js +1 -0
- package/web/app/site/app/assets/locales/fa-IR-DTT490Ck.js +1 -0
- package/web/app/site/app/assets/locales/fi-FI-B23MCPuz.js +1 -0
- package/web/app/site/app/assets/locales/fr-FR-BcaQsO5C.js +1 -0
- package/web/app/site/app/assets/locales/gl-ES-BR-CVKBN.js +1 -0
- package/web/app/site/app/assets/locales/he-IL-CfheRbCu.js +1 -0
- package/web/app/site/app/assets/locales/hu-HU-CfheRbCu.js +1 -0
- package/web/app/site/app/assets/locales/it-IT-C1136UFV.js +1 -0
- package/web/app/site/app/assets/locales/ja-JP-CRZmRAhD.js +1 -0
- package/web/app/site/app/assets/locales/kab-Cz8xH_TT.js +1 -0
- package/web/app/site/app/assets/locales/ko-KR-CG9UiY4o.js +1 -0
- package/web/app/site/app/assets/locales/lt-LT-CJcI6sHZ.js +1 -0
- package/web/app/site/app/assets/locales/nb-NO-CfheRbCu.js +1 -0
- package/web/app/site/app/assets/locales/nl-NL-DxN4Yc1t.js +1 -0
- package/web/app/site/app/assets/locales/oc-FR-CfheRbCu.js +1 -0
- package/web/app/site/app/assets/locales/pl-PL-DkwGXf6P.js +1 -0
- package/web/app/site/app/assets/locales/pseudo-LOCALE-CQ4XxkQ0.js +1 -0
- package/web/app/site/app/assets/locales/pt-BR-nGL0HdFS.js +1 -0
- package/web/app/site/app/assets/locales/pt-PT-Cp17aj2z.js +1 -0
- package/web/app/site/app/assets/locales/ru-RU-0Y8cIoaK.js +1 -0
- package/web/app/site/app/assets/locales/th-TH-CfheRbCu.js +1 -0
- package/web/app/site/app/assets/locales/tok-CfheRbCu.js +1 -0
- package/web/app/site/app/assets/locales/tr-TR-CfheRbCu.js +1 -0
- package/web/app/site/app/assets/locales/uk-UA-C5E3dFBf.js +1 -0
- package/web/app/site/app/assets/locales/zh-CN-BHJi7_et.js +1 -0
- package/web/app/site/app/assets/locales/zh-TW-CfheRbCu.js +1 -0
- package/web/app/site/app/assets/main-BdqNbG-a.js +26 -0
- package/web/app/site/app/assets/main-BdqNbG-a.js.map +1 -0
- package/web/app/site/app/assets/mock-home-DGhR__He.js +2 -0
- package/web/app/site/app/assets/mock-home-DGhR__He.js.map +1 -0
- package/web/app/site/app/assets/multi-column-ROck0NVt.jpg +0 -0
- package/web/app/site/app/assets/multi-hashtag-timeline-DH5INVHi.jpg +0 -0
- package/web/app/site/app/assets/nested-comments-thread-hv59kZLc.jpg +0 -0
- package/web/app/site/app/assets/polyfill-force-H8sUkLGp.js +2 -0
- package/web/app/site/app/assets/polyfill-force-H8sUkLGp.js.map +1 -0
- package/web/app/site/app/assets/pwa-viewport-ogKbF51K.js +20 -0
- package/web/app/site/app/assets/pwa-viewport-ogKbF51K.js.map +1 -0
- package/web/app/site/app/assets/style-B1iLPTPR.css +2 -0
- package/web/app/site/app/assets/temml-DuyIQMei.js +97 -0
- package/web/app/site/app/assets/temml-DuyIQMei.js.map +1 -0
- package/web/app/site/app/assets/tinyld.light.node-DtbBhLYC.js +2 -0
- package/web/app/site/app/assets/tinyld.light.node-DtbBhLYC.js.map +1 -0
- package/web/app/site/app/assets/year-in-posts-B2lywqdx.png +0 -0
- package/web/app/site/app/assets/year-in-posts-DJv_star.js +3 -0
- package/web/app/site/app/assets/year-in-posts-DJv_star.js.map +1 -0
- package/web/app/site/app/compose/index.html +20 -0
- package/web/app/site/app/favicon.ico +0 -0
- package/web/app/site/app/index.html +73 -0
- package/web/app/site/app/logo-192.png +0 -0
- package/web/app/site/app/logo-512.png +0 -0
- package/web/app/site/app/logo-badge-72.png +0 -0
- package/web/app/site/app/logo-maskable-512.png +0 -0
- package/web/app/site/app/logo-monochrome-512.png +0 -0
- package/web/app/site/app/logo-monochrome-maskable-512.png +0 -0
- package/web/app/site/app/manifest.webmanifest +1 -0
- package/web/app/site/app/og-image-2.jpg +0 -0
- package/web/app/site/app/og-image.png +0 -0
- package/web/app/site/app/robots.txt +2 -0
- package/web/app/site/app/version.json +1 -0
- package/web/app/site/boot.js +34067 -0
- package/web/app/site/index.html +203 -0
- package/web/app/site/sw.js +53745 -0
- package/web/app/sw-src.mjs +205 -0
- package/web/front/#new-account.html# +43 -0
- package/web/front/admin.html +1 -81
- package/web/front/admin.js +85 -0
- package/web/front/new-account.html +1 -9
- package/web/front/new-account.js +13 -0
- package/web/front/run.html +1 -92
- package/web/front/run.js +107 -0
- package/lib/podrdf.mjs +0 -132
package/lib/intake.mjs
CHANGED
|
@@ -15,10 +15,14 @@
|
|
|
15
15
|
// a poll every POLL_MS as fallback, plus a drain at startup.
|
|
16
16
|
|
|
17
17
|
import * as $rdf from 'rdflib';
|
|
18
|
+
import * as podInbox from './pod/inbox.mjs';
|
|
19
|
+
import * as podNotifications from './pod/notifications.mjs';
|
|
20
|
+
import * as podNotes from './pod/notes.mjs';
|
|
18
21
|
import { USER_AGENT } from './ua.mjs';
|
|
19
22
|
import { PUBLIC } from './wire.mjs';
|
|
20
23
|
import { HTTP_TIMEOUT_MS, readCapped } from './safefetch.mjs';
|
|
21
24
|
import { linkTargets, REL } from './links.mjs';
|
|
25
|
+
import * as polls from './polls.mjs';
|
|
22
26
|
import { dropFollower } from './store.mjs';
|
|
23
27
|
|
|
24
28
|
const RDF = $rdf.Namespace('http://www.w3.org/1999/02/22-rdf-syntax-ns#');
|
|
@@ -95,10 +99,39 @@ const CONTENT_TYPES = new Set(['Note', 'Article', 'Question', 'Page', 'Video', '
|
|
|
95
99
|
// several), each carrying the tally its author's server maintains.
|
|
96
100
|
// Custom emojis ride the tag list; the images live at the author's server and
|
|
97
101
|
// the client fetches them from there.
|
|
102
|
+
// Content, name and summary were capped; nothing else was. One remote Question
|
|
103
|
+
// with thousands of options, or thousands of emoji, mention or attachment
|
|
104
|
+
// entries, put megabytes into a single statuses.json row — a document rewritten
|
|
105
|
+
// whole on every change. These are display lists: past a few dozen, nothing can
|
|
106
|
+
// render them and nobody meant them to be rendered.
|
|
107
|
+
// http(s) only. An emoji, attachment or mention URL is written straight into
|
|
108
|
+
// the client's markup, so `javascript:` and `data:` have no business in one.
|
|
109
|
+
// The store has guarded avatars this way all along (safeUrl, lib/store.mjs);
|
|
110
|
+
// these three lists were simply never put through it.
|
|
111
|
+
const httpOnly = (u) => {
|
|
112
|
+
if (!u) return null;
|
|
113
|
+
try {
|
|
114
|
+
const p = new URL(String(u));
|
|
115
|
+
return (p.protocol === 'https:' || p.protocol === 'http:') ? String(u) : null;
|
|
116
|
+
} catch { return null; }
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
const MAX_MODQUEUE = 200;
|
|
120
|
+
const MAX_EMOJIS = 60;
|
|
121
|
+
const MAX_POLL_OPTIONS = 50;
|
|
122
|
+
const MAX_OPTION_CHARS = 200;
|
|
123
|
+
const MAX_MENTIONS = 60;
|
|
124
|
+
const MAX_URL_CHARS = 2048;
|
|
125
|
+
|
|
98
126
|
function emojisOf(note) {
|
|
99
127
|
return [].concat(note?.tag || [])
|
|
100
128
|
.filter(t => t?.type === 'Emoji' && t.icon?.url && t.name)
|
|
101
|
-
.
|
|
129
|
+
.slice(0, MAX_EMOJIS)
|
|
130
|
+
.map(t => ({
|
|
131
|
+
shortcode: String(t.name).replace(/^:|:$/g, '').slice(0, 64),
|
|
132
|
+
url: httpOnly(String(t.icon.url).slice(0, MAX_URL_CHARS)),
|
|
133
|
+
}))
|
|
134
|
+
.filter(e => e.url);
|
|
102
135
|
}
|
|
103
136
|
|
|
104
137
|
function pollOf(note) {
|
|
@@ -108,8 +141,8 @@ function pollOf(note) {
|
|
|
108
141
|
multiple: !!note.anyOf,
|
|
109
142
|
expiresAt: note.endTime || null,
|
|
110
143
|
closed: !!note.closed,
|
|
111
|
-
options: opts.map(o => ({
|
|
112
|
-
title: String(o?.name ?? ''),
|
|
144
|
+
options: opts.slice(0, MAX_POLL_OPTIONS).map(o => ({
|
|
145
|
+
title: String(o?.name ?? '').slice(0, MAX_OPTION_CHARS),
|
|
113
146
|
votes: Number(o?.replies?.totalItems) || 0,
|
|
114
147
|
})),
|
|
115
148
|
};
|
|
@@ -127,8 +160,77 @@ const isActorType = (t) => typesOf(t).some(x => ACTOR_TYPES.has(x));
|
|
|
127
160
|
// stranger could have anything at all, of a type nothing here reads,
|
|
128
161
|
// re-delivered to every follower over our signature.
|
|
129
162
|
const FORWARDABLE = new Set(['Create', 'Update', 'Delete', 'Like', 'Announce', 'Undo']);
|
|
163
|
+
// Of those, the ones we may re-deliver to our own followers over our own
|
|
164
|
+
// signature (§7.1.2). Deliberately narrower than FORWARDABLE: these three are
|
|
165
|
+
// the ones whose object this drain fetched from the author's origin and checked
|
|
166
|
+
// before accepting. A Like, an Announce or an Undo is taken on the envelope's
|
|
167
|
+
// word alone — relaying one is signing for a claim nothing corroborated.
|
|
168
|
+
const FORWARD_TYPES = new Set(['Create', 'Update', 'Delete']);
|
|
169
|
+
// How many forwards one drain may send. A reply into a busy thread of ours is a
|
|
170
|
+
// handful; anything near this is a flood using us as an amplifier.
|
|
171
|
+
const MAX_FORWARDS_PER_DRAIN = 20;
|
|
130
172
|
const ACCEPT_AP = 'application/activity+json, application/ld+json; profile="https://www.w3.org/ns/activitystreams"';
|
|
131
173
|
|
|
174
|
+
// What is worth keeping of an activity we are filing rather than acting on.
|
|
175
|
+
//
|
|
176
|
+
// A dead letter, a moderation entry and a waiting follow request each used to
|
|
177
|
+
// hold the WHOLE delivered activity — up to the 512 KB item cap — in documents
|
|
178
|
+
// that are serialized and PUT whole on every change. A stranger could inflate
|
|
179
|
+
// deadletter.json to ~100 MB and requests.json to ~250 MB and make us rewrite
|
|
180
|
+
// them on every batch.
|
|
181
|
+
//
|
|
182
|
+
// Everything the consumers need survives: `acceptActivity` wraps this as the
|
|
183
|
+
// Accept's object (and {id, type, actor, object} IS the standard Follow shape),
|
|
184
|
+
// `applyModeration` reads only `object`, and a dead letter is read by a human
|
|
185
|
+
// who wants to know what arrived, not to replay it.
|
|
186
|
+
export function trimActivity(a) {
|
|
187
|
+
if (!a || typeof a !== 'object') return a ?? null;
|
|
188
|
+
const idOf = (v) => (typeof v === 'string' ? v : v?.id ?? null);
|
|
189
|
+
const out = {};
|
|
190
|
+
for (const k of ['id', 'type', 'actor', 'target']) {
|
|
191
|
+
const v = idOf(a[k]);
|
|
192
|
+
if (v) out[k] = String(v).slice(0, 2048);
|
|
193
|
+
}
|
|
194
|
+
const obj = idOf(a.object);
|
|
195
|
+
if (obj) out.object = String(obj).slice(0, 2048);
|
|
196
|
+
// A typed object with no id still says what it was — a Block of nobody, an
|
|
197
|
+
// Undo of a Follow — and that is the whole of what the queue reads.
|
|
198
|
+
else if (a.object && typeof a.object === 'object' && a.object.type) {
|
|
199
|
+
out.object = { type: String(a.object.type).slice(0, 64) };
|
|
200
|
+
}
|
|
201
|
+
return out;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Same origin AND, where the origin holds more than one identity, the same
|
|
205
|
+
// identity within it.
|
|
206
|
+
//
|
|
207
|
+
// Origin alone is the right test when an origin is one person — the ordinary
|
|
208
|
+
// fediverse server, and a subdomain pod. It is the WRONG test on a path-based
|
|
209
|
+
// host, and above all on a multi-tenant front: every tenant of fedipod.net has
|
|
210
|
+
// ids under `https://fedipod.net/u/<name>/`, so origin-equality made every
|
|
211
|
+
// tenant able to vouch for every other. One tenant could publish posts
|
|
212
|
+
// attributed to another, Update a neighbour's post, or Delete it — and a
|
|
213
|
+
// receiving Mastodon would believe it for the same reason we did.
|
|
214
|
+
//
|
|
215
|
+
// So when both ids carry an identity prefix, the prefixes must match too. The
|
|
216
|
+
// two shapes that exist here are the front's `/u/<name>/` and a pod's own AP
|
|
217
|
+
// root (`…/<root>/ap/…`); anything else has no prefix and falls back to origin,
|
|
218
|
+
// which is what a plain remote server should be judged by.
|
|
219
|
+
function identityPrefix(u) {
|
|
220
|
+
const m = /^(https?:\/\/[^/]+\/u\/[^/]+\/)/u.exec(u);
|
|
221
|
+
if (m) return m[1];
|
|
222
|
+
const ap = /^(https?:\/\/[^/]+\/(?:[^/]+\/)*?)ap\//u.exec(u);
|
|
223
|
+
return ap ? ap[1] : null;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
export function sameIdentity(a, b) {
|
|
227
|
+
if (!sameOrigin(a, b)) return false;
|
|
228
|
+
const pa = identityPrefix(String(a));
|
|
229
|
+
const pb = identityPrefix(String(b));
|
|
230
|
+
if (!pa || !pb) return true; // no prefix to compare: origin is the answer
|
|
231
|
+
return pa === pb;
|
|
232
|
+
}
|
|
233
|
+
|
|
132
234
|
export function sameOrigin(a, b) {
|
|
133
235
|
try { return new URL(a).origin === new URL(b).origin; } catch { return false; }
|
|
134
236
|
}
|
|
@@ -148,13 +250,21 @@ export function sameOrigin(a, b) {
|
|
|
148
250
|
// and two labels minimum so `.live` cannot pose as everyone's parent. Not a
|
|
149
251
|
// public-suffix list — that is a dependency and a data file to keep current,
|
|
150
252
|
// and the party this guards against is the pod you already chose to trust.
|
|
253
|
+
//
|
|
254
|
+
// `localhost` is the one single-label parent allowed, because it is the one that
|
|
255
|
+
// cannot be anybody else: it is reserved to the loopback interface (RFC 6761),
|
|
256
|
+
// so `alice.localhost` and `localhost` are the same machine by definition and
|
|
257
|
+
// there is no stranger for the rule to keep out. Without this a pod served from
|
|
258
|
+
// a subdomain of localhost — which is how a Solid server with subdomain pods
|
|
259
|
+
// runs on a developer's machine — refused its own socket and fell back to
|
|
260
|
+
// polling, so every delivery waited up to two minutes.
|
|
151
261
|
export function sameSocketOrigin(socketUrl, podBase) {
|
|
152
262
|
let s, p;
|
|
153
263
|
try { s = new URL(socketUrl); p = new URL(podBase); } catch { return false; }
|
|
154
264
|
if (s.protocol !== (p.protocol === 'https:' ? 'wss:' : 'ws:')) return false;
|
|
155
265
|
if (s.host === p.host) return true;
|
|
156
266
|
const parent = s.hostname.toLowerCase();
|
|
157
|
-
return parent.split('.').length >= 2
|
|
267
|
+
return (parent.split('.').length >= 2 || parent === 'localhost')
|
|
158
268
|
&& s.port === p.port
|
|
159
269
|
&& p.hostname.toLowerCase().endsWith('.' + parent);
|
|
160
270
|
}
|
|
@@ -185,16 +295,20 @@ export function authorOf(note, delivered = null) {
|
|
|
185
295
|
.map(a => (typeof a === 'string' ? a : a?.id)).find(Boolean) || null;
|
|
186
296
|
const author = claimed || delivered;
|
|
187
297
|
if (!author) return null;
|
|
188
|
-
|
|
298
|
+
// sameIdentity, not sameOrigin: on a multi-tenant front every tenant shares
|
|
299
|
+
// an origin, so origin-equality let any of them be credited with any other's
|
|
300
|
+
// post. See sameIdentity.
|
|
301
|
+
return sameIdentity(note?.id, author) ? author : null;
|
|
189
302
|
}
|
|
190
303
|
|
|
191
304
|
export class Intake {
|
|
192
|
-
constructor({ config, urls, remote,
|
|
193
|
-
Object.assign(this, { config, urls, remote,
|
|
305
|
+
constructor({ config, urls, remote, store, deliverer, publisher, log = console.log, lease = null, archive = null, push = true, pollSeconds = null }) {
|
|
306
|
+
Object.assign(this, { config, urls, remote, store, deliverer, publisher, log, lease, archive, push, pollSeconds });
|
|
194
307
|
this.serial = Date.now();
|
|
195
308
|
this.stopped = false;
|
|
196
309
|
// (attempt counts are kept in pod state — see _bumpAttempt)
|
|
197
310
|
this.lastDrain = null;
|
|
311
|
+
this._forwardBudget = MAX_FORWARDS_PER_DRAIN;
|
|
198
312
|
this.lastDrainAtMs = 0;
|
|
199
313
|
this.reconnectTries = 0;
|
|
200
314
|
this.drainCooldownUntil = 0;
|
|
@@ -314,16 +428,8 @@ export class Intake {
|
|
|
314
428
|
* pod that says nothing has always used.
|
|
315
429
|
*/
|
|
316
430
|
async _storageDescriptionUrl() {
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
method: 'HEAD',
|
|
320
|
-
headers: { 'user-agent': USER_AGENT },
|
|
321
|
-
signal: AbortSignal.timeout(HTTP_TIMEOUT_MS),
|
|
322
|
-
});
|
|
323
|
-
const [found] = linkTargets(head.headers.get('link'), REL.storageDescription, this.urls.base);
|
|
324
|
-
if (found) return found;
|
|
325
|
-
} catch { /* the well-known path below */ }
|
|
326
|
-
return this.urls.base + '.well-known/solid';
|
|
431
|
+
return podNotifications.storageDescriptionUrl(this.urls.base,
|
|
432
|
+
{ headers: { 'user-agent': USER_AGENT }, timeoutMs: HTTP_TIMEOUT_MS });
|
|
327
433
|
}
|
|
328
434
|
|
|
329
435
|
async _subscribeOnce() {
|
|
@@ -334,30 +440,16 @@ export class Intake {
|
|
|
334
440
|
return;
|
|
335
441
|
}
|
|
336
442
|
const descUrl = await this._storageDescriptionUrl();
|
|
337
|
-
const
|
|
338
|
-
headers: {
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
//
|
|
342
|
-
//
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
.map(n => n.value)
|
|
348
|
-
.find(Boolean)
|
|
349
|
-
|| g.each(null, NOTIFY('channelType'), NOTIFY('WebSocketChannel2023'), null)
|
|
350
|
-
.map(n => n.value).find(Boolean);
|
|
351
|
-
if (!channel) { this.wsState = 'unavailable'; this.log('no WebSocketChannel2023 service — polling only'); return; }
|
|
352
|
-
const sub = await this.remote.fetch(channel, {
|
|
353
|
-
method: 'POST',
|
|
354
|
-
headers: { 'content-type': 'application/ld+json' },
|
|
355
|
-
body: JSON.stringify({
|
|
356
|
-
'@context': ['https://www.w3.org/ns/solid/notification/v1'],
|
|
357
|
-
type: 'http://www.w3.org/ns/solid/notifications#WebSocketChannel2023',
|
|
358
|
-
topic: this.urls.inbox,
|
|
359
|
-
}),
|
|
360
|
-
});
|
|
443
|
+
const { channel, error } = await podNotifications.readWebSocketChannel(descUrl,
|
|
444
|
+
{ headers: { 'user-agent': USER_AGENT }, timeoutMs: HTTP_TIMEOUT_MS });
|
|
445
|
+
if (!channel) { this.wsState = 'unavailable'; this.log(`${error} — polling only`); return; }
|
|
446
|
+
// The topic is a POD resource, and it travels in the BODY — so the url map
|
|
447
|
+
// RemotePod applies to the request line never reaches it. A fronted
|
|
448
|
+
// identity's inbox url names the front, which the pod cannot grant read on,
|
|
449
|
+
// and the subscription came back 403. A no-op when unfronted.
|
|
450
|
+
const topic = this.urls.toPod ? this.urls.toPod(this.urls.inbox) : this.urls.inbox;
|
|
451
|
+
const sub = await podNotifications.subscribeToInbox(this.remote,
|
|
452
|
+
{ channelUrl: channel, podTopicUrl: topic });
|
|
361
453
|
const body = await readCapped(sub).then(JSON.parse).catch(() => null);
|
|
362
454
|
if (!body?.receiveFrom) {
|
|
363
455
|
this.wsState = `subscribe-failed-${sub.status}`;
|
|
@@ -439,6 +531,9 @@ export class Intake {
|
|
|
439
531
|
// invariant — commit() flushes whatever is pending either way — so a store
|
|
440
532
|
// that does not implement it behaves exactly as before.
|
|
441
533
|
this._inSweep = true;
|
|
534
|
+
// Fresh per sweep: the cap is on how much this drain may amplify, not a
|
|
535
|
+
// lifetime total (see _maybeForward).
|
|
536
|
+
this._forwardBudget = MAX_FORWARDS_PER_DRAIN;
|
|
442
537
|
this.store.hold?.();
|
|
443
538
|
this._draining = this._drainOnce().finally(async () => {
|
|
444
539
|
this._inSweep = false;
|
|
@@ -475,7 +570,7 @@ export class Intake {
|
|
|
475
570
|
async prune({ before, keepConcerning = false } = {}) {
|
|
476
571
|
const cutoff = Date.parse(before);
|
|
477
572
|
if (!Number.isFinite(cutoff)) throw new Error(`"${before}" is not a date`);
|
|
478
|
-
const all = await this.remote
|
|
573
|
+
const all = await podInbox.list(this.remote, this.urls);
|
|
479
574
|
const older = all.filter(e => !e.url.endsWith('.keep')
|
|
480
575
|
&& e.modified && Date.parse(e.modified) < cutoff);
|
|
481
576
|
const out = { considered: older.length, applied: 0, dropped: 0, discarded: 0, failed: 0 };
|
|
@@ -483,14 +578,14 @@ export class Intake {
|
|
|
483
578
|
for (const item of older) {
|
|
484
579
|
try {
|
|
485
580
|
if (item.size > MAX_ITEM_BYTES) {
|
|
486
|
-
await this.remote
|
|
581
|
+
await podInbox.dropHandledItem(this.remote, item.url); // unreadable by the drain either way
|
|
487
582
|
out.discarded++;
|
|
488
583
|
} else {
|
|
489
|
-
const res = await this.remote.fetch(item.url, { headers: { accept: '*/*' } });
|
|
490
584
|
// Same rule as the drain: a read we could not make is not a Create to
|
|
491
|
-
// be dropped.
|
|
492
|
-
|
|
493
|
-
const
|
|
585
|
+
// be dropped. readItem throws on anything but 404, so it counts as
|
|
586
|
+
// failed and stays for the next pass.
|
|
587
|
+
const got = await podInbox.readItem(this.remote, item.url, { maxBytes: MAX_ITEM_BYTES, readCapped });
|
|
588
|
+
const activity = got.raw === null ? null : (() => { try { return JSON.parse(got.raw); } catch { return null; } })();
|
|
494
589
|
// A Create is the content the owner just asked to be rid of. Anything
|
|
495
590
|
// else changes state and is applied exactly as a drain would.
|
|
496
591
|
if (keepConcerning) {
|
|
@@ -506,7 +601,7 @@ export class Intake {
|
|
|
506
601
|
this.log(`state not written — stopping the prune with ${older.length - out.applied - out.dropped - out.discarded} left`);
|
|
507
602
|
break;
|
|
508
603
|
}
|
|
509
|
-
await this.remote
|
|
604
|
+
await podInbox.dropHandledItem(this.remote, item.url);
|
|
510
605
|
}
|
|
511
606
|
this._clearAttempt(item.url);
|
|
512
607
|
await new Promise(r => setTimeout(r, DELETE_GAP_MS));
|
|
@@ -589,7 +684,7 @@ export class Intake {
|
|
|
589
684
|
this._pruneAttempts();
|
|
590
685
|
let all;
|
|
591
686
|
try {
|
|
592
|
-
all = await this.remote
|
|
687
|
+
all = await podInbox.list(this.remote, this.urls);
|
|
593
688
|
this.drainFailures = 0;
|
|
594
689
|
} catch (e) {
|
|
595
690
|
this._backOff(`inbox unreadable (${e.message})`);
|
|
@@ -631,7 +726,7 @@ export class Intake {
|
|
|
631
726
|
return false;
|
|
632
727
|
}
|
|
633
728
|
for (const url of pending.splice(0)) {
|
|
634
|
-
if (!await this.remote
|
|
729
|
+
if (!await podInbox.dropHandledItem(this.remote, url)) {
|
|
635
730
|
// Still in the mailbox. Handling is idempotent so seeing it again is
|
|
636
731
|
// harmless, but counting it would clear the attempt record and report
|
|
637
732
|
// progress that did not happen.
|
|
@@ -661,17 +756,14 @@ export class Intake {
|
|
|
661
756
|
}
|
|
662
757
|
let activity = null;
|
|
663
758
|
try {
|
|
664
|
-
const
|
|
665
|
-
//
|
|
666
|
-
//
|
|
667
|
-
//
|
|
668
|
-
//
|
|
669
|
-
//
|
|
670
|
-
//
|
|
671
|
-
|
|
672
|
-
// Capped rather than res.text(): the listing's size is advisory, and the
|
|
673
|
-
// inbox is public-Append, so the body is whatever a stranger chose.
|
|
674
|
-
const raw = res.status < 400 ? await readCapped(res, MAX_ITEM_BYTES) : null;
|
|
759
|
+
const got = await podInbox.readItem(this.remote, url, { maxBytes: MAX_ITEM_BYTES, readCapped });
|
|
760
|
+
// readItem carries the rule that matters here: a pod that would not
|
|
761
|
+
// GIVE us the item has told us nothing about it, so anything but a 404
|
|
762
|
+
// throws rather than reading as an empty body. Reading a 500 as empty
|
|
763
|
+
// made it "unparsable JSON" — a REJECTION, dead-lettered with both
|
|
764
|
+
// `activity` and `raw` null and then DELETEd, destroying a delivery on
|
|
765
|
+
// a transient fault with no record of what it had been.
|
|
766
|
+
const raw = got.raw;
|
|
675
767
|
try { activity = raw ? JSON.parse(raw) : null; } catch { /* kept raw for the dead letter */ }
|
|
676
768
|
// A gateway that verified this delivery left a receipt beside it. Read
|
|
677
769
|
// it only when a gateway is configured (no config → no fetch, so an
|
|
@@ -684,7 +776,7 @@ export class Intake {
|
|
|
684
776
|
if (!rejection) await this._maybeForward(activity); // §7.1.2, only what we accepted
|
|
685
777
|
if (rejection) {
|
|
686
778
|
this.store.addDeadLetter({
|
|
687
|
-
inboxUrl: url, reason: rejection, activity,
|
|
779
|
+
inboxUrl: url, reason: rejection, activity: trimActivity(activity),
|
|
688
780
|
...(activity ? {} : { raw: raw?.slice(0, 2000) ?? null }),
|
|
689
781
|
});
|
|
690
782
|
this.log(`rejected (${rejection}) — dead-lettered: ${url}`);
|
|
@@ -694,7 +786,7 @@ export class Intake {
|
|
|
694
786
|
const n = this._bumpAttempt(url, e.message);
|
|
695
787
|
this.log(`inbox item ${url} attempt ${n}/${MAX_ITEM_ATTEMPTS}: ${e.message}`);
|
|
696
788
|
if (n >= MAX_ITEM_ATTEMPTS) {
|
|
697
|
-
this.store.addDeadLetter({ inboxUrl: url, reason: `failed ${n}x: ${e.message}`, activity });
|
|
789
|
+
this.store.addDeadLetter({ inboxUrl: url, reason: `failed ${n}x: ${e.message}`, activity: trimActivity(activity) });
|
|
698
790
|
// The dead letter IS the record of this item — deleting before it is
|
|
699
791
|
// written down would lose the only evidence it ever arrived, so it
|
|
700
792
|
// goes through the same commit-then-delete batch as everything else.
|
|
@@ -759,6 +851,23 @@ export class Intake {
|
|
|
759
851
|
let doc = null;
|
|
760
852
|
try { doc = JSON.parse(await readCapped(res)); }
|
|
761
853
|
catch (e) { this.log(`fetch ${url}: unreadable as JSON — ${e.message}`); return null; }
|
|
854
|
+
// A document is only evidence about its OWN origin.
|
|
855
|
+
//
|
|
856
|
+
// signedFetch follows redirects and hands back only the final response, and
|
|
857
|
+
// every caller checks `doc.id` against the URL it ASKED for — so an open
|
|
858
|
+
// redirector on a victim's origin let an attacker's server answer for a
|
|
859
|
+
// victim's URL and be believed by that check.
|
|
860
|
+
//
|
|
861
|
+
// Only a redirect that CROSSED AN ORIGIN is refused here. A same-origin
|
|
862
|
+
// redirect is ordinary (canonicalisation, trailing slashes), and a document
|
|
863
|
+
// fetched with no redirect at all is still returned whatever it claims —
|
|
864
|
+
// the callers reject it on id, and they say something more useful about it
|
|
865
|
+
// than this could ("actor id mismatch", "object not verifiable content").
|
|
866
|
+
const landed = res.finalUrl || url;
|
|
867
|
+
if (!sameOrigin(landed, url) && doc && doc.id && !sameOrigin(doc.id, landed)) {
|
|
868
|
+
this.log(`fetch ${url}: redirected to ${landed}, which is not where ${doc.id} lives — refused`);
|
|
869
|
+
return null;
|
|
870
|
+
}
|
|
762
871
|
// Every actor type, not just Person. A Group was fetched, used and thrown
|
|
763
872
|
// away, so nothing knew its preferredUsername — and a client rendering it
|
|
764
873
|
// fell back to the last path segment of the actor URL, which is the literal
|
|
@@ -779,6 +888,8 @@ export class Intake {
|
|
|
779
888
|
}
|
|
780
889
|
|
|
781
890
|
sameOrigin(a, b) { return sameOrigin(a, b); }
|
|
891
|
+
// Overridable in tests the same way sameOrigin is.
|
|
892
|
+
sameIdentity(a, b) { return sameIdentity(a, b); }
|
|
782
893
|
|
|
783
894
|
// Have we ever heard of this actor or object? Answered entirely from local
|
|
784
895
|
// state, so asking costs nothing. It is what stops a stranger's Delete or
|
|
@@ -817,15 +928,26 @@ export class Intake {
|
|
|
817
928
|
const secret = this.gatewaySecret();
|
|
818
929
|
if (!secret) return null;
|
|
819
930
|
try {
|
|
820
|
-
const
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
const receipt = JSON.parse(await readCapped(res, 64 * 1024));
|
|
931
|
+
const { readCapped: cap } = await import('./safefetch.mjs');
|
|
932
|
+
const receipt = await podInbox.readDeliveryReceipt(this.remote, itemUrl, { maxBytes: 64 * 1024, readCapped: cap });
|
|
933
|
+
if (!receipt) return null;
|
|
824
934
|
const { verifyReceipt } = await import('./httpsig.mjs');
|
|
825
935
|
return verifyReceipt(receipt, secret) ? receipt : null;
|
|
826
936
|
} catch { return null; }
|
|
827
937
|
}
|
|
828
938
|
|
|
939
|
+
// Whether a receipt says anything about THIS actor. Verified-and-about-someone
|
|
940
|
+
// -else is worth exactly as much as unverified, and is treated the same way:
|
|
941
|
+
// the drain's verify-by-dereference still stands behind it.
|
|
942
|
+
receiptVouchesFor(receipt, actor) {
|
|
943
|
+
if (!receipt?.verified) return false;
|
|
944
|
+
if (!receipt.actor || receipt.actor !== actor) return false;
|
|
945
|
+
// A missing keyId with verified:true cannot come from our own door
|
|
946
|
+
// (makeReceipt fills both from the same key), so refuse rather than waive.
|
|
947
|
+
if (!receipt.keyId || !sameOrigin(receipt.keyId, actor)) return false;
|
|
948
|
+
return true;
|
|
949
|
+
}
|
|
950
|
+
|
|
829
951
|
async handle(activity, receipt = null) { // eslint-disable-line no-unused-vars
|
|
830
952
|
const actor = typeof activity.actor === 'string' ? activity.actor : activity.actor?.id;
|
|
831
953
|
if (!actor) return 'no actor';
|
|
@@ -838,6 +960,12 @@ export class Intake {
|
|
|
838
960
|
if (!httpUrl(actor)) return `actor is not an http(s) URL (${actor})`;
|
|
839
961
|
if (this.store.isBlocked(actor)) return `blocked sender (${actor})`;
|
|
840
962
|
|
|
963
|
+
// Whether the door vouched for this sender — read by the moderation queue
|
|
964
|
+
// just below as well as by the arms further down, so it is settled here,
|
|
965
|
+
// before its first use.
|
|
966
|
+
const trusted = this.receiptVouchesFor(receipt, actor)
|
|
967
|
+
&& this.store.getConfig()?.gateway?.mode === 'trust';
|
|
968
|
+
|
|
841
969
|
// FEP-1b12 moderation from a LISTED moderator. A delivery proves nothing
|
|
842
970
|
// about its sender, which is why these are QUEUED for the operator rather
|
|
843
971
|
// than run on arrival — the queue is where a claimed moderator's word
|
|
@@ -846,18 +974,26 @@ export class Intake {
|
|
|
846
974
|
if (this.config.kind === 'group'
|
|
847
975
|
&& (this.config.moderators || []).includes(actor)
|
|
848
976
|
&& this.isModerationAsk(activity)) {
|
|
849
|
-
return this.queueModeration(activity, actor);
|
|
977
|
+
return this.queueModeration(activity, actor, { trusted });
|
|
850
978
|
}
|
|
851
979
|
|
|
852
|
-
//
|
|
853
|
-
//
|
|
854
|
-
|
|
980
|
+
// `trusted` was settled above, before the moderation queue reads it. Why it
|
|
981
|
+
// is not simply `receipt.verified`: that says the door checked a signature
|
|
982
|
+
// and the signature was good; it does NOT say whose. The door reports the
|
|
983
|
+
// signing key's owner separately, in `receipt.actor`, and nothing here used
|
|
984
|
+
// to read it — so ANY valid fediverse signing key, over an activity whose
|
|
985
|
+
// `actor` field named someone else entirely, arrived as trusted. In trust
|
|
986
|
+
// mode that is one delivery to evict any of your followers, or to have a
|
|
987
|
+
// Follow naming a third party auto-accepted. See receiptVouchesFor, which
|
|
988
|
+
// also holds the keyId to the actor's origin: a key document is fetched
|
|
989
|
+
// from wherever its id points, so one hosted elsewhere that merely CLAIMS
|
|
990
|
+
// `owner: <you>` would otherwise bind.
|
|
855
991
|
|
|
856
992
|
switch (activity.type) {
|
|
857
993
|
case 'Follow': return this.onFollow(activity, actor, { trusted });
|
|
858
994
|
case 'Undo': return this.onUndo(activity, actor, { trusted });
|
|
859
995
|
case 'Create': return this.onCreate(activity, actor);
|
|
860
|
-
case 'Accept': return this.onAccept(activity, actor);
|
|
996
|
+
case 'Accept': return this.onAccept(activity, actor, { trusted });
|
|
861
997
|
case 'Like': case 'Announce': {
|
|
862
998
|
// FEP-1b12: a group Announces the member's whole Create, not the note.
|
|
863
999
|
// Without unwrapping we try to ingest a Create as if it were a Note and
|
|
@@ -891,7 +1027,7 @@ export class Intake {
|
|
|
891
1027
|
}
|
|
892
1028
|
case 'Delete': return this.onDelete(activity, actor);
|
|
893
1029
|
case 'Update': return this.onUpdate(activity, actor);
|
|
894
|
-
case 'Reject': return this.onReject(activity, actor);
|
|
1030
|
+
case 'Reject': return this.onReject(activity, actor, { trusted });
|
|
895
1031
|
case 'Move': return this.onMove(activity, actor);
|
|
896
1032
|
case 'Add': case 'Remove': return this.onAddRemove(activity, actor);
|
|
897
1033
|
default: this.log(`ignored ${activity.type} from ${actor}`);
|
|
@@ -923,17 +1059,42 @@ export class Intake {
|
|
|
923
1059
|
|
|
924
1060
|
// Held, not run: one entry per distinct ask, capped, waiting for the
|
|
925
1061
|
// operator to apply or dismiss it (social.applyModeration).
|
|
926
|
-
|
|
1062
|
+
// A moderator's WORD, not their proof. `actor` is a field in an unsigned
|
|
1063
|
+
// body and a moderator's URL is public, so anyone can claim to be one — which
|
|
1064
|
+
// is exactly why these are QUEUED for the operator rather than run. What was
|
|
1065
|
+
// missing is that the queue did not say which is which, and a stranger could
|
|
1066
|
+
// fill all 200 slots and push the real asks out.
|
|
1067
|
+
//
|
|
1068
|
+
// So: the entry records whether the door vouched for the sender, and when the
|
|
1069
|
+
// queue is full the UNVERIFIED entries are what get dropped. A real
|
|
1070
|
+
// moderator's ask cannot be crowded out by someone impersonating them.
|
|
1071
|
+
queueModeration(activity, actor, { trusted = false } = {}) {
|
|
927
1072
|
const q = this.store.read('modqueue.json', []);
|
|
928
1073
|
const objectId = typeof activity.object === 'string' ? activity.object : activity.object?.id;
|
|
929
1074
|
const key = [activity.type, actor, objectId || JSON.stringify(activity.object || null)].join(' ');
|
|
930
|
-
|
|
1075
|
+
const seen = q.find(e => e.key === key);
|
|
1076
|
+
if (seen) {
|
|
1077
|
+
// The same ask arriving verified is worth more than the copy we hold.
|
|
1078
|
+
if (trusted && !seen.verified) {
|
|
1079
|
+
seen.verified = true;
|
|
1080
|
+
this.store.write('modqueue.json', q);
|
|
1081
|
+
}
|
|
1082
|
+
return;
|
|
1083
|
+
}
|
|
931
1084
|
q.unshift({
|
|
932
1085
|
key, id: (this.serial++).toString(36) + '-' + q.length,
|
|
933
|
-
type: activity.type, moderator: actor, activity
|
|
1086
|
+
type: activity.type, moderator: actor, activity: trimActivity(activity),
|
|
1087
|
+
verified: !!trusted, at: new Date().toISOString(),
|
|
934
1088
|
});
|
|
935
|
-
|
|
936
|
-
|
|
1089
|
+
let kept = q;
|
|
1090
|
+
if (kept.length > MAX_MODQUEUE) {
|
|
1091
|
+
const verified = kept.filter(e => e.verified);
|
|
1092
|
+
const rest = kept.filter(e => !e.verified);
|
|
1093
|
+
// Verified first, then the newest unverified up to the cap.
|
|
1094
|
+
kept = [...verified, ...rest].slice(0, MAX_MODQUEUE);
|
|
1095
|
+
}
|
|
1096
|
+
this.store.write('modqueue.json', kept);
|
|
1097
|
+
this.log(`moderation queued from ${actor}${trusted ? '' : ' (unverified)'}: ${activity.type} ${objectId || ''}`);
|
|
937
1098
|
}
|
|
938
1099
|
|
|
939
1100
|
// §7.6 Add / §7.9 Remove. The side effect would be to add or remove the object
|
|
@@ -986,7 +1147,7 @@ export class Intake {
|
|
|
986
1147
|
if (!reqs.some(r => r.actor === actor)) {
|
|
987
1148
|
reqs.unshift({
|
|
988
1149
|
actor, inbox: doc.inbox, sharedInbox: doc.endpoints?.sharedInbox,
|
|
989
|
-
activity, at: new Date().toISOString(),
|
|
1150
|
+
activity: trimActivity(activity), at: new Date().toISOString(),
|
|
990
1151
|
});
|
|
991
1152
|
this.store.setRequests(reqs.slice(0, 500));
|
|
992
1153
|
this.store.addNotification({ type: 'follow-request', actor });
|
|
@@ -1062,7 +1223,18 @@ export class Intake {
|
|
|
1062
1223
|
// operator's queue forever asking about someone who left.
|
|
1063
1224
|
if (!rec) {
|
|
1064
1225
|
const reqs = this.store.getRequests();
|
|
1065
|
-
|
|
1226
|
+
const pending = reqs.find(r => r.actor === actor);
|
|
1227
|
+
if (pending) {
|
|
1228
|
+
// Bound the same way the follower eviction below is, and for the same
|
|
1229
|
+
// reason: `actor` is a field in an unsigned body, so without a match
|
|
1230
|
+
// anyone could withdraw anyone else's waiting request. The victim's
|
|
1231
|
+
// server believes the Follow is still pending and never resends, so
|
|
1232
|
+
// they simply never get followed and nobody sees why.
|
|
1233
|
+
const theirs = pending.activity?.id;
|
|
1234
|
+
if (!trusted && (!theirs || named !== theirs)) {
|
|
1235
|
+
this.log(`Undo from ${actor} does not name the request we hold — ignored`);
|
|
1236
|
+
return;
|
|
1237
|
+
}
|
|
1066
1238
|
this.store.setRequests(reqs.filter(r => r.actor !== actor));
|
|
1067
1239
|
await this.republish({ pending: true });
|
|
1068
1240
|
this.log(`join request withdrawn: ${actor}`);
|
|
@@ -1155,6 +1327,21 @@ export class Intake {
|
|
|
1155
1327
|
async _maybeForward(activity) {
|
|
1156
1328
|
if (!activity || typeof activity !== 'object') return;
|
|
1157
1329
|
if (!FORWARDABLE.has(activity.type)) return; // see FORWARDABLE
|
|
1330
|
+
// Forwarding signs somebody else's activity with OUR key and pushes it to
|
|
1331
|
+
// every follower we have. That is a lot to do on the word of an unsigned
|
|
1332
|
+
// POST, so it is narrowed three ways:
|
|
1333
|
+
//
|
|
1334
|
+
// 1. Only the types whose object this drain actually DEREFERENCED at the
|
|
1335
|
+
// author's own origin. A Create/Update/Delete went through onCreate /
|
|
1336
|
+
// onUpdate / onDelete, which fetch and check the author; a Like, an
|
|
1337
|
+
// Announce or an Undo is believed on the envelope alone, so relaying
|
|
1338
|
+
// one made us a signed relay for anything a stranger cared to write.
|
|
1339
|
+
// 2. The actor has to be someone we already know of. A complete stranger
|
|
1340
|
+
// addressing our followers collection is not a conversation we are
|
|
1341
|
+
// party to.
|
|
1342
|
+
// 3. A budget per drain, so a flood cannot turn one sweep into thousands
|
|
1343
|
+
// of outbound deliveries under our signature.
|
|
1344
|
+
if (!FORWARD_TYPES.has(activity.type)) return;
|
|
1158
1345
|
try {
|
|
1159
1346
|
const audience = []
|
|
1160
1347
|
.concat(activity.to || [], activity.cc || [], activity.audience || [])
|
|
@@ -1163,6 +1350,14 @@ export class Intake {
|
|
|
1163
1350
|
if (!this._referencesOurObject(activity)) return; // not into a thread of ours
|
|
1164
1351
|
const actor = typeof activity.actor === 'string' ? activity.actor : activity.actor?.id;
|
|
1165
1352
|
if (actor === this.urls.actor) return; // our own; nothing to forward
|
|
1353
|
+
if (!this.known(actor)) {
|
|
1354
|
+
this.log(`not forwarding ${activity.type} from ${actor}: nobody we know of`);
|
|
1355
|
+
return;
|
|
1356
|
+
}
|
|
1357
|
+
if (this._forwardBudget <= 0) {
|
|
1358
|
+
this.log(`not forwarding ${activity.type}: this drain's forwarding budget is spent`);
|
|
1359
|
+
return;
|
|
1360
|
+
}
|
|
1166
1361
|
const id = typeof activity.id === 'string' ? activity.id : null;
|
|
1167
1362
|
if (!id) return;
|
|
1168
1363
|
const forwarded = this.store.read('forwarded.json', []);
|
|
@@ -1173,6 +1368,7 @@ export class Intake {
|
|
|
1173
1368
|
.map(f => f.sharedInbox || f.inbox)
|
|
1174
1369
|
.filter(Boolean))];
|
|
1175
1370
|
if (!inboxes.length) return;
|
|
1371
|
+
this._forwardBudget -= 1;
|
|
1176
1372
|
await this.deliverer.deliverToAll(inboxes, activity);
|
|
1177
1373
|
this.store.write('forwarded.json', [...forwarded, id].slice(-MAX_FORWARDED));
|
|
1178
1374
|
this.log(`forwarded ${activity.type} ${id} to ${inboxes.length} follower inbox(es)`);
|
|
@@ -1200,7 +1396,7 @@ export class Intake {
|
|
|
1200
1396
|
const objectId = typeof activity.object === 'string' ? activity.object : activity.object?.id;
|
|
1201
1397
|
if (!objectId) return 'Create without object id';
|
|
1202
1398
|
if (this.store.isBlocked(objectId)) return `blocked domain (${objectId})`;
|
|
1203
|
-
if (!this.
|
|
1399
|
+
if (!this.sameIdentity(objectId, actor)) return `object/actor identity mismatch (${objectId})`;
|
|
1204
1400
|
// The delivered copy is untrusted for CONTENT, but its addressing is
|
|
1205
1401
|
// enough to decide whether to bother fetching the origin's copy.
|
|
1206
1402
|
const envelope = typeof activity.object === 'object' ? { ...activity, ...activity.object } : activity;
|
|
@@ -1290,8 +1486,22 @@ export class Intake {
|
|
|
1290
1486
|
// Wrap the member's own activity when we have it; a bare note URL is the
|
|
1291
1487
|
// fallback, and renders as a plain boost rather than a group carry. The
|
|
1292
1488
|
// group names itself as the audience (FEP-1b12).
|
|
1489
|
+
//
|
|
1490
|
+
// `activity` is the envelope as DELIVERED — a document the sender wrote,
|
|
1491
|
+
// which the group would otherwise re-sign and hand to every follower with
|
|
1492
|
+
// whatever addressing, tags and object body it carried. Only the note id was
|
|
1493
|
+
// ever verified (ingestNote fetched it from the author's origin and checked
|
|
1494
|
+
// the attribution), so only the note id is safe to pass on: send the bare
|
|
1495
|
+
// id unless the wrapper's own object id agrees with what we verified.
|
|
1496
|
+
const wrapperObject = (a) => {
|
|
1497
|
+
const inner = a?.object;
|
|
1498
|
+
const id = typeof inner === 'string' ? inner : inner?.id;
|
|
1499
|
+
return id === noteId ? a : null;
|
|
1500
|
+
};
|
|
1293
1501
|
const act = announceActivity({
|
|
1294
|
-
urls: this.urls,
|
|
1502
|
+
urls: this.urls,
|
|
1503
|
+
object: wrapperObject(activity) || wrapperObject(held?.activity) || noteId,
|
|
1504
|
+
serial: this.serial++,
|
|
1295
1505
|
audience: this.urls.actor,
|
|
1296
1506
|
});
|
|
1297
1507
|
await this.deliverer.deliverToAll(inboxes, act);
|
|
@@ -1426,6 +1636,36 @@ export class Intake {
|
|
|
1426
1636
|
// somebody else's boost, or through a hashtag feed.
|
|
1427
1637
|
if (this.store.isBlocked(author)) return `blocked author (${author})`;
|
|
1428
1638
|
|
|
1639
|
+
// The ENVELOPE said this concerns us. The envelope is a document a stranger
|
|
1640
|
+
// wrote: onCreate reads addressing off the delivered copy to decide whether
|
|
1641
|
+
// fetching is worth it, and nothing re-asked the question of the copy that
|
|
1642
|
+
// came back from the author's own server. So anyone could take any public
|
|
1643
|
+
// post, address the delivery to us, and have it filed as "X mentioned you"
|
|
1644
|
+
// — or, to a group, have a member's post carried to every follower when the
|
|
1645
|
+
// member never sent it there.
|
|
1646
|
+
//
|
|
1647
|
+
// A boost is exempt (`via`): someone we follow deliberately putting a post
|
|
1648
|
+
// in front of us is the whole point, and the note will not address us.
|
|
1649
|
+
if (!via && !this.concernsUs(note, author)) {
|
|
1650
|
+
return `the note its own server serves does not address us (${objectId})`;
|
|
1651
|
+
}
|
|
1652
|
+
|
|
1653
|
+
// An answer to one of our polls is a number on a document, not a post. It
|
|
1654
|
+
// arrives as an ordinary reply naming an option and carrying nothing else,
|
|
1655
|
+
// so filing it as one would put a blank entry in the thread and ring the
|
|
1656
|
+
// owner once per voter. Counted or refused — a second answer, an option we
|
|
1657
|
+
// do not offer, a poll already shut — it stops here either way.
|
|
1658
|
+
const asked = note.inReplyTo && this.store.getStatuses()
|
|
1659
|
+
.find(x => x.noteId === String(note.inReplyTo) && x.kind === 'post' && x.poll);
|
|
1660
|
+
if (asked && polls.isVoteShape(note)) {
|
|
1661
|
+
const counted = await this.publisher.recordVote(asked.noteId, author, note.name)
|
|
1662
|
+
.catch(e => { this.log(`vote on ${asked.noteId}: ${e.message}`); return false; });
|
|
1663
|
+
this.log(counted
|
|
1664
|
+
? `vote counted (${note.name}): ${asked.noteId}`
|
|
1665
|
+
: `vote not counted (${note.name}) from ${author}: ${asked.noteId}`);
|
|
1666
|
+
return;
|
|
1667
|
+
}
|
|
1668
|
+
|
|
1429
1669
|
// Anyone can Append to a public inbox, so arriving is not the same as
|
|
1430
1670
|
// belonging in the home timeline. Follow Mastodon's split: people you
|
|
1431
1671
|
// follow (and their boosts) are HOME; anyone else is a MENTION — kept,
|
|
@@ -1445,24 +1685,13 @@ export class Intake {
|
|
|
1445
1685
|
const followed = via || known || (!known && await this.isCoMember(author));
|
|
1446
1686
|
const kind = followed ? 'timeline' : 'mention';
|
|
1447
1687
|
|
|
1448
|
-
// `published` comes from a document at someone else's origin, and its first
|
|
1449
|
-
// ten characters become the leading component of a storage path. A date is
|
|
1450
|
-
// a date or it is not used: a value carrying `../` would otherwise be a
|
|
1451
|
-
// remote party choosing where we write.
|
|
1452
|
-
const day = String(note.published || '').slice(0, 10);
|
|
1453
|
-
const slug = (/^\d{4}-\d{2}-\d{2}$/.test(day) ? day : new Date().toISOString().slice(0, 10)) + '-' +
|
|
1454
|
-
(await import('node:crypto')).createHash('sha256').update(note.id).digest('hex').slice(0, 8);
|
|
1455
|
-
if (kind === 'timeline') {
|
|
1456
|
-
await this.local.writeNote('timeline', slug, {
|
|
1457
|
-
noteId: note.id, actor: author, published: note.published, content,
|
|
1458
|
-
inReplyTo: note.inReplyTo, attachments,
|
|
1459
|
-
});
|
|
1460
|
-
}
|
|
1461
1688
|
// Mastodon carries a thread's mentions into every reply, which is the only
|
|
1462
1689
|
// reason a reply ever reaches a group. Keep them so our composer can too.
|
|
1463
1690
|
const mentions = [].concat(note.tag || [])
|
|
1464
1691
|
.filter(t => t?.type === 'Mention' && t.href && t.name)
|
|
1465
|
-
.
|
|
1692
|
+
.slice(0, MAX_MENTIONS)
|
|
1693
|
+
.map(t => ({ href: httpOnly(String(t.href).slice(0, MAX_URL_CHARS)), name: String(t.name).slice(0, 256) }))
|
|
1694
|
+
.filter(m => m.href);
|
|
1466
1695
|
const emojis = emojisOf(note);
|
|
1467
1696
|
const poll = pollOf(note);
|
|
1468
1697
|
// Explicitly addressed, but to nobody public and to no followers
|
|
@@ -1485,7 +1714,6 @@ export class Intake {
|
|
|
1485
1714
|
...(poll ? { poll } : {}),
|
|
1486
1715
|
...(emojis.length ? { emojis } : {}),
|
|
1487
1716
|
...(mentions.length ? { mentions } : {}),
|
|
1488
|
-
...(kind === 'timeline' ? { slug } : {}),
|
|
1489
1717
|
...(attachments.length ? { attachments } : {}),
|
|
1490
1718
|
...(via ? { via } : {}),
|
|
1491
1719
|
});
|
|
@@ -1524,7 +1752,7 @@ export class Intake {
|
|
|
1524
1752
|
async onDelete(activity, actor) {
|
|
1525
1753
|
const objectId = typeof activity.object === 'string' ? activity.object : activity.object?.id;
|
|
1526
1754
|
if (!objectId) return 'Delete without object id';
|
|
1527
|
-
if (!this.
|
|
1755
|
+
if (!this.sameIdentity(objectId, actor)) return `Delete crosses identities (${objectId})`;
|
|
1528
1756
|
// `objectId === actor` is NOT evidence we care: it is true of EVERY account
|
|
1529
1757
|
// deletion, and Mastodon broadcasts those constantly. Taking it as known
|
|
1530
1758
|
// meant a signed dereference to a stranger's server for each one.
|
|
@@ -1570,13 +1798,6 @@ export class Intake {
|
|
|
1570
1798
|
if (s.announceActivity) {
|
|
1571
1799
|
await this.retract(s.noteId, { collect }).catch(e => this.log(`retract: ${e.message}`));
|
|
1572
1800
|
}
|
|
1573
|
-
if (s.slug) {
|
|
1574
|
-
// Said out loud rather than swallowed: the status row is about to go, so
|
|
1575
|
-
// a failure here leaves the note document in the private tree with
|
|
1576
|
-
// nothing indexing it, and no other record that it is there.
|
|
1577
|
-
await this.local.delete(this.local.fedi + 'timeline/' + s.slug)
|
|
1578
|
-
.catch(e => this.log(`forget ${s.noteId}: its RDF note could NOT be removed (${e.message})`));
|
|
1579
|
-
}
|
|
1580
1801
|
this.store.removeStatus(s.noteId);
|
|
1581
1802
|
}
|
|
1582
1803
|
|
|
@@ -1607,7 +1828,7 @@ export class Intake {
|
|
|
1607
1828
|
async onUpdate(activity, actor) {
|
|
1608
1829
|
const objectId = typeof activity.object === 'string' ? activity.object : activity.object?.id;
|
|
1609
1830
|
if (!objectId) return 'Update without object id';
|
|
1610
|
-
if (!this.
|
|
1831
|
+
if (!this.sameIdentity(objectId, actor)) return `Update crosses identities (${objectId})`;
|
|
1611
1832
|
if (objectId === actor) { // display name, avatar, bio
|
|
1612
1833
|
// The same guard onDelete has, for the same reason: the inbox is
|
|
1613
1834
|
// public-Append, so without it anyone can name any host and make us spend
|
|
@@ -1642,12 +1863,6 @@ export class Intake {
|
|
|
1642
1863
|
poll: { ...freshPoll, voted: !!s.poll?.voted, ownVotes: s.poll?.ownVotes || [] },
|
|
1643
1864
|
} : {}),
|
|
1644
1865
|
});
|
|
1645
|
-
if (s.slug) {
|
|
1646
|
-
await this.local.writeNote('timeline', s.slug, {
|
|
1647
|
-
noteId: note.id, actor: s.actor, published: note.published, content,
|
|
1648
|
-
inReplyTo: note.inReplyTo, attachments,
|
|
1649
|
-
}).catch(e => this.log(`rewrite ${s.slug}: ${e.message}`));
|
|
1650
|
-
}
|
|
1651
1866
|
this.log(`edited upstream: ${objectId}`);
|
|
1652
1867
|
}
|
|
1653
1868
|
|
|
@@ -1673,11 +1888,11 @@ export class Intake {
|
|
|
1673
1888
|
// collection, and rewriting on top of one erases every reply already
|
|
1674
1889
|
// recorded. getJson returns null only for a genuine 404 — the document does
|
|
1675
1890
|
// not exist yet — and throws otherwise, which the caller logs and retries.
|
|
1676
|
-
const cur = await this.remote
|
|
1891
|
+
const cur = await podNotes.readReplies(this.remote, url);
|
|
1677
1892
|
const items = Array.isArray(cur?.items) ? cur.items : [];
|
|
1678
1893
|
if (items.includes(replyId)) return;
|
|
1679
1894
|
items.push(replyId);
|
|
1680
|
-
await this.remote
|
|
1895
|
+
await podNotes.writeReplies(this.remote, url, collection(url, items.slice(-MAX_REPLIES_RECORDED)));
|
|
1681
1896
|
this.log(`reply recorded on ${parentId}`);
|
|
1682
1897
|
}
|
|
1683
1898
|
|
|
@@ -1685,11 +1900,32 @@ export class Intake {
|
|
|
1685
1900
|
// has recorded that we do not follow them; ours went on saying we did, and
|
|
1686
1901
|
// published it — so the two disagreed permanently, and a retry would never
|
|
1687
1902
|
// come because as far as they are concerned the question was answered.
|
|
1688
|
-
|
|
1903
|
+
//
|
|
1904
|
+
// It has to answer the Follow we actually SENT. Only the type was checked, so
|
|
1905
|
+
// one Append per account you follow — from anyone, naming no particular
|
|
1906
|
+
// follow — severed every one of them at once, and silently: their server
|
|
1907
|
+
// never hears about it, so nothing ever retries and nothing looks wrong until
|
|
1908
|
+
// the timeline goes quiet. `followActivity` is stored by followActor
|
|
1909
|
+
// (lib/social.mjs) for exactly this kind of comparison.
|
|
1910
|
+
async onReject(activity, actor, { trusted = false } = {}) {
|
|
1689
1911
|
if (activity.object?.type && activity.object.type !== 'Follow') return;
|
|
1690
1912
|
const contacts = this.store.getContacts();
|
|
1691
1913
|
const rec = contacts.following.find(f => f.actor === actor);
|
|
1692
1914
|
if (!rec) return; // nothing of ours to undo
|
|
1915
|
+
const named = typeof activity.object === 'string' ? activity.object : activity.object?.id;
|
|
1916
|
+
const ours = rec.followActivity?.id;
|
|
1917
|
+
// A gateway receipt bound to this actor is the other way to believe it —
|
|
1918
|
+
// the door checked a signature and said whose (see receiptVouchesFor).
|
|
1919
|
+
if (!trusted) {
|
|
1920
|
+
if (!ours) {
|
|
1921
|
+
this.log(`Reject from ${actor}: no follow id on record to match it against — ignored`);
|
|
1922
|
+
return;
|
|
1923
|
+
}
|
|
1924
|
+
if (named !== ours) {
|
|
1925
|
+
this.log(`Reject from ${actor} answers ${named || 'nothing'}, not the follow we sent — ignored`);
|
|
1926
|
+
return;
|
|
1927
|
+
}
|
|
1928
|
+
}
|
|
1693
1929
|
contacts.following = contacts.following.filter(f => f.actor !== actor);
|
|
1694
1930
|
this.store.setContacts(contacts);
|
|
1695
1931
|
await this.republish({ following: true, pending: true });
|
|
@@ -1719,7 +1955,7 @@ export class Intake {
|
|
|
1719
1955
|
this.log(`${actor} moved to ${target} — follow the new account to keep seeing them`);
|
|
1720
1956
|
}
|
|
1721
1957
|
|
|
1722
|
-
async onAccept(activity, actor) {
|
|
1958
|
+
async onAccept(activity, actor, { trusted = false } = {}) {
|
|
1723
1959
|
const contacts = this.store.getContacts();
|
|
1724
1960
|
const rec = contacts.following.find(f => f.actor === actor);
|
|
1725
1961
|
// It has to answer the Follow we actually sent. followActor stores that
|
|
@@ -1728,8 +1964,11 @@ export class Intake {
|
|
|
1728
1964
|
// including one answering a Follow we never made.
|
|
1729
1965
|
const named = typeof activity.object === 'string' ? activity.object : activity.object?.id;
|
|
1730
1966
|
const ours = rec?.followActivity?.id;
|
|
1731
|
-
|
|
1732
|
-
|
|
1967
|
+
// `named &&` used to be part of this, so an Accept naming NOTHING sailed
|
|
1968
|
+
// past — which is the easy one to send, and it marks a request to a locked
|
|
1969
|
+
// account as accepted when it is still sitting in their queue.
|
|
1970
|
+
if (ours && named !== ours && !trusted) {
|
|
1971
|
+
this.log(`Accept from ${actor} answers ${named || 'nothing'}, not the follow we sent — ignored`);
|
|
1733
1972
|
return;
|
|
1734
1973
|
}
|
|
1735
1974
|
if (rec && !rec.accepted) {
|