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/publisher.mjs
CHANGED
|
@@ -1,14 +1,27 @@
|
|
|
1
|
-
// publisher.mjs — builds/maintains the actor's public face on the remote pod
|
|
2
|
-
//
|
|
3
|
-
//
|
|
1
|
+
// publisher.mjs — builds/maintains the actor's public face on the remote pod:
|
|
2
|
+
// webfinger, actor doc, collections, notes. The /ap/ tree is disposable:
|
|
3
|
+
// publishProfile() rebuilds it.
|
|
4
4
|
|
|
5
5
|
import crypto from 'node:crypto';
|
|
6
6
|
import fs from 'node:fs';
|
|
7
7
|
import path from 'node:path';
|
|
8
8
|
import { fileURLToPath } from 'node:url';
|
|
9
9
|
import * as wire from './wire.mjs';
|
|
10
|
+
import * as polls from './polls.mjs';
|
|
10
11
|
import { USER_AGENT } from './ua.mjs';
|
|
11
12
|
import { HTTP_TIMEOUT_MS } from './safefetch.mjs';
|
|
13
|
+
import * as containers from './pod/containers.mjs';
|
|
14
|
+
import * as discovery from './pod/discovery.mjs';
|
|
15
|
+
import * as podActor from './pod/actor.mjs';
|
|
16
|
+
import * as podInbox from './pod/inbox.mjs';
|
|
17
|
+
import * as podOutbox from './pod/outbox.mjs';
|
|
18
|
+
import * as podFollowers from './pod/followers.mjs';
|
|
19
|
+
import * as podFollowing from './pod/following.mjs';
|
|
20
|
+
import * as podFeatured from './pod/featured.mjs';
|
|
21
|
+
import * as podPrivate from './pod/private.mjs';
|
|
22
|
+
import * as podPolicy from './pod/policy.mjs';
|
|
23
|
+
import * as podNotes from './pod/notes.mjs';
|
|
24
|
+
import * as podMedia from './pod/media.mjs';
|
|
12
25
|
|
|
13
26
|
const ACCEPT_AP = 'application/activity+json, application/ld+json; profile="https://www.w3.org/ns/activitystreams"';
|
|
14
27
|
// The default for publishCollections: the whole public surface, ACLs included.
|
|
@@ -16,18 +29,22 @@ const ACCEPT_AP = 'application/activity+json, application/ld+json; profile="http
|
|
|
16
29
|
// still gets everything, so a missed call site degrades to the old cost rather
|
|
17
30
|
// than silently publishing nothing.
|
|
18
31
|
const REBUILD_MAX_PER_RUN = 200;
|
|
32
|
+
// How long a poll gathers votes before its Question is rewritten. Every vote
|
|
33
|
+
// changes a number other servers re-read, and rewriting per vote would make a
|
|
34
|
+
// busy poll a steady write stream against the pod. A burst costs one rewrite
|
|
35
|
+
// and one Update instead.
|
|
36
|
+
const POLL_REWRITE_MS = 10_000;
|
|
19
37
|
const ALL_COLLECTIONS = { followers: true, following: true, outbox: true, acls: true,
|
|
20
38
|
pending: true, blocked: true };
|
|
21
39
|
const AGENT_VERSION = JSON.parse(fs.readFileSync(
|
|
22
40
|
path.join(path.dirname(fileURLToPath(import.meta.url)), '../package.json'), 'utf8')).version;
|
|
23
41
|
|
|
24
42
|
export class Publisher {
|
|
25
|
-
constructor({ config, remote,
|
|
26
|
-
probeFetch = null, resolveMention = null,
|
|
43
|
+
constructor({ config, remote, store, deliverer, publicKeyPem, assertionKey = null, log = console.log,
|
|
44
|
+
probeFetch = null, resolveMention = null, clientOrigin = null,
|
|
27
45
|
}) {
|
|
28
46
|
this.config = config;
|
|
29
47
|
this.remote = remote;
|
|
30
|
-
this.local = local;
|
|
31
48
|
this.store = store;
|
|
32
49
|
this.deliverer = deliverer;
|
|
33
50
|
this.publicKeyPem = publicKeyPem;
|
|
@@ -47,7 +64,8 @@ export class Publisher {
|
|
|
47
64
|
// socket opened to that pod. Tests inject their own.
|
|
48
65
|
this.probeFetch = probeFetch || ((u, i) => this.remote.probe(u, i));
|
|
49
66
|
this.resolveMention = resolveMention;
|
|
50
|
-
|
|
67
|
+
// Per-poll rewrite windows, keyed by question id. See POLL_REWRITE_MS.
|
|
68
|
+
this.pollTimers = new Map();
|
|
51
69
|
this.log = log;
|
|
52
70
|
}
|
|
53
71
|
|
|
@@ -110,34 +128,25 @@ export class Publisher {
|
|
|
110
128
|
return { unreachable: [], updated: 0, skipped: true };
|
|
111
129
|
}
|
|
112
130
|
|
|
113
|
-
await this.remote
|
|
114
|
-
wire.jrd({ handle: this.config.handle, host, actor: urls.actor })
|
|
115
|
-
await this.remote.
|
|
131
|
+
await discovery.writeWebfinger(this.remote, urls,
|
|
132
|
+
wire.jrd({ handle: this.config.handle, host, actor: urls.actor }));
|
|
133
|
+
await discovery.writeHostMeta(this.remote, urls, wire.hostMeta(urls.base));
|
|
116
134
|
|
|
117
|
-
const hostMetaUrl = urls.base + '.well-known/host-meta';
|
|
118
|
-
await this.remote.put(hostMetaUrl, wire.hostMeta(urls.base), 'application/xrd+xml');
|
|
119
|
-
await this.remote.setAcl(hostMetaUrl, ['Read']);
|
|
120
|
-
|
|
121
|
-
// NodeInfo: pointer at the protocol-required root, document in our tree.
|
|
122
135
|
const nodeinfoDocUrl = urls.home + 'ap/nodeinfo-2.0';
|
|
123
136
|
const localPosts = this.store.getStatuses().filter(s => s.kind === 'post').length;
|
|
124
|
-
await this.remote
|
|
125
|
-
wire.nodeinfoPointer(nodeinfoDocUrl),
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
wire.nodeinfoDoc({ version: AGENT_VERSION, localPosts }), 'application/json');
|
|
129
|
-
await this.remote.setAcl(nodeinfoDocUrl, ['Read']);
|
|
137
|
+
await discovery.writeNodeinfo(this.remote, urls, {
|
|
138
|
+
pointer: wire.nodeinfoPointer(nodeinfoDocUrl),
|
|
139
|
+
doc: wire.nodeinfoDoc({ version: AGENT_VERSION, localPosts }),
|
|
140
|
+
});
|
|
130
141
|
|
|
131
142
|
const actor = actorDoc; // built above, for the digest
|
|
132
|
-
await this.remote
|
|
133
|
-
await this.remote.setAcl(urls.actor, ['Read']);
|
|
143
|
+
await podActor.write(this.remote, urls, actor);
|
|
134
144
|
|
|
135
145
|
// FEP-1b12: the moderator roster, public — it is what a recipient
|
|
136
146
|
// validates a group's announced moderation against.
|
|
137
147
|
if (moderators) {
|
|
138
|
-
await this.remote
|
|
148
|
+
await podFeatured.writeModerators(this.remote, urls,
|
|
139
149
|
wire.orderedCollection(urls.moderators, this.config.moderators));
|
|
140
|
-
await this.remote.setAcl(urls.moderators, ['Read']);
|
|
141
150
|
}
|
|
142
151
|
// The gateway policy doc: the PUBLIC data a keyless gateway reads to decide
|
|
143
152
|
// what concerns this identity. Written only while a gateway is advertised.
|
|
@@ -145,20 +154,19 @@ export class Publisher {
|
|
|
145
154
|
|
|
146
155
|
// The human half: a page a browser can open and follow from. The actor
|
|
147
156
|
// document is for servers; this is the address you hand to a person.
|
|
148
|
-
await this.remote
|
|
157
|
+
await podActor.writeProfilePage(this.remote, urls, wire.profilePageHtml({
|
|
149
158
|
name: this.config.name || this.config.handle,
|
|
150
159
|
address: wire.webfingerHost(urls.base) ? `@${this.config.handle}@${host}` : urls.actor,
|
|
151
160
|
summary: this.config.summary ? wire.contentHtml(this.config.summary) : null,
|
|
152
161
|
icon: this.config.icon || null,
|
|
153
162
|
kind: this.config.kind,
|
|
154
|
-
})
|
|
155
|
-
await this.remote.setAcl(urls.profileHtml, ['Read']);
|
|
163
|
+
}));
|
|
156
164
|
|
|
157
165
|
// WebID → actor: the profile card lists the actor as a foaf:account.
|
|
158
166
|
// Best-effort — a profile that cannot be read or edited does not stop the
|
|
159
167
|
// publish, it is logged and the rest of the surface still goes up.
|
|
160
168
|
try {
|
|
161
|
-
const wrote = await this.remote
|
|
169
|
+
const wrote = await podActor.linkInWebIdProfile(this.remote, {
|
|
162
170
|
actorUrl: urls.actor,
|
|
163
171
|
accountName: `@${this.config.handle}@${host}`,
|
|
164
172
|
kind: this.config.kind,
|
|
@@ -171,21 +179,15 @@ export class Publisher {
|
|
|
171
179
|
// inbox: public may only Append; owner (the agent) reads + drains. A
|
|
172
180
|
// quiesced actor keeps its name resolving but takes no more mail, so a
|
|
173
181
|
// republish must not re-open the door.
|
|
174
|
-
await this.remote
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
this.log('inbox left closed — this actor is quiesced');
|
|
178
|
-
} else {
|
|
179
|
-
await this.remote.setAcl(urls.inbox, ['Append']);
|
|
180
|
-
}
|
|
182
|
+
await podInbox.writeKeep(this.remote, urls);
|
|
183
|
+
await podInbox.setPosture(this.remote, urls, this.config.quiescedAt ? 'closed' : 'open');
|
|
184
|
+
if (this.config.quiescedAt) this.log('inbox left closed — this actor is quiesced');
|
|
181
185
|
|
|
182
186
|
// notes live under a public-Read container (acl:default covers new notes).
|
|
183
|
-
await this.remote
|
|
184
|
-
await this.remote.setAcl(urls.notes, ['Read']);
|
|
187
|
+
await podNotes.provisionContainer(this.remote, urls);
|
|
185
188
|
|
|
186
189
|
await this.publishCollections({ ...ALL_COLLECTIONS, force });
|
|
187
190
|
const updated = await this.announceProfileChange(actor, { force });
|
|
188
|
-
await this.local.writeSettings({ handle: this.config.handle, actorUrl: urls.actor });
|
|
189
191
|
await this.ensurePrivateAcls();
|
|
190
192
|
const unreachable = await this.verifyPublicSurface();
|
|
191
193
|
// Last, and merged: announceProfileChange writes this document too.
|
|
@@ -272,23 +274,14 @@ export class Publisher {
|
|
|
272
274
|
// this runs on every connect: probe UNauthenticated, rewrite whatever
|
|
273
275
|
// answers, and say so loudly if the rewrite does not take.
|
|
274
276
|
async ensurePrivateAcls() {
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
if (
|
|
282
|
-
this.log(
|
|
283
|
-
try {
|
|
284
|
-
await this.remote.setAcl(url, []);
|
|
285
|
-
} catch (e) {
|
|
286
|
-
this.log(`SECURITY: ${url} is public and its ACL could not be rewritten: ${e.message}`);
|
|
287
|
-
continue;
|
|
288
|
-
}
|
|
289
|
-
if (await this.publiclyReadable(url)) {
|
|
290
|
-
this.log(`SECURITY: ${url} is STILL readable without credentials — check the pod's ACLs`);
|
|
291
|
-
}
|
|
277
|
+
const findings = await containers.repairPrivateAcls(this.remote,
|
|
278
|
+
[this.urls.home, this.urls.state],
|
|
279
|
+
{ isPublic: (u) => this.publiclyReadable(u) });
|
|
280
|
+
// What a finding MEANS is ours to say; the library just reports.
|
|
281
|
+
for (const f of findings) {
|
|
282
|
+
this.log(`${f.url} was readable without credentials — rewriting its ACL`);
|
|
283
|
+
if (f.error) { this.log(`SECURITY: ${f.url} is public and its ACL could not be rewritten: ${f.error}`); continue; }
|
|
284
|
+
if (f.stillPublic) this.log(`SECURITY: ${f.url} is STILL readable without credentials — check the pod's ACLs`);
|
|
292
285
|
}
|
|
293
286
|
}
|
|
294
287
|
|
|
@@ -296,14 +289,9 @@ export class Publisher {
|
|
|
296
289
|
// accept: */* matters — asking for turtle makes the server answer 501 on the
|
|
297
290
|
// JSON documents (webfinger, actor), which reads as "unreachable" when the
|
|
298
291
|
// world can in fact see them perfectly well.
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
headers: { accept: '*/*', 'user-agent': USER_AGENT },
|
|
303
|
-
signal: AbortSignal.timeout(HTTP_TIMEOUT_MS),
|
|
304
|
-
});
|
|
305
|
-
return res.status < 400;
|
|
306
|
-
} catch { return false; } // unreachable is not a finding here
|
|
292
|
+
publiclyReadable(url) {
|
|
293
|
+
return containers.probePublicReadability(this.probeFetch, url,
|
|
294
|
+
{ headers: { 'user-agent': USER_AGENT }, timeoutMs: HTTP_TIMEOUT_MS });
|
|
307
295
|
}
|
|
308
296
|
|
|
309
297
|
// Retire this identity for good: tell everyone who follows us to drop the
|
|
@@ -318,8 +306,7 @@ export class Publisher {
|
|
|
318
306
|
const inboxes = [...new Set(contacts.followers.map(f => f.sharedInbox || f.inbox).filter(Boolean))];
|
|
319
307
|
const deletedAt = new Date().toISOString();
|
|
320
308
|
await this.deliverer.deliverToAll(inboxes, wire.deleteActorActivity(urls, Date.parse(deletedAt)));
|
|
321
|
-
await this.remote
|
|
322
|
-
await this.remote.setAcl(urls.actor, ['Read']); // a Tombstone must stay fetchable
|
|
309
|
+
await podActor.writeTombstone(this.remote, urls, wire.tombstoneDoc(urls, deletedAt, this.config.kind));
|
|
323
310
|
this.store.setConfig({ ...this.store.getConfig(), retiredAt: deletedAt });
|
|
324
311
|
await this.store.flush();
|
|
325
312
|
this.log(`retired: Delete sent to ${inboxes.length} inbox(es), actor replaced with a Tombstone`);
|
|
@@ -330,7 +317,7 @@ export class Publisher {
|
|
|
330
317
|
// 401 rather than a 201 into storage nobody will ever drain. WebFinger,
|
|
331
318
|
// host-meta and the actor stay published, so the handle still resolves.
|
|
332
319
|
async closeInbox() {
|
|
333
|
-
await this.remote
|
|
320
|
+
await podInbox.setPosture(this.remote, this.urls, 'closed');
|
|
334
321
|
const at = new Date().toISOString();
|
|
335
322
|
this.store.setConfig({ ...this.store.getConfig(), quiescedAt: at });
|
|
336
323
|
await this.store.flush();
|
|
@@ -340,7 +327,7 @@ export class Publisher {
|
|
|
340
327
|
|
|
341
328
|
// Undo closeInbox: mail flows again and the actor is no longer quiesced.
|
|
342
329
|
async openInbox() {
|
|
343
|
-
await this.remote
|
|
330
|
+
await podInbox.setPosture(this.remote, this.urls, 'open');
|
|
344
331
|
const { quiescedAt, ...rest } = this.store.getConfig() || {};
|
|
345
332
|
this.store.setConfig(rest);
|
|
346
333
|
this.config.quiescedAt = undefined;
|
|
@@ -352,7 +339,7 @@ export class Publisher {
|
|
|
352
339
|
// the pod except through the gateway's verify-at-the-door. Reversible with
|
|
353
340
|
// openInbox (public-Append) — the one-call rollback.
|
|
354
341
|
async lockInboxToGateway(gatewayWebId) {
|
|
355
|
-
await this.remote
|
|
342
|
+
await podInbox.setPosture(this.remote, this.urls, { gatewayWebId });
|
|
356
343
|
this.log(`inbox locked to gateway ${gatewayWebId} — public delivery is refused`);
|
|
357
344
|
}
|
|
358
345
|
|
|
@@ -366,7 +353,7 @@ export class Publisher {
|
|
|
366
353
|
await this.deliverer.deliverToAll(inboxes, wire.moveActivity(urls, target, Date.parse(at)));
|
|
367
354
|
this.config.movedTo = target; // so the republish below carries it
|
|
368
355
|
this.store.setConfig({ ...this.store.getConfig(), movedTo: target, movedAt: at });
|
|
369
|
-
await this.remote
|
|
356
|
+
await podActor.writeMoved(this.remote, urls, wire.actorDoc({
|
|
370
357
|
urls, handle: wire.publicHandle(this.config), name: this.config.name,
|
|
371
358
|
publicKeyPem: this.publicKeyPem, movedTo: target, kind: this.config.kind,
|
|
372
359
|
approveJoins: wire.followsNeedApproval(this.config),
|
|
@@ -376,7 +363,6 @@ export class Publisher {
|
|
|
376
363
|
webId: this.remote.webId || null,
|
|
377
364
|
aliases: this.config.aliases || [],
|
|
378
365
|
}));
|
|
379
|
-
await this.remote.setAcl(urls.actor, ['Read']);
|
|
380
366
|
await this.store.flush();
|
|
381
367
|
this.log(`moved to ${target}: Move sent to ${inboxes.length} inbox(es), actor now advertises movedTo`);
|
|
382
368
|
return { inboxes: inboxes.length, target, movedAt: at };
|
|
@@ -500,8 +486,7 @@ export class Publisher {
|
|
|
500
486
|
// `-replies` — plus a `.keep`. Which is which is settled by reading the
|
|
501
487
|
// document below, not by its name: a slug is a date and eight hex
|
|
502
488
|
// characters and says nothing about what it holds.
|
|
503
|
-
for (const child of await this.remote
|
|
504
|
-
if (/(-create|-replies|\/\.keep)$/.test(child.url)) continue;
|
|
489
|
+
for (const child of await podNotes.list(this.remote, urls).catch(() => [])) {
|
|
505
490
|
ids.add(child.url);
|
|
506
491
|
}
|
|
507
492
|
indexed = true;
|
|
@@ -520,7 +505,7 @@ export class Publisher {
|
|
|
520
505
|
if (budget <= 0) { this.log(`rebuild: stopping at ${REBUILD_MAX_PER_RUN} this run — run it again for the rest`); break; }
|
|
521
506
|
if (have.has(id) || removed.has(id)) continue;
|
|
522
507
|
budget--;
|
|
523
|
-
const note = await this.remote
|
|
508
|
+
const note = await podNotes.read(this.remote, id).catch(() => null);
|
|
524
509
|
if (note?.type !== 'Note' || note.id !== id || note.attributedTo !== urls.actor) continue;
|
|
525
510
|
const attachments = wire.attachmentsOf(note);
|
|
526
511
|
const mentions = (Array.isArray(note.tag) ? note.tag : [])
|
|
@@ -552,31 +537,18 @@ export class Publisher {
|
|
|
552
537
|
}
|
|
553
538
|
if (!recovered.length && !reblogs) return { indexed: ids.size, recovered: 0, reblogs: 0, landed: true };
|
|
554
539
|
|
|
555
|
-
// Written whole and sorted
|
|
556
|
-
//
|
|
557
|
-
//
|
|
540
|
+
// Written whole and sorted: addStatus unshifts and fires the streaming
|
|
541
|
+
// event, so a loop of it would arrive backwards and push every recovered
|
|
542
|
+
// post at connected clients as new.
|
|
558
543
|
merged.sort((a, b) => String(b.published || '').localeCompare(String(a.published || '')));
|
|
559
544
|
const kept = merged.slice(0, 1000);
|
|
560
545
|
this.store.write('statuses.json', kept);
|
|
561
546
|
const landed = await this.store.commit();
|
|
562
547
|
|
|
563
|
-
// The RDF mirror is the other thing a lost machine took with it, and it is
|
|
564
|
-
// what backfillStatuses reads on a fresh state. Best effort: a post in
|
|
565
|
-
// statuses.json is already the recovery that matters.
|
|
566
|
-
let rdf = 0;
|
|
567
|
-
for (const s of recovered) {
|
|
568
|
-
try {
|
|
569
|
-
await this.local.writeNote('posts', s.slug, {
|
|
570
|
-
noteId: s.noteId, actor: s.actor, published: s.published,
|
|
571
|
-
content: s.content, inReplyTo: s.inReplyTo, attachments: s.attachments,
|
|
572
|
-
});
|
|
573
|
-
rdf++;
|
|
574
|
-
} catch (e) { this.log(`rebuild: RDF for ${s.slug} not written (${e.message})`); }
|
|
575
|
-
}
|
|
576
548
|
this.log(`rebuilt ${recovered.length} post(s) and ${reblogs} boost(s) from the pod`
|
|
577
549
|
+ `${landed ? '' : ' — THE STATE WRITE DID NOT LAND'}`);
|
|
578
550
|
return {
|
|
579
|
-
indexed: ids.size, recovered: recovered.length, reblogs,
|
|
551
|
+
indexed: ids.size, recovered: recovered.length, reblogs, landed,
|
|
580
552
|
dropped: Math.max(0, merged.length - kept.length),
|
|
581
553
|
};
|
|
582
554
|
}
|
|
@@ -624,8 +596,8 @@ export class Publisher {
|
|
|
624
596
|
const digest = crypto.createHash('sha256').update(JSON.stringify(doc)).digest('hex').slice(0, 16);
|
|
625
597
|
after[n] = digest;
|
|
626
598
|
if (before[n] === digest) continue; // sealed and unchanged
|
|
627
|
-
await this.remote
|
|
628
|
-
|
|
599
|
+
await podOutbox.writePage(this.remote, wire.outboxPageId(urls.outbox, n), doc,
|
|
600
|
+
{ publicRead: !before[n] || acls });
|
|
629
601
|
wrote++;
|
|
630
602
|
}
|
|
631
603
|
// Pages above the new count are orphans: the outbox shrank past them, and
|
|
@@ -635,13 +607,12 @@ export class Publisher {
|
|
|
635
607
|
const stale = Object.keys(seen.outboxPages || {}).map(Number)
|
|
636
608
|
.filter(n => Number.isFinite(n) && n > pages.length);
|
|
637
609
|
for (const n of stale) {
|
|
638
|
-
await this.remote
|
|
610
|
+
await podOutbox.dropPage(this.remote, wire.outboxPageId(urls.outbox, n));
|
|
639
611
|
}
|
|
640
612
|
// The head carries totalItems, so it moves whenever the outbox does. Four
|
|
641
613
|
// lines, and constant however much you have posted.
|
|
642
|
-
await this.remote
|
|
643
|
-
wire.outboxHead(urls.outbox, outbox.length, pages.length));
|
|
644
|
-
if (acls) await this.remote.setAcl(urls.outbox, ['Read']);
|
|
614
|
+
await podOutbox.writeHead(this.remote, urls,
|
|
615
|
+
wire.outboxHead(urls.outbox, outbox.length, pages.length), { publicRead: acls });
|
|
645
616
|
this.store.write('published.json',
|
|
646
617
|
{ ...this.store.read('published.json', {}), outboxPages: after, outboxIndex: index });
|
|
647
618
|
return wrote;
|
|
@@ -651,22 +622,7 @@ export class Publisher {
|
|
|
651
622
|
// the flat collection this used to write, so an actor published before paging
|
|
652
623
|
// is still readable — which matters because rebuild reads this to recover
|
|
653
624
|
// posts a lost machine no longer has.
|
|
654
|
-
|
|
655
|
-
const head = await this.remote.getJson(this.urls.outbox).catch(() => null);
|
|
656
|
-
if (!head) return null;
|
|
657
|
-
if (Array.isArray(head.orderedItems) && head.orderedItems.length) return head.orderedItems;
|
|
658
|
-
const items = [];
|
|
659
|
-
let next = head.first;
|
|
660
|
-
const seen = new Set();
|
|
661
|
-
while (next && !seen.has(next) && items.length < 10_000) {
|
|
662
|
-
seen.add(next);
|
|
663
|
-
const page = await this.remote.getJson(next).catch(() => null);
|
|
664
|
-
if (!page) break;
|
|
665
|
-
items.push(...(page.orderedItems || []));
|
|
666
|
-
next = page.next;
|
|
667
|
-
}
|
|
668
|
-
return items;
|
|
669
|
-
}
|
|
625
|
+
readPublishedOutbox() { return podOutbox.readPublished(this.remote, this.urls); }
|
|
670
626
|
|
|
671
627
|
// The followers collection, paged like the outbox: a head that carries only
|
|
672
628
|
// the count and the page bounds, and page documents holding the actor IRIs —
|
|
@@ -687,19 +643,18 @@ export class Publisher {
|
|
|
687
643
|
const digest = crypto.createHash('sha256').update(JSON.stringify(doc)).digest('hex').slice(0, 16);
|
|
688
644
|
after[n] = digest;
|
|
689
645
|
if (before[n] === digest) continue; // unchanged page
|
|
690
|
-
await this.remote
|
|
691
|
-
|
|
646
|
+
await podFollowers.writePage(this.remote, wire.followersPageId(urls.followers, n), doc,
|
|
647
|
+
{ publicRead: !before[n] || acls });
|
|
692
648
|
}
|
|
693
649
|
// Pages the collection shrank past would keep serving names that no longer
|
|
694
650
|
// follow; the head stops pointing at them but the URL is guessable.
|
|
695
651
|
const stale = Object.keys(seen.followersPages || {}).map(Number)
|
|
696
652
|
.filter(n => Number.isFinite(n) && n > pages.length);
|
|
697
653
|
for (const n of stale) {
|
|
698
|
-
await this.remote
|
|
654
|
+
await podFollowers.dropPage(this.remote, wire.followersPageId(urls.followers, n));
|
|
699
655
|
}
|
|
700
|
-
await this.remote
|
|
701
|
-
wire.followersHead(urls.followers, actors.length, pages.length));
|
|
702
|
-
if (acls) await this.remote.setAcl(urls.followers, ['Read']);
|
|
656
|
+
await podFollowers.writeHead(this.remote, urls,
|
|
657
|
+
wire.followersHead(urls.followers, actors.length, pages.length), { publicRead: acls });
|
|
703
658
|
this.store.write('published.json',
|
|
704
659
|
{ ...this.store.read('published.json', {}), followersPages: after, followersIndex: index });
|
|
705
660
|
}
|
|
@@ -707,23 +662,7 @@ export class Publisher {
|
|
|
707
662
|
// Every actor in the published followers collection, walking pages. Also reads
|
|
708
663
|
// the flat collection this used to write, so an actor published before paging
|
|
709
664
|
// still reconciles.
|
|
710
|
-
|
|
711
|
-
const head = await this.remote.getJson(this.urls.followers).catch(() => null);
|
|
712
|
-
if (!head) return null;
|
|
713
|
-
if (Array.isArray(head.orderedItems) && head.orderedItems.length) return head.orderedItems;
|
|
714
|
-
if (Array.isArray(head.items) && head.items.length) return head.items;
|
|
715
|
-
const items = [];
|
|
716
|
-
let next = head.first;
|
|
717
|
-
const seen = new Set();
|
|
718
|
-
while (next && !seen.has(next) && items.length < 100_000) {
|
|
719
|
-
seen.add(next);
|
|
720
|
-
const page = await this.remote.getJson(next).catch(() => null);
|
|
721
|
-
if (!page) break;
|
|
722
|
-
items.push(...(page.orderedItems || []));
|
|
723
|
-
next = page.next;
|
|
724
|
-
}
|
|
725
|
-
return items;
|
|
726
|
-
}
|
|
665
|
+
readPublishedFollowers() { return podFollowers.readPublished(this.remote, this.urls); }
|
|
727
666
|
|
|
728
667
|
async publishCollections(which = ALL_COLLECTIONS) {
|
|
729
668
|
const { urls } = this;
|
|
@@ -740,9 +679,9 @@ export class Publisher {
|
|
|
740
679
|
await this.publishFollowers(actors, { acls: which.acls, force: which.force });
|
|
741
680
|
}
|
|
742
681
|
if (which.following) {
|
|
743
|
-
await this.remote
|
|
744
|
-
wire.orderedCollection(urls.following, contacts.following.filter(f => f.accepted).map(f => f.actor))
|
|
745
|
-
|
|
682
|
+
await podFollowing.write(this.remote, urls,
|
|
683
|
+
wire.orderedCollection(urls.following, contacts.following.filter(f => f.accepted).map(f => f.actor)),
|
|
684
|
+
{ publicRead: which.acls });
|
|
746
685
|
}
|
|
747
686
|
if (which.pending) await this.publishPending();
|
|
748
687
|
if (which.blocked) await this.publishBlocked();
|
|
@@ -758,7 +697,6 @@ export class Publisher {
|
|
|
758
697
|
if (which.force || !Array.isArray(known)) await this.reconcileOutbox(outbox);
|
|
759
698
|
await this.publishOutbox(outbox, { acls: which.acls, force: which.force });
|
|
760
699
|
}
|
|
761
|
-
if (which.followers || which.following) await this.local.writeContacts(contacts);
|
|
762
700
|
}
|
|
763
701
|
|
|
764
702
|
// FEP-4ccd: the follows in limbo, as owner-only collections of the Follow
|
|
@@ -770,13 +708,13 @@ export class Publisher {
|
|
|
770
708
|
if (await this.privateReady() !== true) return;
|
|
771
709
|
const { urls } = this;
|
|
772
710
|
const contacts = this.store.getContacts();
|
|
773
|
-
await this.remote
|
|
774
|
-
urls.pendingFollowers,
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
711
|
+
await podPrivate.writePending(this.remote, urls, {
|
|
712
|
+
followers: wire.orderedCollection(urls.pendingFollowers,
|
|
713
|
+
this.store.getRequests().filter(r => r.activity && !r.bsky).map(r => r.activity)),
|
|
714
|
+
following: wire.orderedCollection(urls.pendingFollowing,
|
|
715
|
+
contacts.following.filter(f => !f.accepted && f.followActivity)
|
|
716
|
+
.map(f => f.followActivity).reverse()),
|
|
717
|
+
});
|
|
780
718
|
}
|
|
781
719
|
|
|
782
720
|
// The gateway policy: a small PUBLIC document a keyless inbox gateway reads
|
|
@@ -789,7 +727,7 @@ export class Publisher {
|
|
|
789
727
|
const { urls } = this;
|
|
790
728
|
const contacts = this.store.getContacts();
|
|
791
729
|
const bl = this.store.getBlocklist();
|
|
792
|
-
await this.remote
|
|
730
|
+
await podPolicy.write(this.remote, urls, {
|
|
793
731
|
v: 1,
|
|
794
732
|
actorUrl: urls.actor,
|
|
795
733
|
followersUrl: urls.followers,
|
|
@@ -799,8 +737,7 @@ export class Publisher {
|
|
|
799
737
|
kind: this.config.kind || 'person',
|
|
800
738
|
following: contacts.following.filter(f => f.accepted && !f.bsky).map(f => f.actor),
|
|
801
739
|
blocklist: { domains: bl.domains || [], actors: bl.actors || [] },
|
|
802
|
-
}
|
|
803
|
-
await this.remote.setAcl(urls.home + 'ap/gateway-policy.json', ['Read']);
|
|
740
|
+
});
|
|
804
741
|
}
|
|
805
742
|
|
|
806
743
|
// FEP-c648: the blocked actors, as an owner-only collection. Actors only —
|
|
@@ -809,7 +746,7 @@ export class Publisher {
|
|
|
809
746
|
if (await this.privateReady() !== true) return;
|
|
810
747
|
const { urls } = this;
|
|
811
748
|
const actors = [...(this.store.getBlocklist().actors || [])].reverse();
|
|
812
|
-
await this.remote
|
|
749
|
+
await podPrivate.writeBlocked(this.remote, urls, wire.orderedCollection(urls.blocked, actors));
|
|
813
750
|
}
|
|
814
751
|
|
|
815
752
|
// The outbox is the public record of everything this actor has said, boosts
|
|
@@ -844,19 +781,32 @@ export class Publisher {
|
|
|
844
781
|
|
|
845
782
|
// Media container on the remote pod — public-Read like notes, created lazily
|
|
846
783
|
// at first upload (idempotent; the flag only saves round-trips).
|
|
784
|
+
// Ask before writing. `_mediaReady` is per PROCESS, and a service worker is
|
|
785
|
+
// restarted whenever the browser feels like it — so on the browser build this
|
|
786
|
+
// re-created a container that has existed since sign-up, and rewrote its ACL,
|
|
787
|
+
// on every restart. A HEAD is one request and usually the only one.
|
|
847
788
|
async ensureMediaContainer() {
|
|
848
789
|
if (this._mediaReady) return;
|
|
849
|
-
await this.remote
|
|
850
|
-
await this.remote
|
|
790
|
+
if (await containers.exists(this.remote, this.urls.media)) { this._mediaReady = true; return; }
|
|
791
|
+
await containers.provisionPublic(this.remote, this.urls.media);
|
|
851
792
|
this._mediaReady = true;
|
|
852
793
|
}
|
|
853
794
|
|
|
795
|
+
// Whether the canary this class writes is already there. Only a definite
|
|
796
|
+
// "yes" counts: anything else falls through to the write, which is
|
|
797
|
+
// idempotent anyway, so a failed probe costs a request and never correctness.
|
|
798
|
+
_containerExists(base) { return containers.exists(this.remote, base); }
|
|
799
|
+
|
|
854
800
|
// The owner-only container that followers-only and direct posts live in.
|
|
855
801
|
// Its ACL is set once; every note under it inherits.
|
|
856
802
|
async ensurePrivateContainer() {
|
|
857
803
|
if (this._privateContainer) return;
|
|
858
|
-
|
|
859
|
-
|
|
804
|
+
// Same reasoning as ensureMediaContainer: probe before provisioning, so a
|
|
805
|
+
// worker restart is not a re-provision. The ACL is NOT skipped on the
|
|
806
|
+
// strength of the container existing alone — ensurePrivateAcls re-checks
|
|
807
|
+
// that separately on every connect, which is the control that matters here.
|
|
808
|
+
if (await containers.exists(this.remote, this.urls.privateNotes)) { this._privateContainer = true; return; }
|
|
809
|
+
await containers.provisionOwnerOnly(this.remote, this.urls.privateNotes);
|
|
860
810
|
this._privateContainer = true;
|
|
861
811
|
}
|
|
862
812
|
|
|
@@ -873,9 +823,8 @@ export class Publisher {
|
|
|
873
823
|
// fronted identity must map the advertised private container back to the
|
|
874
824
|
// pod itself — the ACL we are testing is the pod's.
|
|
875
825
|
const pn = this.urls.toPod ? this.urls.toPod(this.urls.privateNotes) : this.urls.privateNotes;
|
|
876
|
-
const
|
|
877
|
-
this._privateVerdict =
|
|
878
|
-
: `this pod serves private documents to strangers (HTTP ${r.status}) — private posts stay off it`;
|
|
826
|
+
const verdict = await containers.probePrivateEnforcement(this.probeFetch || fetch, pn + '.keep');
|
|
827
|
+
this._privateVerdict = verdict === true ? true : `${verdict} — private posts stay off it`;
|
|
879
828
|
return this._privateVerdict;
|
|
880
829
|
} catch (e) {
|
|
881
830
|
return `could not verify that the pod protects private posts (${e.message})`;
|
|
@@ -883,20 +832,13 @@ export class Publisher {
|
|
|
883
832
|
}
|
|
884
833
|
|
|
885
834
|
// Compose → wire note on remote pod + RDF truth locally + deliver Create.
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
const published = new Date().toISOString();
|
|
894
|
-
const slug = published.slice(0, 10) + '-' + crypto.randomBytes(4).toString('hex');
|
|
895
|
-
// A mention nobody can resolve stays plain text rather than failing the post.
|
|
896
|
-
// The reply's own text decides who is mentioned: trim a handle out and that
|
|
897
|
-
// person is not notified, which is what every fediverse client leads people
|
|
898
|
-
// to expect. A Group named in the parent is the one thing carried forward
|
|
899
|
-
// regardless — drop it and the group stops carrying the thread.
|
|
835
|
+
// Who this text mentions, resolved. A mention nobody can resolve stays plain
|
|
836
|
+
// text rather than failing the post. The text itself decides: trim a handle
|
|
837
|
+
// out and that person is not notified, which is what every fediverse client
|
|
838
|
+
// leads people to expect. A Group named in the parent is the one thing
|
|
839
|
+
// carried forward regardless — drop it and the group stops carrying the
|
|
840
|
+
// thread.
|
|
841
|
+
async _mentionsFor(content, inReplyTo) {
|
|
900
842
|
const inText = new Set(wire.mentionsIn(content));
|
|
901
843
|
const carried = inReplyTo
|
|
902
844
|
? (this.store.getStatuses().find(s => s.noteId === inReplyTo)?.mentions || [])
|
|
@@ -910,18 +852,29 @@ export class Publisher {
|
|
|
910
852
|
if (!inText.has(handle) && doc.type !== 'Group') continue; // author trimmed them out
|
|
911
853
|
mentions.push({ handle, actor: doc.id, page: doc.url || null, inbox: doc.endpoints?.sharedInbox || doc.inbox });
|
|
912
854
|
}
|
|
855
|
+
return mentions;
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
async publishNote(content, { inReplyTo, attachments, visibility = 'public', spoilerText = null } = {}) {
|
|
859
|
+
const { urls } = this;
|
|
860
|
+
const priv = visibility === 'private' || visibility === 'direct';
|
|
861
|
+
if (priv) {
|
|
862
|
+
const ready = await this.privateReady();
|
|
863
|
+
if (ready !== true) throw new Error(ready);
|
|
864
|
+
}
|
|
865
|
+
const published = new Date().toISOString();
|
|
866
|
+
const slug = published.slice(0, 10) + '-' + crypto.randomBytes(4).toString('hex');
|
|
867
|
+
const mentions = await this._mentionsFor(content, inReplyTo);
|
|
913
868
|
const note = wire.noteDoc({ urls, slug, content, published, inReplyTo, attachments, mentions,
|
|
914
869
|
visibility, summary: spoilerText, container: priv ? urls.privateNotes : urls.notes });
|
|
915
870
|
|
|
916
|
-
await this.remote
|
|
871
|
+
await podNotes.write(this.remote, note.id, note);
|
|
917
872
|
// Empty, but present: a dangling `replies` that 404s is worse than none.
|
|
918
|
-
await this.remote
|
|
873
|
+
await podNotes.writeEmptyReplies(this.remote, wire.repliesId(note.id),
|
|
874
|
+
wire.collection(wire.repliesId(note.id), []));
|
|
919
875
|
// The outbox is the PUBLIC index; a private or direct post is not in it.
|
|
920
876
|
if (!priv) await this.recordOutbox(note.id);
|
|
921
877
|
|
|
922
|
-
await this.local.writeNote('posts', slug, {
|
|
923
|
-
noteId: note.id, actor: urls.actor, published, content: note.content, inReplyTo, attachments,
|
|
924
|
-
});
|
|
925
878
|
this.store.addStatus({
|
|
926
879
|
noteId: note.id, actor: urls.actor, content: note.content, published, inReplyTo,
|
|
927
880
|
kind: 'post', slug, text: content, visibility,
|
|
@@ -934,7 +887,7 @@ export class Publisher {
|
|
|
934
887
|
// Published as its own document: a group that carries this post wraps the
|
|
935
888
|
// whole activity, and the receiving server resolves it by fetching this id.
|
|
936
889
|
// It inherits the notes container's public-Read acl:default.
|
|
937
|
-
await this.remote
|
|
890
|
+
await podNotes.writeCreate(this.remote, create.id, create);
|
|
938
891
|
const contacts = this.store.getContacts();
|
|
939
892
|
// A direct post goes to the people it names and to nobody else.
|
|
940
893
|
const inboxes = [...new Set([
|
|
@@ -962,12 +915,234 @@ export class Publisher {
|
|
|
962
915
|
return note;
|
|
963
916
|
}
|
|
964
917
|
|
|
918
|
+
// --- polls ---------------------------------------------------------------
|
|
919
|
+
//
|
|
920
|
+
// A poll is published as a Question and answered by ordinary replies naming
|
|
921
|
+
// an option, so the count is ours to keep and ours to republish. The roster
|
|
922
|
+
// of who chose what lives in agent state (polls.VOTES_DOC); the tallies on
|
|
923
|
+
// the status row and in the pod document are always derived from it.
|
|
924
|
+
|
|
925
|
+
/**
|
|
926
|
+
* Publish a poll. `options` is a list of choice titles, `multiple` lets a
|
|
927
|
+
* voter pick more than one, and `expiresAt` is when voting stops.
|
|
928
|
+
*/
|
|
929
|
+
async publishQuestion(content, { options = [], multiple = false, expiresAt = null,
|
|
930
|
+
inReplyTo = undefined, visibility = 'public', spoilerText = null } = {}) {
|
|
931
|
+
const { urls } = this;
|
|
932
|
+
const priv = visibility === 'private' || visibility === 'direct';
|
|
933
|
+
if (priv) {
|
|
934
|
+
const ready = await this.privateReady();
|
|
935
|
+
if (ready !== true) throw new Error(ready);
|
|
936
|
+
}
|
|
937
|
+
const titles = [].concat(options).map(o => String(o ?? '').trim()).filter(Boolean);
|
|
938
|
+
if (titles.length < 2) throw new Error('a poll needs at least two options');
|
|
939
|
+
// Options are matched BY NAME when a vote arrives — that is the whole of
|
|
940
|
+
// the convention — so two options reading the same are one option that
|
|
941
|
+
// cannot be told apart.
|
|
942
|
+
if (new Set(titles).size !== titles.length) throw new Error('a poll’s options must differ from one another');
|
|
943
|
+
// Bounded here as well as at the client API, because the outbox is a
|
|
944
|
+
// second way in and an unbounded poll is a document a stranger sizes.
|
|
945
|
+
if (titles.length > polls.MAX_OPTIONS) throw new Error(`a poll takes at most ${polls.MAX_OPTIONS} options`);
|
|
946
|
+
if (titles.some(t => t.length > polls.MAX_OPTION_CHARS)) {
|
|
947
|
+
throw new Error(`a poll option is at most ${polls.MAX_OPTION_CHARS} characters`);
|
|
948
|
+
}
|
|
949
|
+
if (expiresAt) {
|
|
950
|
+
const ends = Date.parse(expiresAt);
|
|
951
|
+
if (!Number.isFinite(ends)) throw new Error('the closing time is not a date');
|
|
952
|
+
const seconds = (ends - Date.now()) / 1000;
|
|
953
|
+
if (seconds < polls.MIN_SECONDS || seconds > polls.MAX_SECONDS) {
|
|
954
|
+
throw new Error(`a poll runs between ${polls.MIN_SECONDS} and ${polls.MAX_SECONDS} seconds`);
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
const published = new Date().toISOString();
|
|
959
|
+
const slug = published.slice(0, 10) + '-' + crypto.randomBytes(4).toString('hex');
|
|
960
|
+
const mentions = await this._mentionsFor(content, inReplyTo);
|
|
961
|
+
const poll = {
|
|
962
|
+
multiple: !!multiple,
|
|
963
|
+
expiresAt: expiresAt || null,
|
|
964
|
+
closed: null,
|
|
965
|
+
options: titles.map(title => ({ title, votes: 0 })),
|
|
966
|
+
votersCount: 0,
|
|
967
|
+
// Resolved once, here: a tally rewrite must not cost a webfinger lookup
|
|
968
|
+
// per vote for people the poll named.
|
|
969
|
+
mentionInboxes: [...new Set(mentions.map(m => m.inbox).filter(Boolean))],
|
|
970
|
+
};
|
|
971
|
+
const question = wire.questionDoc({
|
|
972
|
+
urls, slug, content, published, inReplyTo, attachments: [], mentions,
|
|
973
|
+
visibility, summary: spoilerText,
|
|
974
|
+
container: priv ? urls.privateNotes : urls.notes,
|
|
975
|
+
options: poll.options, multiple: poll.multiple, endTime: poll.expiresAt, votersCount: 0,
|
|
976
|
+
});
|
|
977
|
+
|
|
978
|
+
await podNotes.write(this.remote, question.id, question);
|
|
979
|
+
// Empty, but present: a dangling `replies` that 404s is worse than none.
|
|
980
|
+
await podNotes.writeEmptyReplies(this.remote, wire.repliesId(question.id),
|
|
981
|
+
wire.collection(wire.repliesId(question.id), []));
|
|
982
|
+
if (!priv) await this.recordOutbox(question.id);
|
|
983
|
+
this.store.addStatus({
|
|
984
|
+
noteId: question.id, actor: urls.actor, content: question.content, published,
|
|
985
|
+
kind: 'post', slug, text: content, visibility, poll, inReplyTo,
|
|
986
|
+
...(spoilerText ? { spoiler: spoilerText } : {}),
|
|
987
|
+
...(question.tag?.length ? { mentions: question.tag.map(t => ({ href: t.href, name: t.name })) } : {}),
|
|
988
|
+
});
|
|
989
|
+
|
|
990
|
+
const create = this._pollActivity('Create', question, wire.createActivityId(question.id));
|
|
991
|
+
await podNotes.writeCreate(this.remote, create.id, create);
|
|
992
|
+
const contacts = this.store.getContacts();
|
|
993
|
+
const inboxes = [...new Set([
|
|
994
|
+
...(visibility === 'direct' ? [] : contacts.followers.map(f => f.sharedInbox || f.inbox)),
|
|
995
|
+
...poll.mentionInboxes,
|
|
996
|
+
].filter(Boolean))];
|
|
997
|
+
await this.deliverer.deliverToAll(inboxes, create);
|
|
998
|
+
this.log(`poll published: ${question.id} (${titles.length} options) → ${inboxes.length} inbox(es)`);
|
|
999
|
+
return question;
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
// The Create or Update carrying a Question. The context is hoisted onto the
|
|
1003
|
+
// activity and the embedded object keeps none: a nested @context is legal
|
|
1004
|
+
// JSON-LD but not every server reads one, and votersCount is declared there.
|
|
1005
|
+
_pollActivity(type, question, id) {
|
|
1006
|
+
const { '@context': ctx, ...object } = question;
|
|
1007
|
+
return {
|
|
1008
|
+
'@context': ctx, id, type,
|
|
1009
|
+
actor: this.urls.actor,
|
|
1010
|
+
published: question.published,
|
|
1011
|
+
to: question.to, cc: question.cc,
|
|
1012
|
+
object,
|
|
1013
|
+
};
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
/**
|
|
1017
|
+
* One vote on one of OUR polls, named by the option's title. Returns true
|
|
1018
|
+
* when it counted — a second answer to a single-choice poll, an option we do
|
|
1019
|
+
* not offer, or a poll that has closed all count for nothing.
|
|
1020
|
+
*/
|
|
1021
|
+
async recordVote(questionId, actor, optionName) {
|
|
1022
|
+
const s = this.store.getStatuses().find(x => x.noteId === questionId);
|
|
1023
|
+
if (!s?.poll || s.kind !== 'post' || s.actor !== this.urls.actor) return false;
|
|
1024
|
+
if (polls.pollClosed(s.poll)) return false;
|
|
1025
|
+
const index = polls.optionIndex(s.poll, optionName);
|
|
1026
|
+
if (index < 0) return false;
|
|
1027
|
+
const all = this.store.read(polls.VOTES_DOC, {});
|
|
1028
|
+
const { roster, changed } = polls.addVote(all[questionId] || {}, polls.voterKey(actor), index,
|
|
1029
|
+
{ multiple: !!s.poll.multiple });
|
|
1030
|
+
if (!changed) return false;
|
|
1031
|
+
this.store.write(polls.VOTES_DOC, { ...all, [questionId]: roster });
|
|
1032
|
+
this.store.updateStatus(questionId, { poll: polls.withTally(s.poll, roster) });
|
|
1033
|
+
this._pollDirty(questionId);
|
|
1034
|
+
return true;
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
// Open the rewrite window for a poll whose count moved. Already open is
|
|
1038
|
+
// already enough: the whole point is that a burst costs one rewrite.
|
|
1039
|
+
_pollDirty(questionId) {
|
|
1040
|
+
if (this.pollTimers.has(questionId)) return;
|
|
1041
|
+
const t = setTimeout(() => {
|
|
1042
|
+
this.pollTimers.delete(questionId);
|
|
1043
|
+
this.republishPoll(questionId).catch(e => this.log(`poll rewrite: ${e.message}`));
|
|
1044
|
+
}, POLL_REWRITE_MS);
|
|
1045
|
+
t.unref?.();
|
|
1046
|
+
this.pollTimers.set(questionId, t);
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
/**
|
|
1050
|
+
* Write the poll's current count back to the pod and tell everyone who has
|
|
1051
|
+
* it. `closing` stamps it shut, which is a one-way door.
|
|
1052
|
+
*/
|
|
1053
|
+
async republishPoll(questionId, { closing = null } = {}) {
|
|
1054
|
+
const s = this.store.getStatuses().find(x => x.noteId === questionId);
|
|
1055
|
+
if (!s?.poll) return null;
|
|
1056
|
+
const { urls } = this;
|
|
1057
|
+
const roster = this.store.read(polls.VOTES_DOC, {})[questionId] || {};
|
|
1058
|
+
// A shut poll whose roster has been retired keeps the counts on its row:
|
|
1059
|
+
// deriving them from an empty roster would publish a poll nobody voted in.
|
|
1060
|
+
const shut = closing || s.poll.closed;
|
|
1061
|
+
const counted = shut && !Object.keys(roster).length ? s.poll : polls.withTally(s.poll, roster);
|
|
1062
|
+
const poll = { ...counted, ...(closing ? { closed: closing } : {}) };
|
|
1063
|
+
const container = String(s.noteId).startsWith(urls.privateNotes) ? urls.privateNotes : urls.notes;
|
|
1064
|
+
const slug = s.slug || String(s.noteId).slice(container.length);
|
|
1065
|
+
const mentions = (s.mentions || []).map(m => ({
|
|
1066
|
+
handle: String(m.name || '').replace(/^@/, ''), actor: m.href, page: null, inbox: null,
|
|
1067
|
+
}));
|
|
1068
|
+
const question = wire.questionDoc({
|
|
1069
|
+
urls, slug, content: s.text ?? '', published: s.published, inReplyTo: s.inReplyTo,
|
|
1070
|
+
attachments: [], mentions,
|
|
1071
|
+
visibility: s.visibility || 'public', summary: s.spoiler || null, container,
|
|
1072
|
+
options: poll.options, multiple: !!poll.multiple, endTime: poll.expiresAt,
|
|
1073
|
+
closed: poll.closed, votersCount: poll.votersCount || 0,
|
|
1074
|
+
});
|
|
1075
|
+
await podNotes.write(this.remote, question.id, question);
|
|
1076
|
+
// The Create is overwritten too, so a group's Announce resolves to the
|
|
1077
|
+
// current count rather than to the one the poll opened with.
|
|
1078
|
+
await podNotes.writeCreate(this.remote, wire.createActivityId(question.id),
|
|
1079
|
+
this._pollActivity('Create', question, wire.createActivityId(question.id)));
|
|
1080
|
+
this.store.updateStatus(questionId, { poll });
|
|
1081
|
+
|
|
1082
|
+
// Not `updated`: a changed count is not an edit, and stamping one would
|
|
1083
|
+
// have every client show the poll as edited each time somebody voted.
|
|
1084
|
+
//
|
|
1085
|
+
// The Update is named after the STATE it carries rather than the moment it
|
|
1086
|
+
// was sent. A clock only tells two rewrites apart when they fall in
|
|
1087
|
+
// different milliseconds, and a receiving server that has seen an activity
|
|
1088
|
+
// id drops the next one wearing it — which would quietly freeze the count.
|
|
1089
|
+
// Naming the state means an id changes exactly when there is something new
|
|
1090
|
+
// to say, and two sends of the same numbers are the duplicate they look
|
|
1091
|
+
// like.
|
|
1092
|
+
const stamp = crypto.createHash('sha256').update(JSON.stringify([
|
|
1093
|
+
poll.options.map(o => o.votes || 0), poll.votersCount || 0, poll.closed || '',
|
|
1094
|
+
])).digest('hex').slice(0, 12);
|
|
1095
|
+
const update = this._pollActivity('Update', question, `${question.id}#poll-${stamp}`);
|
|
1096
|
+
const contacts = this.store.getContacts();
|
|
1097
|
+
const inboxes = [...new Set([
|
|
1098
|
+
...(s.visibility === 'direct' ? [] : contacts.followers.map(f => f.sharedInbox || f.inbox)),
|
|
1099
|
+
...(s.poll.mentionInboxes || []),
|
|
1100
|
+
].filter(Boolean))];
|
|
1101
|
+
await this.deliverer.deliverToAll(inboxes, update);
|
|
1102
|
+
this.log(`poll ${closing ? 'closed' : 'count published'}: ${question.id} → ${inboxes.length} inbox(es)`);
|
|
1103
|
+
return poll;
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
/**
|
|
1107
|
+
* Shut any poll whose time is up. Called from the agent's sweep. Closing is
|
|
1108
|
+
* recorded BEFORE the republish, so a failed republish cannot leave a poll
|
|
1109
|
+
* open and collecting votes it has already refused.
|
|
1110
|
+
*/
|
|
1111
|
+
async closeDuePolls(now = Date.now()) {
|
|
1112
|
+
const due = this.store.getStatuses().filter(s => s.kind === 'post' && s.poll
|
|
1113
|
+
&& !s.poll.closed && s.poll.expiresAt && Date.parse(s.poll.expiresAt) <= now);
|
|
1114
|
+
for (const s of due) {
|
|
1115
|
+
const closed = new Date(Math.min(now, Date.parse(s.poll.expiresAt) || now)).toISOString();
|
|
1116
|
+
clearTimeout(this.pollTimers.get(s.noteId));
|
|
1117
|
+
this.pollTimers.delete(s.noteId);
|
|
1118
|
+
this.store.updateStatus(s.noteId, { poll: { ...s.poll, closed } });
|
|
1119
|
+
const done = await this.republishPoll(s.noteId, { closing: closed })
|
|
1120
|
+
.catch(e => { this.log(`poll close ${s.noteId}: ${e.message}`); return null; });
|
|
1121
|
+
// The roster only ever answered one question — has this person already
|
|
1122
|
+
// voted — and a shut poll has stopped asking it. Dropping it keeps a
|
|
1123
|
+
// document we serialize whole from carrying every poll's voters forever.
|
|
1124
|
+
if (done) {
|
|
1125
|
+
const all = this.store.read(polls.VOTES_DOC, {});
|
|
1126
|
+
if (all[s.noteId]) {
|
|
1127
|
+
delete all[s.noteId];
|
|
1128
|
+
this.store.write(polls.VOTES_DOC, all);
|
|
1129
|
+
}
|
|
1130
|
+
}
|
|
1131
|
+
}
|
|
1132
|
+
return due.length;
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
/** Stop the pending rewrite windows. Called at shutdown. */
|
|
1136
|
+
stopPolls() {
|
|
1137
|
+
for (const t of this.pollTimers.values()) clearTimeout(t);
|
|
1138
|
+
this.pollTimers.clear();
|
|
1139
|
+
}
|
|
1140
|
+
|
|
965
1141
|
// The pinned posts, as the actor's featured collection — the one document a
|
|
966
1142
|
// remote server reads when it shows this profile's pins.
|
|
967
1143
|
async publishFeatured() {
|
|
968
1144
|
const ids = this.store.getStatuses().filter(s => s.kind === 'post' && s.pinned).map(s => s.noteId);
|
|
969
|
-
await this.remote
|
|
970
|
-
await this.remote.setAcl(this.urls.featured, ['Read']);
|
|
1145
|
+
await podFeatured.write(this.remote, this.urls, wire.orderedCollection(this.urls.featured, ids));
|
|
971
1146
|
return ids.length;
|
|
972
1147
|
}
|
|
973
1148
|
|
|
@@ -996,14 +1171,8 @@ export class Publisher {
|
|
|
996
1171
|
attachments: atts, mentions, visibility: s.visibility || 'public',
|
|
997
1172
|
summary: spoilerText, updated, container,
|
|
998
1173
|
});
|
|
999
|
-
await this.remote
|
|
1000
|
-
await this.remote
|
|
1001
|
-
if (s.slug) {
|
|
1002
|
-
await this.local.writeNote('posts', s.slug, {
|
|
1003
|
-
noteId: note.id, actor: urls.actor, published: s.published, content: note.content,
|
|
1004
|
-
inReplyTo: s.inReplyTo, attachments: atts,
|
|
1005
|
-
});
|
|
1006
|
-
}
|
|
1174
|
+
await podNotes.write(this.remote, note.id, note);
|
|
1175
|
+
await podNotes.writeCreate(this.remote, wire.createActivityId(note.id), wire.createActivity(note, urls));
|
|
1007
1176
|
const patched = this.store.updateStatus(s.noteId, {
|
|
1008
1177
|
content: note.content, text: content, editedAt: updated,
|
|
1009
1178
|
spoiler: spoilerText || undefined,
|