fedipod 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +123 -0
- package/architecture.svg +89 -0
- package/bin/fedipod.mjs +2303 -0
- package/cli.md +243 -0
- package/groups.md +85 -0
- package/gui.md +99 -0
- package/lib/account.mjs +87 -0
- package/lib/admin.mjs +1606 -0
- package/lib/atproto.mjs +310 -0
- package/lib/bskyfeed.mjs +166 -0
- package/lib/bskygroup.mjs +162 -0
- package/lib/c2s.mjs +305 -0
- package/lib/certs.mjs +265 -0
- package/lib/deliver.mjs +249 -0
- package/lib/directory.mjs +90 -0
- package/lib/embed.mjs +214 -0
- package/lib/export-collections.mjs +161 -0
- package/lib/front-core.mjs +322 -0
- package/lib/gateway-core.mjs +115 -0
- package/lib/guard.mjs +213 -0
- package/lib/home.mjs +161 -0
- package/lib/httpsig.mjs +109 -0
- package/lib/import.mjs +357 -0
- package/lib/intake.mjs +1710 -0
- package/lib/keys.mjs +139 -0
- package/lib/lease.mjs +190 -0
- package/lib/localapi.mjs +87 -0
- package/lib/mastoapi.mjs +1588 -0
- package/lib/migrate.mjs +127 -0
- package/lib/oidc-auth.mjs +53 -0
- package/lib/podrdf.mjs +132 -0
- package/lib/ports.mjs +27 -0
- package/lib/publisher.mjs +1003 -0
- package/lib/remote.mjs +303 -0
- package/lib/safefetch.mjs +235 -0
- package/lib/setup.mjs +262 -0
- package/lib/social.mjs +505 -0
- package/lib/storage.mjs +170 -0
- package/lib/store.mjs +470 -0
- package/lib/streaming.mjs +154 -0
- package/lib/tagfeed.mjs +158 -0
- package/lib/ua.mjs +29 -0
- package/lib/update.mjs +91 -0
- package/lib/webpush.mjs +98 -0
- package/lib/wire.mjs +762 -0
- package/package.json +60 -0
- package/phanpy/dist/404.html +40 -0
- package/phanpy/dist/_headers +2 -0
- package/phanpy/dist/apple-touch-icon.png +0 -0
- package/phanpy/dist/assets/ICONS-DIPJeU0C.js +2 -0
- package/phanpy/dist/assets/ICONS-DIPJeU0C.js.map +1 -0
- package/phanpy/dist/assets/ScheduledAtField-D-Abfo4J.js +183 -0
- package/phanpy/dist/assets/ScheduledAtField-D-Abfo4J.js.map +1 -0
- package/phanpy/dist/assets/Temml-CCjoacWt.woff2 +0 -0
- package/phanpy/dist/assets/__vite-browser-external-C7iut881.js +2 -0
- package/phanpy/dist/assets/__vite-browser-external-C7iut881.js.map +1 -0
- package/phanpy/dist/assets/boosts-carousel-BiOaNdMT.jpg +0 -0
- package/phanpy/dist/assets/catch-up-O5q5C75Z.png +0 -0
- package/phanpy/dist/assets/chunk-aKtaBQYM.js +1 -0
- package/phanpy/dist/assets/compose-CFEx7wb4.js +2 -0
- package/phanpy/dist/assets/compose-CFEx7wb4.js.map +1 -0
- package/phanpy/dist/assets/compose-ZJN5xSb4.js +35 -0
- package/phanpy/dist/assets/compose-ZJN5xSb4.js.map +1 -0
- package/phanpy/dist/assets/debug.module-YFQgwnIr.js +52 -0
- package/phanpy/dist/assets/debug.module-YFQgwnIr.js.map +1 -0
- package/phanpy/dist/assets/dom-p1YcJ5Rw.js +2 -0
- package/phanpy/dist/assets/dom-p1YcJ5Rw.js.map +1 -0
- package/phanpy/dist/assets/home-mobile-dark@2x-Dc-J8-SM.png +0 -0
- package/phanpy/dist/assets/home-mobile-light@2x-Bd3fod8m.png +0 -0
- package/phanpy/dist/assets/home-tablet-dark@2x-BgcNI8le.png +0 -0
- package/phanpy/dist/assets/home-tablet-light@2x-BW2RV9T6.png +0 -0
- package/phanpy/dist/assets/icons/add-circle-line-WD0mwORD.js +1 -0
- package/phanpy/dist/assets/icons/alert-line-CpFUMf9s.js +1 -0
- package/phanpy/dist/assets/icons/android-2-line-Bzxnrd3S.js +1 -0
- package/phanpy/dist/assets/icons/announcement-line-5nwaJFJm.js +1 -0
- package/phanpy/dist/assets/icons/arrow-down-circle-line-a7Id9-Iv.js +1 -0
- package/phanpy/dist/assets/icons/arrow-down-line-B8uzXzq6.js +1 -0
- package/phanpy/dist/assets/icons/arrow-left-line-CGrGT9C-.js +1 -0
- package/phanpy/dist/assets/icons/arrow-right-line-CE-HERfX.js +1 -0
- package/phanpy/dist/assets/icons/arrow-to-up-line-CvdQjmcy.js +1 -0
- package/phanpy/dist/assets/icons/arrow-up-circle-line-DEparSNF.js +1 -0
- package/phanpy/dist/assets/icons/arrow-up-line-C7yBeugc.js +1 -0
- package/phanpy/dist/assets/icons/arrows-right-line-BtbXmJGP.js +1 -0
- package/phanpy/dist/assets/icons/at-line-CF66Bznk.js +1 -0
- package/phanpy/dist/assets/icons/attachment-line-BY8It-l9.js +1 -0
- package/phanpy/dist/assets/icons/blockquote-line-BS6pU_hm.js +1 -0
- package/phanpy/dist/assets/icons/board-line-C0UWXyA6.js +1 -0
- package/phanpy/dist/assets/icons/bookmark-line-1CtX5JLF.js +1 -0
- package/phanpy/dist/assets/icons/building-5-line-Dd59MzbO.js +1 -0
- package/phanpy/dist/assets/icons/bus-2-line-6_dvA0GG.js +1 -0
- package/phanpy/dist/assets/icons/calendar-day-line-Crpl_fmu.js +1 -0
- package/phanpy/dist/assets/icons/calendar-month-line-vommts2C.js +1 -0
- package/phanpy/dist/assets/icons/calendar-time-add-line-BU_DTlQk.js +1 -0
- package/phanpy/dist/assets/icons/camera-line-Cv69MEei.js +1 -0
- package/phanpy/dist/assets/icons/celebrate-line-B8DvIoi0.js +1 -0
- package/phanpy/dist/assets/icons/chart-bar-line-FK-QOmtF.js +1 -0
- package/phanpy/dist/assets/icons/chart-line-line-BGcJAFWw.js +1 -0
- package/phanpy/dist/assets/icons/chat-3-line-YFK2-hwz.js +1 -0
- package/phanpy/dist/assets/icons/check-circle-line-DsAxFr5-.js +1 -0
- package/phanpy/dist/assets/icons/clipboard-line-BBp364gJ.js +1 -0
- package/phanpy/dist/assets/icons/close-circle-line-CICdiuGz.js +1 -0
- package/phanpy/dist/assets/icons/close-line-DYrFWcjv.js +1 -0
- package/phanpy/dist/assets/icons/cloud-line-tiQAbah8.js +1 -0
- package/phanpy/dist/assets/icons/code-line-fAl8iiV3.js +1 -0
- package/phanpy/dist/assets/icons/comment-2-line-CR3g7WeB.js +1 -0
- package/phanpy/dist/assets/icons/copy-2-line-CLW69yF5.js +1 -0
- package/phanpy/dist/assets/icons/delete-2-line-B8xmbr4n.js +1 -0
- package/phanpy/dist/assets/icons/document-3-line-DBGNhAg2.js +1 -0
- package/phanpy/dist/assets/icons/document-line-C7mb9sb7.js +1 -0
- package/phanpy/dist/assets/icons/down-line-BGqSSThd.js +1 -0
- package/phanpy/dist/assets/icons/edit-4-line-DcgGaEhx.js +1 -0
- package/phanpy/dist/assets/icons/emoji-2-line-D8YgnM-y.js +1 -0
- package/phanpy/dist/assets/icons/exit-line-Dc07BS-x.js +1 -0
- package/phanpy/dist/assets/icons/external-link-line-BvpSkmjO.js +1 -0
- package/phanpy/dist/assets/icons/eye-2-line-CiWXx-f_.js +1 -0
- package/phanpy/dist/assets/icons/eye-close-line-BgTaSJ4P.js +1 -0
- package/phanpy/dist/assets/icons/filter-2-line-B_mR2usy.js +1 -0
- package/phanpy/dist/assets/icons/filter-line-C_nm-3cU.js +1 -0
- package/phanpy/dist/assets/icons/flag-1-line-C2HB05I0.js +1 -0
- package/phanpy/dist/assets/icons/forbid-circle-line-D3LTT9yd.js +1 -0
- package/phanpy/dist/assets/icons/formula-line-DLrt2d6j.js +1 -0
- package/phanpy/dist/assets/icons/grid-line-CgUmEikf.js +1 -0
- package/phanpy/dist/assets/icons/group-line-C5fnX0wU.js +1 -0
- package/phanpy/dist/assets/icons/hand-finger-2-line-BGgxbuL9.js +1 -0
- package/phanpy/dist/assets/icons/hashtag-line-CyTEEXBb.js +1 -0
- package/phanpy/dist/assets/icons/heart-crack-line-BZddbO2C.js +1 -0
- package/phanpy/dist/assets/icons/heart-line-CJ7K47J4.js +1 -0
- package/phanpy/dist/assets/icons/history-2-line-kWWvFzqv.js +1 -0
- package/phanpy/dist/assets/icons/history-line-bGQiwbTH.js +1 -0
- package/phanpy/dist/assets/icons/home-3-line-fRU1zeLG.js +1 -0
- package/phanpy/dist/assets/icons/information-line-iNk6-sOY.js +1 -0
- package/phanpy/dist/assets/icons/keyboard-line-DsCyuQNz.js +1 -0
- package/phanpy/dist/assets/icons/layout-4-line-CZkOX52Z.js +1 -0
- package/phanpy/dist/assets/icons/layout-5-line-B_znt5wO.js +1 -0
- package/phanpy/dist/assets/icons/left-line-eUw0tya4.js +1 -0
- package/phanpy/dist/assets/icons/lightning-line-DaKoDdix.js +1 -0
- package/phanpy/dist/assets/icons/link-2-line-DCqLmMA7.js +1 -0
- package/phanpy/dist/assets/icons/list-check-line-BAxosBh9.js +1 -0
- package/phanpy/dist/assets/icons/lock-line-CPoaXeUW.js +1 -0
- package/phanpy/dist/assets/icons/mail-line-B0P8aa1L.js +1 -0
- package/phanpy/dist/assets/icons/moon-line-CEqNAToP.js +1 -0
- package/phanpy/dist/assets/icons/more-1-fill-Dw2e5xab.js +1 -0
- package/phanpy/dist/assets/icons/more-3-line-BIiVjYTz.js +1 -0
- package/phanpy/dist/assets/icons/notification-line-BZZq2Gtu.js +1 -0
- package/phanpy/dist/assets/icons/pencil-line-CKh8-A1v.js +1 -0
- package/phanpy/dist/assets/icons/photo-album-line-Bnfg2u40.js +1 -0
- package/phanpy/dist/assets/icons/pin-line-DpTolyhs.js +1 -0
- package/phanpy/dist/assets/icons/play-fill-B0-G_8lX.js +1 -0
- package/phanpy/dist/assets/icons/qrcode-2-line-B6SPCaHn.js +1 -0
- package/phanpy/dist/assets/icons/quill-pen-line-B6LUrnCP.js +1 -0
- package/phanpy/dist/assets/icons/quote-left-fill-hQZ1cj_6.js +1 -0
- package/phanpy/dist/assets/icons/radar-line-CKyroTth.js +1 -0
- package/phanpy/dist/assets/icons/react-line-CoIKhhtK.js +1 -0
- package/phanpy/dist/assets/icons/refresh-2-line-D0mTF97S.js +1 -0
- package/phanpy/dist/assets/icons/right-line-B3KWJaqc.js +1 -0
- package/phanpy/dist/assets/icons/rocket-line-4KZl537D.js +1 -0
- package/phanpy/dist/assets/icons/round-fill-C69NTrZ0.js +1 -0
- package/phanpy/dist/assets/icons/round-line-B9k_1uo-.js +1 -0
- package/phanpy/dist/assets/icons/route-line-B28ReHcE.js +1 -0
- package/phanpy/dist/assets/icons/rows-4-line-CrBniagM.js +1 -0
- package/phanpy/dist/assets/icons/scan-line-DSf74YdP.js +1 -0
- package/phanpy/dist/assets/icons/search-2-line-B6geIU1z.js +1 -0
- package/phanpy/dist/assets/icons/settings-3-line-azJwK47w.js +1 -0
- package/phanpy/dist/assets/icons/settings-6-line-C_5zc5YR.js +1 -0
- package/phanpy/dist/assets/icons/share-2-line-D5MxWjrm.js +1 -0
- package/phanpy/dist/assets/icons/share-forward-line-Cp61fZzZ.js +1 -0
- package/phanpy/dist/assets/icons/sparkles-2-line-DYYyXTaW.js +1 -0
- package/phanpy/dist/assets/icons/sparkles-line-C2hY1s6I.js +1 -0
- package/phanpy/dist/assets/icons/time-line-BF3eet1v.js +1 -0
- package/phanpy/dist/assets/icons/transfer-4-line-oisfvTBw.js +1 -0
- package/phanpy/dist/assets/icons/translate-line-BFWol2Ce.js +1 -0
- package/phanpy/dist/assets/icons/unlock-line-X_x5vwv3.js +1 -0
- package/phanpy/dist/assets/icons/upload-3-line-DgzwUJeW.js +1 -0
- package/phanpy/dist/assets/icons/user-4-line-BVSynZCp.js +1 -0
- package/phanpy/dist/assets/icons/user-add-2-line-BlGS5Q8q.js +1 -0
- package/phanpy/dist/assets/icons/user-add-line-BbXdHG4z.js +1 -0
- package/phanpy/dist/assets/icons/user-edit-line-BFAHOV0X.js +1 -0
- package/phanpy/dist/assets/icons/user-follow-line-BXux0wSd.js +1 -0
- package/phanpy/dist/assets/icons/user-star-line-DdYlpHLU.js +1 -0
- package/phanpy/dist/assets/icons/user-warning-line-Cj43j0_E.js +1 -0
- package/phanpy/dist/assets/icons/user-x-line-DIDgNlPH.js +1 -0
- package/phanpy/dist/assets/icons/volume-line-Bs390KQt.js +1 -0
- package/phanpy/dist/assets/icons/volume-mute-line-DzlzIqFZ.js +1 -0
- package/phanpy/dist/assets/icons/walk-line-yNXfB-md.js +1 -0
- package/phanpy/dist/assets/icons/world-2-line-CenkGIwj.js +1 -0
- package/phanpy/dist/assets/icons/zoom-in-line-CaENUphf.js +1 -0
- package/phanpy/dist/assets/icons/zoom-out-line-BDNk0EWo.js +1 -0
- package/phanpy/dist/assets/instances-BNPptnn-.json +333 -0
- package/phanpy/dist/assets/locales/ar-SA-CfheRbCu.js +1 -0
- package/phanpy/dist/assets/locales/ca-ES-DHRW86Tz.js +1 -0
- package/phanpy/dist/assets/locales/cs-CZ-CKoqEuA1.js +1 -0
- package/phanpy/dist/assets/locales/de-DE-ByRPMRFy.js +1 -0
- package/phanpy/dist/assets/locales/eo-UY-CPC7wLCS.js +1 -0
- package/phanpy/dist/assets/locales/es-ES-dJa0Fd9t.js +1 -0
- package/phanpy/dist/assets/locales/eu-ES-C3djlPR2.js +1 -0
- package/phanpy/dist/assets/locales/fa-IR-DTT490Ck.js +1 -0
- package/phanpy/dist/assets/locales/fi-FI-B23MCPuz.js +1 -0
- package/phanpy/dist/assets/locales/fr-FR-BcaQsO5C.js +1 -0
- package/phanpy/dist/assets/locales/gl-ES-BR-CVKBN.js +1 -0
- package/phanpy/dist/assets/locales/he-IL-CfheRbCu.js +1 -0
- package/phanpy/dist/assets/locales/hu-HU-CfheRbCu.js +1 -0
- package/phanpy/dist/assets/locales/it-IT-C1136UFV.js +1 -0
- package/phanpy/dist/assets/locales/ja-JP-CRZmRAhD.js +1 -0
- package/phanpy/dist/assets/locales/kab-Cz8xH_TT.js +1 -0
- package/phanpy/dist/assets/locales/ko-KR-CG9UiY4o.js +1 -0
- package/phanpy/dist/assets/locales/lt-LT-CJcI6sHZ.js +1 -0
- package/phanpy/dist/assets/locales/nb-NO-CfheRbCu.js +1 -0
- package/phanpy/dist/assets/locales/nl-NL-DxN4Yc1t.js +1 -0
- package/phanpy/dist/assets/locales/oc-FR-CfheRbCu.js +1 -0
- package/phanpy/dist/assets/locales/pl-PL-DkwGXf6P.js +1 -0
- package/phanpy/dist/assets/locales/pseudo-LOCALE-CQ4XxkQ0.js +1 -0
- package/phanpy/dist/assets/locales/pt-BR-nGL0HdFS.js +1 -0
- package/phanpy/dist/assets/locales/pt-PT-Cp17aj2z.js +1 -0
- package/phanpy/dist/assets/locales/ru-RU-0Y8cIoaK.js +1 -0
- package/phanpy/dist/assets/locales/th-TH-CfheRbCu.js +1 -0
- package/phanpy/dist/assets/locales/tok-CfheRbCu.js +1 -0
- package/phanpy/dist/assets/locales/tr-TR-CfheRbCu.js +1 -0
- package/phanpy/dist/assets/locales/uk-UA-C5E3dFBf.js +1 -0
- package/phanpy/dist/assets/locales/zh-CN-BHJi7_et.js +1 -0
- package/phanpy/dist/assets/locales/zh-TW-CfheRbCu.js +1 -0
- package/phanpy/dist/assets/main-BdqNbG-a.js +26 -0
- package/phanpy/dist/assets/main-BdqNbG-a.js.map +1 -0
- package/phanpy/dist/assets/mock-home-DGhR__He.js +2 -0
- package/phanpy/dist/assets/mock-home-DGhR__He.js.map +1 -0
- package/phanpy/dist/assets/multi-column-ROck0NVt.jpg +0 -0
- package/phanpy/dist/assets/multi-hashtag-timeline-DH5INVHi.jpg +0 -0
- package/phanpy/dist/assets/nested-comments-thread-hv59kZLc.jpg +0 -0
- package/phanpy/dist/assets/polyfill-force-H8sUkLGp.js +2 -0
- package/phanpy/dist/assets/polyfill-force-H8sUkLGp.js.map +1 -0
- package/phanpy/dist/assets/pwa-viewport-ogKbF51K.js +20 -0
- package/phanpy/dist/assets/pwa-viewport-ogKbF51K.js.map +1 -0
- package/phanpy/dist/assets/style-B1iLPTPR.css +2 -0
- package/phanpy/dist/assets/temml-DuyIQMei.js +97 -0
- package/phanpy/dist/assets/temml-DuyIQMei.js.map +1 -0
- package/phanpy/dist/assets/tinyld.light.node-DtbBhLYC.js +2 -0
- package/phanpy/dist/assets/tinyld.light.node-DtbBhLYC.js.map +1 -0
- package/phanpy/dist/assets/year-in-posts-B2lywqdx.png +0 -0
- package/phanpy/dist/assets/year-in-posts-DJv_star.js +3 -0
- package/phanpy/dist/assets/year-in-posts-DJv_star.js.map +1 -0
- package/phanpy/dist/compose/index.html +20 -0
- package/phanpy/dist/favicon.ico +0 -0
- package/phanpy/dist/index.html +73 -0
- package/phanpy/dist/logo-192.png +0 -0
- package/phanpy/dist/logo-512.png +0 -0
- package/phanpy/dist/logo-badge-72.png +0 -0
- package/phanpy/dist/logo-maskable-512.png +0 -0
- package/phanpy/dist/logo-monochrome-512.png +0 -0
- package/phanpy/dist/logo-monochrome-maskable-512.png +0 -0
- package/phanpy/dist/manifest.webmanifest +1 -0
- package/phanpy/dist/og-image-2.jpg +0 -0
- package/phanpy/dist/og-image.png +0 -0
- package/phanpy/dist/robots.txt +2 -0
- package/phanpy/dist/sw.js +25 -0
- package/phanpy/dist/version.json +1 -0
- package/run-agent.mjs +805 -0
- package/scripts/build-dist.mjs +37 -0
- package/scripts/verify-ui.mjs +60 -0
- package/ui/README.md +23 -0
- package/vendor/gate.cjs +132 -0
- package/vendor/idp-grant.cjs +394 -0
- package/vendor/jfetch.cjs +16 -0
- package/web/admin/admin.js +958 -0
- package/web/admin/bar.css +64 -0
- package/web/admin/bar.js +61 -0
- package/web/admin/client/client.js +66 -0
- package/web/admin/client/index.html +42 -0
- package/web/admin/index.html +493 -0
- package/web/admin/setup/index.html +172 -0
- package/web/admin/setup/setup.js +268 -0
- package/web/admin/window.css +55 -0
- package/web/admin/window.js +136 -0
- package/web/front/install.sh +69 -0
- package/web/front/new-account.html +424 -0
- package/web/front/run.html +138 -0
- package/web/front/solid-client-authn.bundle.js +2 -0
package/lib/wire.mjs
ADDED
|
@@ -0,0 +1,762 @@
|
|
|
1
|
+
// wire.mjs — builders for the opaque AS2/JSON wire documents the remote pod
|
|
2
|
+
// serves (see claude/plans/ap-pod-mapping.md, "Remote pod layout"). These are
|
|
3
|
+
// protocol documents, not our RDF.
|
|
4
|
+
|
|
5
|
+
export const AS_CTX = 'https://www.w3.org/ns/activitystreams';
|
|
6
|
+
export const SEC_CTX = 'https://w3id.org/security/v1';
|
|
7
|
+
export const PUBLIC = 'https://www.w3.org/ns/activitystreams#Public';
|
|
8
|
+
|
|
9
|
+
// URLs of the actor's documents on the remote pod. Everything the agent owns
|
|
10
|
+
// nests under ONE top-level container (`root`) so the pod stays tidy and one
|
|
11
|
+
// pod could host several actors under different roots. Webfinger + host-meta
|
|
12
|
+
// stay at the pod root — fediverse discovery requires the host root.
|
|
13
|
+
// The AP URLs for an identity. Normally everything hangs off the pod home.
|
|
14
|
+
//
|
|
15
|
+
// When `publicBase` is given (a fronted identity, e.g.
|
|
16
|
+
// "https://fedipod.net/u/me/"), the ADVERTISED ids — the actor and everything
|
|
17
|
+
// under `ap/` a remote server sees — build on the fronted home instead, while
|
|
18
|
+
// the pod-side trees (`fediverse/`, `ap-state/`) and the write targets stay on
|
|
19
|
+
// the pod. `toPod`/`toPublic` map between the two spaces; `RemotePod` applies
|
|
20
|
+
// `toPod` at its one request choke point, so publisher/social/intake keep
|
|
21
|
+
// passing advertised ids and the writes still land on the pod. With no
|
|
22
|
+
// `publicBase` the returned object is byte-identical to before — the invariant
|
|
23
|
+
// the smoke suite pins.
|
|
24
|
+
export function apUrls(remotePod, root = 'activitypods-js/', { publicBase = null } = {}) {
|
|
25
|
+
const base = remotePod.endsWith('/') ? remotePod : remotePod + '/';
|
|
26
|
+
const home = base + (!root || root.endsWith('/') ? root : root + '/');
|
|
27
|
+
// The face a remote sees: the fronted home, or the pod home when unfronted.
|
|
28
|
+
const face = publicBase ? (publicBase.endsWith('/') ? publicBase : publicBase + '/') : home;
|
|
29
|
+
const urls = {
|
|
30
|
+
base, home,
|
|
31
|
+
webfinger: base + '.well-known/webfinger',
|
|
32
|
+
actor: face + 'ap/actor',
|
|
33
|
+
inbox: face + 'ap/inbox/',
|
|
34
|
+
outbox: face + 'ap/outbox',
|
|
35
|
+
followers: face + 'ap/followers',
|
|
36
|
+
following: face + 'ap/following',
|
|
37
|
+
notes: face + 'ap/notes/',
|
|
38
|
+
privateNotes: face + 'ap/private/',
|
|
39
|
+
featured: face + 'ap/featured',
|
|
40
|
+
// FEP-1b12: the moderator roster a recipient validates announced
|
|
41
|
+
// moderation against. Published only when moderators are configured.
|
|
42
|
+
moderators: face + 'ap/moderators',
|
|
43
|
+
// FEP-4ccd and FEP-c648: follows in limbo and the block list, as
|
|
44
|
+
// collections. They live in the private container so the owner-only ACL
|
|
45
|
+
// is inherited, not re-stated per document.
|
|
46
|
+
pendingFollowers: face + 'ap/private/pending-followers',
|
|
47
|
+
pendingFollowing: face + 'ap/private/pending-following',
|
|
48
|
+
blocked: face + 'ap/private/blocked',
|
|
49
|
+
profileHtml: face + 'ap/profile.html',
|
|
50
|
+
// Media stays on the pod even when fronted: attachment urls are not
|
|
51
|
+
// identity-checked by remotes, and proxying blobs would be pure cost.
|
|
52
|
+
media: home + 'ap/media/',
|
|
53
|
+
fediverse: home + 'fediverse/',
|
|
54
|
+
state: home + 'ap-state/',
|
|
55
|
+
};
|
|
56
|
+
if (publicBase) {
|
|
57
|
+
urls.podHome = home;
|
|
58
|
+
urls.publicHome = face;
|
|
59
|
+
urls.toPod = (u) => (typeof u === 'string' && u.startsWith(face) ? home + u.slice(face.length) : u);
|
|
60
|
+
urls.toPublic = (u) => (typeof u === 'string' && u.startsWith(home) ? face + u.slice(home.length) : u);
|
|
61
|
+
}
|
|
62
|
+
return urls;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// The host a handle would resolve through, or null when no handle can point
|
|
66
|
+
// here. @name@host is looked up at https://host/.well-known/webfinger, so only
|
|
67
|
+
// a pod that owns the root of its host can answer for one; a pod living at
|
|
68
|
+
// https://server/name/ may publish the document but nothing will ever ask.
|
|
69
|
+
export function webfingerHost(podUrl) {
|
|
70
|
+
const u = new URL(podUrl);
|
|
71
|
+
return u.pathname === '/' ? u.host : null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// RFC 6415 host-meta: several fediverse implementations discover WebFinger
|
|
75
|
+
// through this LRDD template rather than hitting /.well-known/webfinger
|
|
76
|
+
// directly.
|
|
77
|
+
export function hostMeta(base) {
|
|
78
|
+
return `<?xml version="1.0" encoding="UTF-8"?>\n<XRD xmlns="http://docs.oasis-open.org/ns/xri/xrd-1.0">\n <Link rel="lrdd" template="${base}.well-known/webfinger?resource={uri}"/>\n</XRD>\n`;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function jrd({ handle, host, actor }) {
|
|
82
|
+
return {
|
|
83
|
+
subject: `acct:${handle}@${host}`,
|
|
84
|
+
links: [{ rel: 'self', type: 'application/activity+json', href: actor }],
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// `kind: 'group'` publishes a Group rather than a Person, which is what makes
|
|
89
|
+
// Mastodon and Lemmy treat the actor as a community you join.
|
|
90
|
+
export function actorDoc({ urls, handle, name, publicKeyPem, movedTo = null, kind = 'person',
|
|
91
|
+
approveJoins = false, summary = null, icon = null, image = null, fields = [],
|
|
92
|
+
webId = null, aliases = [], moderators = null, pendingFollowers = null, pendingFollowing = null,
|
|
93
|
+
blocked = null, inbox = null }) {
|
|
94
|
+
// manuallyApprovesFollowers is NOT in the base AS2 context, so it is declared
|
|
95
|
+
// inline exactly as Mastodon declares it — and only when we actually use it.
|
|
96
|
+
// It is what makes a client show "Request to follow" rather than "Follow" and
|
|
97
|
+
// then sit in Requested with no explanation.
|
|
98
|
+
// PropertyValue is the same case: schema.org's, declared the way Mastodon
|
|
99
|
+
// declares it, and only when there are fields to carry. `image` (the banner)
|
|
100
|
+
// and `attachment` are both plain AS2 and need no declaration.
|
|
101
|
+
const context = [AS_CTX, SEC_CTX];
|
|
102
|
+
// `featured` is Mastodon's term for the pinned-posts collection, declared
|
|
103
|
+
// inline exactly as Mastodon declares it.
|
|
104
|
+
context.push({ toot: 'http://joinmastodon.org/ns#', featured: { '@id': 'toot:featured', '@type': '@id' } });
|
|
105
|
+
if (approveJoins) context.push({ manuallyApprovesFollowers: 'as:manuallyApprovesFollowers' });
|
|
106
|
+
// FEP-4ccd and FEP-c648 terms, declared exactly as those FEPs declare them,
|
|
107
|
+
// and only when the collections are actually published.
|
|
108
|
+
if (pendingFollowers || pendingFollowing) {
|
|
109
|
+
context.push({
|
|
110
|
+
pdg: 'https://purl.archive.org/socialweb/pending#',
|
|
111
|
+
pendingFollowers: { '@id': 'pdg:pendingFollowers', '@type': '@id' },
|
|
112
|
+
pendingFollowing: { '@id': 'pdg:pendingFollowing', '@type': '@id' },
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
if (blocked) {
|
|
116
|
+
context.push({
|
|
117
|
+
bl: 'https://purl.archive.org/socialweb/blocked#',
|
|
118
|
+
blocked: { '@id': 'bl:blocked', '@type': '@id' },
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
// The actor's other identities: the account's WebID, plus any aliases —
|
|
122
|
+
// old accounts elsewhere whose servers check for their own URL here before
|
|
123
|
+
// they will send a Move. Declared inline exactly as Mastodon declares it.
|
|
124
|
+
if (webId || aliases.length) context.push({ alsoKnownAs: { '@id': 'as:alsoKnownAs', '@type': '@id' } });
|
|
125
|
+
if (fields.length) {
|
|
126
|
+
context.push({ schema: 'http://schema.org#', PropertyValue: 'schema:PropertyValue', value: 'schema:value' });
|
|
127
|
+
}
|
|
128
|
+
return {
|
|
129
|
+
'@context': context,
|
|
130
|
+
id: urls.actor,
|
|
131
|
+
type: kind === 'group' ? 'Group' : 'Person',
|
|
132
|
+
...(approveJoins ? { manuallyApprovesFollowers: true } : {}),
|
|
133
|
+
...(movedTo ? { movedTo } : {}),
|
|
134
|
+
...(webId || aliases.length ? { alsoKnownAs: [...(webId ? [webId] : []), ...aliases] } : {}),
|
|
135
|
+
preferredUsername: handle,
|
|
136
|
+
name: name || handle,
|
|
137
|
+
// The bio, and the avatar. For a group, `summary` is where what the group
|
|
138
|
+
// is for goes — there is nowhere else for it.
|
|
139
|
+
...(summary ? { summary: contentHtml(summary) } : {}),
|
|
140
|
+
...(icon ? { icon: { type: 'Image', url: icon } } : {}),
|
|
141
|
+
// The banner behind the avatar, and the labelled rows a client shows under
|
|
142
|
+
// the bio — what Mastodon's profile editor calls Header picture and Extra
|
|
143
|
+
// fields. Both are what every other server publishes for the same thing.
|
|
144
|
+
...(image ? { image: { type: 'Image', url: image } } : {}),
|
|
145
|
+
// Escaped, not run through contentHtml: a field value is one inline row,
|
|
146
|
+
// and wrapping it in <p> renders as a paragraph inside a table cell.
|
|
147
|
+
...(fields.length ? {
|
|
148
|
+
attachment: fields.map(f => ({
|
|
149
|
+
type: 'PropertyValue',
|
|
150
|
+
name: String(f.name).replace(/[&<>]/g, c => HTML_ESCAPES[c]),
|
|
151
|
+
value: String(f.value).replace(/[&<>]/g, c => HTML_ESCAPES[c]),
|
|
152
|
+
})),
|
|
153
|
+
} : {}),
|
|
154
|
+
// The human page. Remote servers send profile clicks here, so it must be
|
|
155
|
+
// something a person can read — the pod root is not.
|
|
156
|
+
url: urls.profileHtml,
|
|
157
|
+
// Normally the pod inbox (which buffers with nothing running). When an
|
|
158
|
+
// inbox gateway is advertised, deliveries go to it instead — it verifies
|
|
159
|
+
// and forwards into this same pod inbox for the drain.
|
|
160
|
+
inbox: inbox || urls.inbox,
|
|
161
|
+
// Every Mastodon actor publishes one. With a single actor per pod ours is
|
|
162
|
+
// just the inbox, but its absence is the non-standard thing.
|
|
163
|
+
endpoints: { sharedInbox: inbox || urls.inbox },
|
|
164
|
+
outbox: urls.outbox,
|
|
165
|
+
// The pinned posts, as the collection other servers read when they show
|
|
166
|
+
// this profile. Mastodon's term, declared the way Mastodon declares it.
|
|
167
|
+
featured: urls.featured,
|
|
168
|
+
followers: urls.followers,
|
|
169
|
+
following: urls.following,
|
|
170
|
+
// FEP-1b12: attributedTo names the moderators collection; recipients
|
|
171
|
+
// validate a group's announced moderation against it.
|
|
172
|
+
...(moderators ? { attributedTo: moderators } : {}),
|
|
173
|
+
...(pendingFollowers ? { pendingFollowers } : {}),
|
|
174
|
+
...(pendingFollowing ? { pendingFollowing } : {}),
|
|
175
|
+
...(blocked ? { blocked } : {}),
|
|
176
|
+
publicKey: { id: urls.actor + '#main-key', owner: urls.actor, publicKeyPem },
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// The human half of the actor: a page a browser can open, published beside
|
|
181
|
+
// the actor document. It says who this is and how to follow — the handle to
|
|
182
|
+
// paste, and the remote-follow control that hands the visitor to their own
|
|
183
|
+
// server's follow screen.
|
|
184
|
+
export function profilePageHtml({ name, address, summary = null, icon = null, kind = 'person' }) {
|
|
185
|
+
const esc = (s) => String(s ?? '').replace(/[&<>"]/g, c => ({ ...HTML_ESCAPES, '"': '"' }[c]));
|
|
186
|
+
const what = kind === 'group' ? 'a group' : 'an account';
|
|
187
|
+
return `<!doctype html>
|
|
188
|
+
<html lang="en">
|
|
189
|
+
<head>
|
|
190
|
+
<meta charset="utf-8">
|
|
191
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
192
|
+
<title>${esc(name)} (${esc(address)})</title>
|
|
193
|
+
<style>
|
|
194
|
+
:root { color-scheme: light dark; }
|
|
195
|
+
body { font: 112.5%/1.5 system-ui, sans-serif; max-width: 34rem; margin: 3rem auto; padding: 0 1rem; }
|
|
196
|
+
img.avatar { width: 6rem; height: 6rem; border-radius: 1rem; object-fit: cover; }
|
|
197
|
+
h1 { margin: .5rem 0 0; }
|
|
198
|
+
.address { font-size: 1.1rem; user-select: all; }
|
|
199
|
+
form { margin-top: 2rem; }
|
|
200
|
+
label { display: block; margin-bottom: .3rem; }
|
|
201
|
+
input { font: inherit; padding: .5rem; width: 14rem; max-width: 100%; }
|
|
202
|
+
button { font: inherit; padding: .5rem 1rem; }
|
|
203
|
+
.hint { color: #777; font-size: .9rem; }
|
|
204
|
+
.err { color: #b00020; }
|
|
205
|
+
.err:empty { display: none; }
|
|
206
|
+
@media (prefers-color-scheme: dark) {
|
|
207
|
+
.hint { color: #aaa; }
|
|
208
|
+
.err { color: #ff8a80; }
|
|
209
|
+
}
|
|
210
|
+
</style>
|
|
211
|
+
</head>
|
|
212
|
+
<body>
|
|
213
|
+
<main>
|
|
214
|
+
${icon ? `<img class="avatar" src="${esc(icon)}" alt="">` : ''}
|
|
215
|
+
<h1>${esc(name)}</h1>
|
|
216
|
+
<p class="address">${esc(address)}</p>
|
|
217
|
+
${summary ? `<div>${summary}</div>` : ''}
|
|
218
|
+
<p>This is ${what} on the fediverse. To follow it, paste the address above
|
|
219
|
+
into the search box of Mastodon or any fediverse app — or use the form.</p>
|
|
220
|
+
<form id="follow">
|
|
221
|
+
<label for="server">your server</label>
|
|
222
|
+
<input id="server" type="text" placeholder="mastodon.social" autocomplete="off"
|
|
223
|
+
required aria-describedby="follow-err">
|
|
224
|
+
<button type="submit">Follow</button>
|
|
225
|
+
<p class="err" id="follow-err" role="alert"></p>
|
|
226
|
+
</form>
|
|
227
|
+
<p class="hint">The form sends you to your own server's follow screen.</p>
|
|
228
|
+
</main>
|
|
229
|
+
<script>
|
|
230
|
+
document.getElementById('follow').addEventListener('submit', (ev) => {
|
|
231
|
+
ev.preventDefault();
|
|
232
|
+
const s = document.getElementById('server').value.trim().replace(/^https?:\\/\\//, '').replace(/\\/.*$/, '');
|
|
233
|
+
const err = document.getElementById('follow-err');
|
|
234
|
+
const addr = document.querySelector('.address').textContent.trim();
|
|
235
|
+
if (!s) { err.textContent = 'Enter your server, e.g. mastodon.social'; return; }
|
|
236
|
+
err.textContent = '';
|
|
237
|
+
location.href = 'https://' + s + '/authorize_interaction?uri=' + encodeURIComponent(addr);
|
|
238
|
+
});
|
|
239
|
+
</script>
|
|
240
|
+
</body>
|
|
241
|
+
</html>
|
|
242
|
+
`;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// Retirement. An abandoned pod keeps accepting deliveries into a container
|
|
246
|
+
// nobody will ever drain, and no remote server has any way to learn that. A
|
|
247
|
+
// Delete of the actor is the signal to drop us; the Tombstone left in its place
|
|
248
|
+
// answers anyone who dereferences the actor afterwards.
|
|
249
|
+
export function deleteActorActivity(urls, stamp) {
|
|
250
|
+
return {
|
|
251
|
+
'@context': AS_CTX,
|
|
252
|
+
id: `${urls.actor}#delete-${stamp}`,
|
|
253
|
+
type: 'Delete',
|
|
254
|
+
actor: urls.actor,
|
|
255
|
+
object: urls.actor,
|
|
256
|
+
to: ['https://www.w3.org/ns/activitystreams#Public'],
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// Move is the fediverse's "this account lives elsewhere now": remote servers
|
|
261
|
+
// stop delivering here and migrate their followers to the target, while the old
|
|
262
|
+
// handle keeps resolving. movedTo on the actor is the durable half of it.
|
|
263
|
+
export function moveActivity(urls, target, stamp) {
|
|
264
|
+
return {
|
|
265
|
+
'@context': AS_CTX,
|
|
266
|
+
id: `${urls.actor}#move-${stamp}`,
|
|
267
|
+
type: 'Move',
|
|
268
|
+
actor: urls.actor,
|
|
269
|
+
object: urls.actor,
|
|
270
|
+
target,
|
|
271
|
+
to: ['https://www.w3.org/ns/activitystreams#Public'],
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
export function tombstoneDoc(urls, deletedAt, kind = 'person') {
|
|
276
|
+
return {
|
|
277
|
+
'@context': AS_CTX,
|
|
278
|
+
id: urls.actor,
|
|
279
|
+
type: 'Tombstone',
|
|
280
|
+
formerType: kind === 'group' ? 'Group' : 'Person',
|
|
281
|
+
deleted: deletedAt,
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// A deleted note is left as a Tombstone at its own URL — kept fetchable — so a
|
|
286
|
+
// peer that dereferences it learns the post was DELETED rather than getting a
|
|
287
|
+
// bare 404 it cannot tell from "never existed" (§7.4).
|
|
288
|
+
export function noteTombstone(noteId, deletedAt) {
|
|
289
|
+
return {
|
|
290
|
+
'@context': AS_CTX,
|
|
291
|
+
id: noteId,
|
|
292
|
+
type: 'Tombstone',
|
|
293
|
+
formerType: 'Note',
|
|
294
|
+
deleted: deletedAt,
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// A note's replies live in a document of their own, so a server that was never
|
|
299
|
+
// delivered a reply can still discover it by dereferencing this.
|
|
300
|
+
export function repliesId(noteId) { return noteId + '-replies'; }
|
|
301
|
+
|
|
302
|
+
export function collection(id, items) {
|
|
303
|
+
return {
|
|
304
|
+
'@context': AS_CTX,
|
|
305
|
+
id, type: 'Collection',
|
|
306
|
+
totalItems: items.length,
|
|
307
|
+
items,
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
export function orderedCollection(id, items) {
|
|
312
|
+
return {
|
|
313
|
+
'@context': AS_CTX,
|
|
314
|
+
id, type: 'OrderedCollection',
|
|
315
|
+
totalItems: items.length,
|
|
316
|
+
orderedItems: items,
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// A PAGED outbox, the shape Mastodon serves.
|
|
321
|
+
//
|
|
322
|
+
// One document holding every activity ever posted has to be rewritten whole on
|
|
323
|
+
// every post, so the cost of saying something grows with everything you have
|
|
324
|
+
// already said. Pages fix that without dropping anything: a new post rewrites
|
|
325
|
+
// the newest page and a head that is four lines long, and every sealed page is
|
|
326
|
+
// written once and never touched again.
|
|
327
|
+
//
|
|
328
|
+
// Pages are numbered from the OLDEST end, and that is the load-bearing part.
|
|
329
|
+
// Number them from the newest and every page boundary shifts each time you
|
|
330
|
+
// post, which is the problem again with more requests.
|
|
331
|
+
export const OUTBOX_PAGE_SIZE = 20;
|
|
332
|
+
export const outboxPageId = (outboxId, n) => `${outboxId}-${n}`;
|
|
333
|
+
|
|
334
|
+
// How many pages `total` activities occupy. Always at least one, so an empty
|
|
335
|
+
// outbox still has somewhere to point and a reader always has a `first`.
|
|
336
|
+
export const outboxPageCount = (total) => Math.max(1, Math.ceil(total / OUTBOX_PAGE_SIZE));
|
|
337
|
+
|
|
338
|
+
// `first` is the NEWEST page and `next` walks backwards in time, which is what
|
|
339
|
+
// a client following an outbox expects.
|
|
340
|
+
// `pageCount` is passed by anyone using outboxPaging, because once a page can
|
|
341
|
+
// be SHORT the count is no longer ceil(total/20) — deriving it there would put
|
|
342
|
+
// `first` below the newest page and hide everything above it.
|
|
343
|
+
export function outboxHead(id, total, pageCount = outboxPageCount(total)) {
|
|
344
|
+
const pages = Math.max(1, pageCount);
|
|
345
|
+
return {
|
|
346
|
+
'@context': AS_CTX,
|
|
347
|
+
id, type: 'OrderedCollection',
|
|
348
|
+
totalItems: total,
|
|
349
|
+
first: outboxPageId(id, pages),
|
|
350
|
+
last: outboxPageId(id, 1),
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// `items` is this page's slice, newest-first within the page. `next` points at
|
|
355
|
+
// the older page; a sealed page never needs rewriting, so it carries no `prev`
|
|
356
|
+
// — that would have to be written when the page AFTER it is created.
|
|
357
|
+
export function outboxPage(outboxId, n, items) {
|
|
358
|
+
return {
|
|
359
|
+
'@context': AS_CTX,
|
|
360
|
+
id: outboxPageId(outboxId, n),
|
|
361
|
+
type: 'OrderedCollectionPage',
|
|
362
|
+
partOf: outboxId,
|
|
363
|
+
orderedItems: items,
|
|
364
|
+
...(n > 1 ? { next: outboxPageId(outboxId, n - 1) } : {}),
|
|
365
|
+
};
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// The slices, oldest page first. `outbox` arrives newest-first (recordOutbox
|
|
369
|
+
// unshifts), and paging has to be anchored at the oldest end or appending one
|
|
370
|
+
// activity moves every boundary.
|
|
371
|
+
//
|
|
372
|
+
// Anchoring at the oldest end is right for appends and wrong for REMOVALS,
|
|
373
|
+
// which is why nothing calls this to publish any more — see outboxPaging.
|
|
374
|
+
export function outboxPages(outbox) {
|
|
375
|
+
const chron = [...outbox].reverse();
|
|
376
|
+
const out = [];
|
|
377
|
+
for (let i = 0; i < Math.max(chron.length, 1); i += OUTBOX_PAGE_SIZE) {
|
|
378
|
+
out.push(chron.slice(i, i + OUTBOX_PAGE_SIZE).reverse()); // newest-first within the page
|
|
379
|
+
}
|
|
380
|
+
return out;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
export const outboxItemId = (i) => (typeof i === 'string' ? i : i?.id || null);
|
|
384
|
+
|
|
385
|
+
// Assign entries to pages, and KEEP the assignment.
|
|
386
|
+
//
|
|
387
|
+
// Deriving the pages from position — which is what outboxPages does — makes an
|
|
388
|
+
// append cheap, because only the newest page moves. It makes a REMOVAL cost the
|
|
389
|
+
// whole history: every entry after the hole shifts back one, so every page from
|
|
390
|
+
// there to the newest gets different contents, a different digest and a PUT.
|
|
391
|
+
// Taking one old post down rewrote all of them, and an inbound Delete{actor}
|
|
392
|
+
// paid that once per post the actor had.
|
|
393
|
+
//
|
|
394
|
+
// Holding the assignment instead means a removal touches the single page that
|
|
395
|
+
// held it and leaves that page one item short. A short sealed page is legal
|
|
396
|
+
// AS2 — the newest page is already partial — and `totalItems` on the head is
|
|
397
|
+
// still the true count.
|
|
398
|
+
//
|
|
399
|
+
// `index` is the assignment we last published, oldest page first, ids only.
|
|
400
|
+
// Ids it no longer contains have been removed; ids it does not know are new and
|
|
401
|
+
// extend the newest page, spilling into fresh ones. An absent index (a first
|
|
402
|
+
// publish, or a machine that lost its state) re-derives the same boundaries
|
|
403
|
+
// outboxPages would have given, so upgrading rewrites nothing.
|
|
404
|
+
// The stable-paging core, shared by the outbox and the followers collection.
|
|
405
|
+
// `order` is the ids to place, in the order new ones should fill pages; `byId`
|
|
406
|
+
// maps each id back to its item. Ids the `index` no longer contains are dropped
|
|
407
|
+
// (a removal leaves that page one short); ids not in the index are appended to
|
|
408
|
+
// the newest page. A middle hole stays a hole — never a re-slice.
|
|
409
|
+
export function pageItems({ order, byId, index = [], pageSize }) {
|
|
410
|
+
const next = (Array.isArray(index) ? index : [])
|
|
411
|
+
.map(ids => (Array.isArray(ids) ? ids.filter(id => byId.has(id)) : []));
|
|
412
|
+
const placed = new Set(next.flat());
|
|
413
|
+
for (const id of order) {
|
|
414
|
+
if (id == null || placed.has(id)) continue;
|
|
415
|
+
placed.add(id);
|
|
416
|
+
const last = next[next.length - 1];
|
|
417
|
+
if (!last || last.length >= pageSize) next.push([id]);
|
|
418
|
+
else last.push(id);
|
|
419
|
+
}
|
|
420
|
+
// A trailing empty page would leave `first`/`last` pointing at nothing; a
|
|
421
|
+
// middle hole is the short page this exists to keep.
|
|
422
|
+
while (next.length > 1 && !next[next.length - 1].length) next.pop();
|
|
423
|
+
if (!next.length) next.push([]);
|
|
424
|
+
return { index: next, pages: next.map(ids => ids.map(id => byId.get(id))) };
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
export function outboxPaging(outbox, index = []) {
|
|
428
|
+
const byId = new Map();
|
|
429
|
+
for (const it of outbox) {
|
|
430
|
+
const id = outboxItemId(it);
|
|
431
|
+
if (id && !byId.has(id)) byId.set(id, it);
|
|
432
|
+
}
|
|
433
|
+
// Fill pages oldest-first (outbox.json is newest-first), then reverse each
|
|
434
|
+
// page back to newest-first for display.
|
|
435
|
+
const order = [...outbox].reverse().map(outboxItemId);
|
|
436
|
+
const { index: idx, pages } = pageItems({ order, byId, index, pageSize: OUTBOX_PAGE_SIZE });
|
|
437
|
+
return { index: idx, pages: pages.map(p => [...p].reverse()) };
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// ── followers, paged the same way ────────────────────────────────────────
|
|
441
|
+
// A followers collection is a set of actor IRIs. Each IRI is its own id, so
|
|
442
|
+
// there is nothing to dereference to page it, and no within-page order to keep.
|
|
443
|
+
export const FOLLOWERS_PAGE_SIZE = 20;
|
|
444
|
+
export const followersPageId = (id, n) => `${id}-${n}`;
|
|
445
|
+
export const followersPageCount = (total) => Math.max(1, Math.ceil(total / FOLLOWERS_PAGE_SIZE));
|
|
446
|
+
|
|
447
|
+
export function followersHead(id, total, pageCount = followersPageCount(total)) {
|
|
448
|
+
const pages = Math.max(1, pageCount);
|
|
449
|
+
return {
|
|
450
|
+
'@context': AS_CTX,
|
|
451
|
+
id, type: 'OrderedCollection',
|
|
452
|
+
totalItems: total,
|
|
453
|
+
first: followersPageId(id, 1),
|
|
454
|
+
last: followersPageId(id, pages),
|
|
455
|
+
};
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
export function followersPage(id, n, items, pageCount) {
|
|
459
|
+
return {
|
|
460
|
+
'@context': AS_CTX,
|
|
461
|
+
id: followersPageId(id, n),
|
|
462
|
+
type: 'OrderedCollectionPage',
|
|
463
|
+
partOf: id,
|
|
464
|
+
orderedItems: items,
|
|
465
|
+
...(n < pageCount ? { next: followersPageId(id, n + 1) } : {}),
|
|
466
|
+
};
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
export function followersPaging(actors, index = []) {
|
|
470
|
+
const list = actors.filter(a => typeof a === 'string');
|
|
471
|
+
const byId = new Map();
|
|
472
|
+
for (const a of list) if (!byId.has(a)) byId.set(a, a);
|
|
473
|
+
return pageItems({ order: list, byId, index, pageSize: FOLLOWERS_PAGE_SIZE });
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
// NodeInfo (the fediverse's server self-description): a root .well-known
|
|
477
|
+
// pointer plus the document it points at. Both are static, so the pod can
|
|
478
|
+
// serve them exactly like webfinger.
|
|
479
|
+
export function nodeinfoPointer(docHref) {
|
|
480
|
+
return {
|
|
481
|
+
links: [{ rel: 'http://nodeinfo.diaspora.software/ns/schema/2.0', href: docHref }],
|
|
482
|
+
};
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
export function nodeinfoDoc({ version, localPosts = 0 }) {
|
|
486
|
+
return {
|
|
487
|
+
version: '2.0',
|
|
488
|
+
// "hometown", not "fedipod": clients built on megalodon (Whalebird,
|
|
489
|
+
// Fedistar) refuse any software name outside their list, and hometown —
|
|
490
|
+
// a small Mastodon fork — is the least false of the accepted names for
|
|
491
|
+
// what this API serves.
|
|
492
|
+
software: { name: 'hometown', version },
|
|
493
|
+
protocols: ['activitypub'],
|
|
494
|
+
services: { inbound: [], outbound: [] },
|
|
495
|
+
openRegistrations: false,
|
|
496
|
+
usage: { users: { total: 1, activeMonth: 1, activeHalfyear: 1 }, localPosts },
|
|
497
|
+
metadata: {},
|
|
498
|
+
};
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
// Federated post bodies are attacker-controlled HTML that clients render —
|
|
502
|
+
// and Mastodon clients (Phanpy included) trust the server's content and
|
|
503
|
+
// innerHTML it, because Mastodon sanitizes server-side. That makes THIS the
|
|
504
|
+
// only line of defence, so it runs on a real HTML parser (sanitize-html →
|
|
505
|
+
// htmlparser2) rather than pattern-matching: allowlisted tags and
|
|
506
|
+
// attributes, safe URL schemes, and script/style dropped with their text.
|
|
507
|
+
import sanitize from 'sanitize-html';
|
|
508
|
+
|
|
509
|
+
const ALLOWED_TAGS = ['p', 'br', 'a', 'span', 'em', 'strong', 'b', 'i', 'u', 'del',
|
|
510
|
+
'code', 'pre', 'blockquote', 'ul', 'ol', 'li', 'h1', 'h2', 'h3', 'h4'];
|
|
511
|
+
const ALLOWED_ATTRS = ['href', 'rel', 'class', 'lang', 'title'];
|
|
512
|
+
|
|
513
|
+
export function sanitizeHtml(html) {
|
|
514
|
+
if (!html) return '';
|
|
515
|
+
return sanitize(String(html), {
|
|
516
|
+
allowedTags: ALLOWED_TAGS,
|
|
517
|
+
allowedAttributes: { '*': ALLOWED_ATTRS },
|
|
518
|
+
allowedSchemes: ['http', 'https', 'mailto'],
|
|
519
|
+
allowedSchemesAppliedToAttributes: ['href'],
|
|
520
|
+
allowProtocolRelative: false,
|
|
521
|
+
disallowedTagsMode: 'discard',
|
|
522
|
+
nonTextTags: ['script', 'style', 'textarea', 'noscript'], // drop their text too
|
|
523
|
+
transformTags: {
|
|
524
|
+
a: sanitize.simpleTransform('a', { rel: 'nofollow noopener noreferrer' }),
|
|
525
|
+
},
|
|
526
|
+
});
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
// Normalize a wire Note's attachment list to { url, mediaType, description }.
|
|
530
|
+
export function attachmentsOf(note) {
|
|
531
|
+
const list = Array.isArray(note?.attachment) ? note.attachment : note?.attachment ? [note.attachment] : [];
|
|
532
|
+
return list.map(a => ({
|
|
533
|
+
url: typeof a?.url === 'string' ? a.url : a?.url?.href,
|
|
534
|
+
mediaType: a?.mediaType || '',
|
|
535
|
+
...(a?.name ? { description: a.name } : {}),
|
|
536
|
+
})).filter(a => a.url);
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
const HTML_ESCAPES = { '&': '&', '<': '<', '>': '>' };
|
|
540
|
+
// user@host in the text, which is what a Mention tag has to agree with.
|
|
541
|
+
export const MENTION_RE = /@([A-Za-z0-9_.-]+)@([A-Za-z0-9.-]+\.[A-Za-z]{2,}(?::\d+)?)/g;
|
|
542
|
+
|
|
543
|
+
export function mentionsIn(text) {
|
|
544
|
+
return [...new Set(String(text).match(MENTION_RE) || [])].map(m => m.slice(1));
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
// Escape first, then linkify: escaping cannot introduce an @handle, and the
|
|
548
|
+
// anchors must survive it. A mention nobody could resolve stays plain text.
|
|
549
|
+
export function contentHtml(text, mentions = []) {
|
|
550
|
+
let html = String(text).replace(/[&<>]/g, c => HTML_ESCAPES[c]);
|
|
551
|
+
for (const m of mentions) {
|
|
552
|
+
if (!m?.handle || !m?.actor) continue;
|
|
553
|
+
// The anchor is for PEOPLE — the mentioned actor's advertised page when it
|
|
554
|
+
// has one; a browser landing on a raw actor document reads nothing. The
|
|
555
|
+
// mention TAG keeps the actor id, which is what servers match on.
|
|
556
|
+
const href = String(m.page || m.actor).replace(/[&<>"]/g, c => (HTML_ESCAPES[c] || '"'));
|
|
557
|
+
html = html.split('@' + m.handle).join(
|
|
558
|
+
`<a href="${href}" class="u-url mention">@${m.handle.split('@')[0]}</a>`);
|
|
559
|
+
}
|
|
560
|
+
return '<p>' + html.replace(/\n+/g, '</p><p>') + '</p>';
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
export function noteDoc({ urls, slug, content, published, inReplyTo, attachments, mentions = [],
|
|
564
|
+
visibility = 'public', summary = null, updated = null, container = null }) {
|
|
565
|
+
const id = (container || urls.notes) + slug;
|
|
566
|
+
const who = mentions.map(m => m.actor);
|
|
567
|
+
// Mastodon's four shapes. Public and unlisted are world-readable; private
|
|
568
|
+
// (followers-only) and direct carry no Public address at all and belong in
|
|
569
|
+
// the owner-only container. Mastodon addresses mentions in cc (to, for a
|
|
570
|
+
// direct post) and will not notify anyone it does not find there or in the
|
|
571
|
+
// tags.
|
|
572
|
+
const addressing = {
|
|
573
|
+
public: { to: [PUBLIC], cc: [urls.followers, ...who] },
|
|
574
|
+
unlisted: { to: [urls.followers], cc: [PUBLIC, ...who] },
|
|
575
|
+
private: { to: [urls.followers], cc: who },
|
|
576
|
+
direct: { to: who, cc: [] },
|
|
577
|
+
}[visibility] || { to: [PUBLIC], cc: [urls.followers, ...who] };
|
|
578
|
+
const note = {
|
|
579
|
+
'@context': AS_CTX,
|
|
580
|
+
id, type: 'Note',
|
|
581
|
+
attributedTo: urls.actor,
|
|
582
|
+
content: contentHtml(content, mentions),
|
|
583
|
+
published,
|
|
584
|
+
to: addressing.to,
|
|
585
|
+
cc: addressing.cc,
|
|
586
|
+
replies: repliesId(id),
|
|
587
|
+
};
|
|
588
|
+
if (summary) note.summary = summary; // the content warning
|
|
589
|
+
if (updated) note.updated = updated; // an edit's own stamp
|
|
590
|
+
if (mentions.length) {
|
|
591
|
+
note.tag = mentions.map(m => ({ type: 'Mention', href: m.actor, name: '@' + m.handle }));
|
|
592
|
+
}
|
|
593
|
+
if (inReplyTo) note.inReplyTo = inReplyTo;
|
|
594
|
+
if (attachments?.length) {
|
|
595
|
+
note.attachment = attachments.map(a => ({
|
|
596
|
+
type: 'Document', mediaType: a.mediaType, url: a.url,
|
|
597
|
+
...(a.description ? { name: a.description } : {}),
|
|
598
|
+
}));
|
|
599
|
+
}
|
|
600
|
+
return note;
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
// The id is a document of its own, not `note.id + '#create'`. A group wraps this
|
|
604
|
+
// whole activity in its Announce (FEP-1b12), and the receiver resolves it by
|
|
605
|
+
// dereferencing this id — a fragment would just serve the Note back under a
|
|
606
|
+
// different id, which Mastodon rejects.
|
|
607
|
+
export function createActivityId(noteId) { return noteId + '-create'; }
|
|
608
|
+
|
|
609
|
+
export function createActivity(note, urls) {
|
|
610
|
+
return {
|
|
611
|
+
'@context': AS_CTX,
|
|
612
|
+
id: createActivityId(note.id),
|
|
613
|
+
type: 'Create',
|
|
614
|
+
actor: urls.actor,
|
|
615
|
+
published: note.published,
|
|
616
|
+
to: note.to, cc: note.cc,
|
|
617
|
+
object: note,
|
|
618
|
+
};
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
export function acceptActivity({ urls, followActivity, serial }) {
|
|
622
|
+
return {
|
|
623
|
+
'@context': AS_CTX,
|
|
624
|
+
id: urls.actor + '#accept-' + serial,
|
|
625
|
+
type: 'Accept',
|
|
626
|
+
actor: urls.actor,
|
|
627
|
+
object: followActivity,
|
|
628
|
+
};
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
// The other half of Accept: what a group sends to end a following it will no
|
|
632
|
+
// longer carry. Without it an ejected member's server never learns, and keeps
|
|
633
|
+
// receiving everything the group announces.
|
|
634
|
+
export function rejectActivity({ urls, followActivity, serial }) {
|
|
635
|
+
return {
|
|
636
|
+
'@context': AS_CTX,
|
|
637
|
+
id: urls.actor + '#reject-' + serial,
|
|
638
|
+
type: 'Reject',
|
|
639
|
+
actor: urls.actor,
|
|
640
|
+
object: followActivity,
|
|
641
|
+
};
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
export function followActivity({ urls, targetActor, serial }) {
|
|
645
|
+
return {
|
|
646
|
+
'@context': AS_CTX,
|
|
647
|
+
id: urls.actor + '#follow-' + serial,
|
|
648
|
+
type: 'Follow',
|
|
649
|
+
actor: urls.actor,
|
|
650
|
+
object: targetActor,
|
|
651
|
+
};
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
export function likeActivity({ urls, noteId, serial }) {
|
|
655
|
+
return {
|
|
656
|
+
'@context': AS_CTX,
|
|
657
|
+
id: urls.actor + '#like-' + serial,
|
|
658
|
+
type: 'Like',
|
|
659
|
+
actor: urls.actor,
|
|
660
|
+
object: noteId,
|
|
661
|
+
};
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
// `object` is a note URL for a personal boost — what Mastodon renders — or the
|
|
665
|
+
// whole received activity when a group carries a member's post, which is what
|
|
666
|
+
// FEP-1b12 requires and what lets a follower see the author's own activity
|
|
667
|
+
// rather than the group's summary of it. Preserved exactly as delivered.
|
|
668
|
+
export function announceActivity({ urls, object, serial, published = new Date().toISOString(),
|
|
669
|
+
audience = null }) {
|
|
670
|
+
return {
|
|
671
|
+
'@context': AS_CTX,
|
|
672
|
+
id: urls.actor + '#announce-' + serial,
|
|
673
|
+
type: 'Announce',
|
|
674
|
+
actor: urls.actor,
|
|
675
|
+
published,
|
|
676
|
+
to: [PUBLIC], cc: [urls.followers],
|
|
677
|
+
// FEP-1b12: a group's carry names the group as the audience.
|
|
678
|
+
...(audience ? { audience } : {}),
|
|
679
|
+
object,
|
|
680
|
+
};
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
// Tell followers the actor document changed. Nothing obliges a server to fetch
|
|
684
|
+
// an actor it already holds — Mastodon shows its cached copy, follower count
|
|
685
|
+
// included, until something makes it look again — so a profile edit is
|
|
686
|
+
// invisible to everyone until this goes out.
|
|
687
|
+
//
|
|
688
|
+
// The actor's own @context is hoisted onto the activity and the embedded copy
|
|
689
|
+
// loses its: the document declares `schema:` when it carries PropertyValue
|
|
690
|
+
// attachments, and an object nested under a plainer context would lose them.
|
|
691
|
+
export function updateActorActivity({ urls, actor, serial, published = new Date().toISOString() }) {
|
|
692
|
+
const { '@context': ctx, ...object } = actor;
|
|
693
|
+
return {
|
|
694
|
+
'@context': ctx || AS_CTX,
|
|
695
|
+
id: urls.actor + '#update-' + serial,
|
|
696
|
+
type: 'Update',
|
|
697
|
+
actor: urls.actor,
|
|
698
|
+
published,
|
|
699
|
+
to: [PUBLIC], cc: [urls.followers],
|
|
700
|
+
object,
|
|
701
|
+
};
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
// An edit: same Note id, `updated` stamped, so receivers replace their copy
|
|
705
|
+
// and show it as edited. The activity id carries the stamp — each edit is a
|
|
706
|
+
// new activity, and a replay of an old one is already seen.
|
|
707
|
+
export function updateActivity(note, urls) {
|
|
708
|
+
return {
|
|
709
|
+
'@context': AS_CTX,
|
|
710
|
+
id: note.id + '#update-' + String(note.updated || '').replace(/[^0-9TZ]/g, ''),
|
|
711
|
+
type: 'Update',
|
|
712
|
+
actor: urls.actor,
|
|
713
|
+
to: note.to, cc: note.cc,
|
|
714
|
+
object: note,
|
|
715
|
+
};
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
export function deleteActivity({ urls, noteId }) {
|
|
719
|
+
return {
|
|
720
|
+
'@context': AS_CTX,
|
|
721
|
+
id: noteId + '#delete',
|
|
722
|
+
type: 'Delete',
|
|
723
|
+
actor: urls.actor,
|
|
724
|
+
to: [PUBLIC],
|
|
725
|
+
object: { id: noteId, type: 'Tombstone' },
|
|
726
|
+
};
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
export function undoActivity({ urls, activity, serial }) {
|
|
730
|
+
return {
|
|
731
|
+
'@context': AS_CTX,
|
|
732
|
+
id: urls.actor + '#undo-' + serial,
|
|
733
|
+
type: 'Undo',
|
|
734
|
+
actor: urls.actor,
|
|
735
|
+
object: activity,
|
|
736
|
+
};
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
// A group's ban (FEP-1b12 moderation). Only ever announced BY a group —
|
|
740
|
+
// a person's blocks are never put on the wire.
|
|
741
|
+
export function blockActivity({ urls, targetActor, serial }) {
|
|
742
|
+
return {
|
|
743
|
+
'@context': AS_CTX,
|
|
744
|
+
id: urls.actor + '#block-' + serial,
|
|
745
|
+
type: 'Block',
|
|
746
|
+
actor: urls.actor,
|
|
747
|
+
object: targetActor,
|
|
748
|
+
};
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
// Add/Remove on one of this actor's own collections — the moderator roster
|
|
752
|
+
// is the one that changes hands (FEP-1b12).
|
|
753
|
+
export function addRemoveActivity({ urls, type, object, target, serial }) {
|
|
754
|
+
return {
|
|
755
|
+
'@context': AS_CTX,
|
|
756
|
+
id: urls.actor + '#' + type.toLowerCase() + '-' + serial,
|
|
757
|
+
type,
|
|
758
|
+
actor: urls.actor,
|
|
759
|
+
object,
|
|
760
|
+
target,
|
|
761
|
+
};
|
|
762
|
+
}
|