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/social.mjs
ADDED
|
@@ -0,0 +1,505 @@
|
|
|
1
|
+
// social.mjs — outbound social actions (follow, like, announce, delete)
|
|
2
|
+
// shared by the admin API and the Mastodon-API facade. Each action delivers
|
|
3
|
+
// the activity and mirrors state into the store; the store keeps the
|
|
4
|
+
// activity a later Undo must name.
|
|
5
|
+
|
|
6
|
+
import crypto from 'node:crypto';
|
|
7
|
+
import * as wire from './wire.mjs';
|
|
8
|
+
import { dropFollower } from './store.mjs';
|
|
9
|
+
|
|
10
|
+
// WebFinger, ours rather than fedify's — v2 dropped lookupWebFinger.
|
|
11
|
+
//
|
|
12
|
+
// Better here anyway: the host comes out of a handle, and a handle can arrive
|
|
13
|
+
// in a MENTION inside a post somebody else wrote, so it is remote input and
|
|
14
|
+
// belongs behind the same SSRF guard as every other outbound request. fedify's
|
|
15
|
+
// lookup did not go through ours. https only, because a handle resolved over
|
|
16
|
+
// plaintext is a handle anyone on the path can answer for.
|
|
17
|
+
export async function lookupWebFinger(acct) {
|
|
18
|
+
const clean = String(acct || '').replace(/^acct:/, '');
|
|
19
|
+
const at = clean.lastIndexOf('@');
|
|
20
|
+
if (at < 1) return null;
|
|
21
|
+
const [user, host] = [clean.slice(0, at), clean.slice(at + 1)];
|
|
22
|
+
if (!host || /[/\\?#]/.test(host)) return null;
|
|
23
|
+
const { safeFetch, readCapped } = await import('./safefetch.mjs');
|
|
24
|
+
const url = `https://${host}/.well-known/webfinger?resource=${encodeURIComponent(`acct:${clean}`)}`;
|
|
25
|
+
const res = await safeFetch(url, {
|
|
26
|
+
headers: { accept: 'application/jrd+json, application/json' },
|
|
27
|
+
}).catch(() => null);
|
|
28
|
+
if (!res || res.status >= 400) return null;
|
|
29
|
+
try { return JSON.parse(await readCapped(res, 256 * 1024)); } catch { return null; }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export const selfLink = (jrd) => jrd?.links?.find(l => l.rel === 'self' &&
|
|
33
|
+
/application\/(activity\+json|ld\+json)/.test(l.type || ''));
|
|
34
|
+
|
|
35
|
+
// A WebFinger server can name an actor on ANY host, and until this ran we
|
|
36
|
+
// believed it. The handle is remote input — it arrives in the Mention tags of a
|
|
37
|
+
// note somebody else wrote — so evil.example could answer for `@x@evil.example`
|
|
38
|
+
// with a real, busy actor elsewhere, and a reply that carried that mention was
|
|
39
|
+
// addressed, tagged and DELIVERED to a third party who had nothing to do with
|
|
40
|
+
// the thread, over our signature.
|
|
41
|
+
//
|
|
42
|
+
// The host cannot simply be required to match: `@user@example.com` served by
|
|
43
|
+
// mastodon.example is the ordinary delegated setup and the whole point of
|
|
44
|
+
// WebFinger. So ask the ACTOR's own host who it says that actor is, and require
|
|
45
|
+
// it to come back to the same document. A server may name anyone; it cannot
|
|
46
|
+
// make that person's server agree. This is the round trip Mastodon does.
|
|
47
|
+
//
|
|
48
|
+
// Only when the two hosts differ — same-host is self-consistent by
|
|
49
|
+
// construction, so the common case costs no extra request.
|
|
50
|
+
export async function confirmDelegation(asked, doc, lookup = lookupWebFinger) {
|
|
51
|
+
let home;
|
|
52
|
+
try { home = new URL(doc.id).host.toLowerCase(); }
|
|
53
|
+
catch { throw new Error(`actor id is not a URL (${doc.id})`); }
|
|
54
|
+
if (home === asked.toLowerCase()) return;
|
|
55
|
+
const user = doc.preferredUsername;
|
|
56
|
+
if (!user) {
|
|
57
|
+
throw new Error(`${asked} points at ${doc.id} on another host, which names no `
|
|
58
|
+
+ 'username to confirm it by');
|
|
59
|
+
}
|
|
60
|
+
const back = await lookup(`acct:${user}@${home}`);
|
|
61
|
+
if (selfLink(back)?.href !== doc.id) {
|
|
62
|
+
throw new Error(`${asked} claims ${doc.id}, but ${home} does not agree that is `
|
|
63
|
+
+ `@${user}@${home} — refusing to take one host's word for another's actor`);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// acct:user@host → verified actor doc (cached by fetchAP), or throws.
|
|
68
|
+
export async function resolveHandle(agent, handle) {
|
|
69
|
+
const clean = String(handle || '').replace(/^@/, '');
|
|
70
|
+
if (!/^[^@]+@[^@]+$/.test(clean)) throw new Error('handle must look like user@host');
|
|
71
|
+
if (agent.store.isBlocked('https://' + clean.split('@')[1] + '/')) throw new Error('domain is blocked');
|
|
72
|
+
const jrd = await lookupWebFinger('acct:' + clean);
|
|
73
|
+
const self = selfLink(jrd);
|
|
74
|
+
if (!self?.href) throw new Error(`webfinger found no actor for ${clean}`);
|
|
75
|
+
const doc = await agent.intake.fetchAP(self.href);
|
|
76
|
+
if (!doc?.id) throw new Error(`actor document unusable for ${clean}`);
|
|
77
|
+
if (agent.store.isBlocked(doc.id)) throw new Error('actor is blocked');
|
|
78
|
+
await confirmDelegation(clean.slice(clean.lastIndexOf('@') + 1), doc);
|
|
79
|
+
// Counts live in the collections, not the actor document, so a client asking
|
|
80
|
+
// "who is this" would otherwise be told everyone has none. Two GETs, and only
|
|
81
|
+
// when someone asked by name — never on the drain path.
|
|
82
|
+
await cacheCounts(agent, doc);
|
|
83
|
+
return doc;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// totalItems off the followers/following collections, stored beside the cached
|
|
87
|
+
// actor. Best effort: a collection that will not answer leaves the count unset
|
|
88
|
+
// rather than reported as zero, which is a different claim.
|
|
89
|
+
async function cacheCounts(agent, doc) {
|
|
90
|
+
const total = async (url) => {
|
|
91
|
+
if (!url) return null;
|
|
92
|
+
try {
|
|
93
|
+
const res = await agent.intake.fetchAP(url);
|
|
94
|
+
return Number.isInteger(res?.totalItems) ? res.totalItems : null;
|
|
95
|
+
} catch { return null; }
|
|
96
|
+
};
|
|
97
|
+
const [followers, following] = await Promise.all([total(doc.followers), total(doc.following)]);
|
|
98
|
+
if (followers === null && following === null) return;
|
|
99
|
+
const actors = agent.store.getActors();
|
|
100
|
+
if (!actors[doc.id]) return;
|
|
101
|
+
actors[doc.id] = { ...actors[doc.id], counts: { followers, following } };
|
|
102
|
+
agent.store.write('actors.json', actors);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// `doc` skips the fetch when the caller already resolved the actor; a bulk
|
|
106
|
+
// import passes publish:false and republishes the collections once at the end.
|
|
107
|
+
export async function followActor(agent, actorUrl, { publish = true, doc: fetched = null } = {}) {
|
|
108
|
+
const doc = fetched || await agent.intake.fetchAP(actorUrl);
|
|
109
|
+
if (!doc?.inbox) throw new Error(`actor document unusable (${actorUrl})`);
|
|
110
|
+
if (agent.store.isBlocked(doc.id)) throw new Error('actor is blocked');
|
|
111
|
+
const contacts = agent.store.getContacts();
|
|
112
|
+
let rec = contacts.following.find(f => f.actor === doc.id);
|
|
113
|
+
if (!rec) {
|
|
114
|
+
rec = { actor: doc.id, inbox: doc.inbox, accepted: false };
|
|
115
|
+
contacts.following.push(rec);
|
|
116
|
+
}
|
|
117
|
+
const follow = wire.followActivity({ urls: agent.publisher.urls, targetActor: doc.id, serial: Date.now() });
|
|
118
|
+
rec.followActivity = follow; // kept for a later Undo
|
|
119
|
+
agent.store.setContacts(contacts);
|
|
120
|
+
await agent.deliverer.deliver(doc.inbox, follow);
|
|
121
|
+
if (publish) await agent.publisher.publishCollections({ following: true, pending: true });
|
|
122
|
+
return doc;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export async function followHandle(agent, handle) {
|
|
126
|
+
const doc = await resolveHandle(agent, handle);
|
|
127
|
+
await followActor(agent, doc.id);
|
|
128
|
+
const clean = String(handle || '').replace(/^@/, '');
|
|
129
|
+
const contacts = agent.store.getContacts();
|
|
130
|
+
const rec = contacts.following.find(f => f.actor === doc.id);
|
|
131
|
+
if (rec && !rec.handle) { rec.handle = clean; agent.store.setContacts(contacts); }
|
|
132
|
+
return { ok: true, actor: doc.id };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export async function unfollowActor(agent, actor) {
|
|
136
|
+
const contacts = agent.store.getContacts();
|
|
137
|
+
const rec = contacts.following.find(f => f.actor === actor);
|
|
138
|
+
if (!rec) throw new Error('not following that actor');
|
|
139
|
+
if (rec.followActivity) {
|
|
140
|
+
await agent.deliverer.deliver(rec.inbox,
|
|
141
|
+
wire.undoActivity({ urls: agent.publisher.urls, activity: rec.followActivity, serial: Date.now() }));
|
|
142
|
+
}
|
|
143
|
+
contacts.following = contacts.following.filter(f => f.actor !== actor);
|
|
144
|
+
agent.store.setContacts(contacts);
|
|
145
|
+
await agent.publisher.publishCollections({ following: true, pending: true });
|
|
146
|
+
return { ok: true };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export async function favourite(agent, s) {
|
|
150
|
+
if (s.favourited) return s;
|
|
151
|
+
const doc = await agent.intake.fetchAP(s.actor);
|
|
152
|
+
if (!doc?.inbox) throw new Error('author inbox unavailable');
|
|
153
|
+
const act = wire.likeActivity({ urls: agent.publisher.urls, noteId: s.noteId, serial: Date.now() });
|
|
154
|
+
await agent.deliverer.deliver(doc.endpoints?.sharedInbox || doc.inbox, act);
|
|
155
|
+
return agent.store.updateStatus(s.noteId, { favourited: true, likeActivity: act });
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export async function unfavourite(agent, s) {
|
|
159
|
+
if (s.likeActivity) {
|
|
160
|
+
const doc = await agent.intake.fetchAP(s.actor).catch(() => null);
|
|
161
|
+
if (doc?.inbox) {
|
|
162
|
+
await agent.deliverer.deliver(doc.endpoints?.sharedInbox || doc.inbox,
|
|
163
|
+
wire.undoActivity({ urls: agent.publisher.urls, activity: s.likeActivity, serial: Date.now() }));
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
return agent.store.updateStatus(s.noteId, { favourited: false, likeActivity: undefined });
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Announce goes to our followers (that's what boosting is) and to the author.
|
|
170
|
+
export async function reblog(agent, s) {
|
|
171
|
+
if (s.reblogged) return s;
|
|
172
|
+
const { urls } = agent.publisher;
|
|
173
|
+
const act = wire.announceActivity({ urls, object: s.noteId, serial: Date.now() });
|
|
174
|
+
const inboxes = new Set(agent.store.getContacts().followers
|
|
175
|
+
.map(f => f.sharedInbox || f.inbox).filter(Boolean));
|
|
176
|
+
if (s.actor !== urls.actor) {
|
|
177
|
+
const doc = await agent.intake.fetchAP(s.actor).catch(() => null);
|
|
178
|
+
if (doc?.inbox) inboxes.add(doc.endpoints?.sharedInbox || doc.inbox);
|
|
179
|
+
}
|
|
180
|
+
await agent.deliverer.deliverToAll([...inboxes], act);
|
|
181
|
+
// Mark it boosted before recording it: a failed outbox write costs one
|
|
182
|
+
// missing entry, a failed status write would let a retry announce twice.
|
|
183
|
+
const updated = agent.store.updateStatus(s.noteId, { reblogged: true, announceActivity: act });
|
|
184
|
+
await agent.publisher.recordOutbox(act);
|
|
185
|
+
return updated;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export async function unreblog(agent, s) {
|
|
189
|
+
if (s.announceActivity) {
|
|
190
|
+
const undo = wire.undoActivity({ urls: agent.publisher.urls, activity: s.announceActivity, serial: Date.now() });
|
|
191
|
+
const inboxes = new Set(agent.store.getContacts().followers
|
|
192
|
+
.map(f => f.sharedInbox || f.inbox).filter(Boolean));
|
|
193
|
+
const doc = await agent.intake.fetchAP(s.actor).catch(() => null);
|
|
194
|
+
if (doc?.inbox && s.actor !== agent.publisher.urls.actor) inboxes.add(doc.endpoints?.sharedInbox || doc.inbox);
|
|
195
|
+
await agent.deliverer.deliverToAll([...inboxes], undo);
|
|
196
|
+
await agent.publisher.unrecordOutbox(i => i?.id === s.announceActivity.id);
|
|
197
|
+
}
|
|
198
|
+
return agent.store.updateStatus(s.noteId, { reblogged: false, announceActivity: undefined });
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Group: end a following we will no longer carry. Forgetting them locally is
|
|
202
|
+
// not enough — without the Reject their server keeps the relationship and they
|
|
203
|
+
// keep receiving everything the group announces. Muting them too, because an
|
|
204
|
+
// ejection anyone can undo by re-following is not an ejection; a re-follow is
|
|
205
|
+
// still auto-accepted, but nothing of theirs gets carried.
|
|
206
|
+
export async function ejectFollower(agent, actor) {
|
|
207
|
+
const { urls } = agent.publisher;
|
|
208
|
+
const contacts = agent.store.getContacts();
|
|
209
|
+
const rec = contacts.followers.find(f => f.actor === actor);
|
|
210
|
+
if (!rec) throw new Error('not a member');
|
|
211
|
+
dropFollower(contacts, actor, 'ejected');
|
|
212
|
+
agent.store.setContacts(contacts);
|
|
213
|
+
const muted = agent.store.getMuted();
|
|
214
|
+
muted.actors = [...new Set([...muted.actors, actor])];
|
|
215
|
+
agent.store.setMuted(muted);
|
|
216
|
+
await agent.publisher.publishCollections({ followers: true });
|
|
217
|
+
// The ban is broadcast to the membership (FEP-1b12), so their servers can
|
|
218
|
+
// mirror it. Never sent to the ejected member — they get the Reject below.
|
|
219
|
+
await announceModeration(agent,
|
|
220
|
+
wire.blockActivity({ urls, targetActor: actor, serial: Date.now() }))
|
|
221
|
+
.catch(e => agent.log?.(`moderation announce failed: ${e.message}`));
|
|
222
|
+
// A Bluesky-side member's departure sticks by blocking the DID.
|
|
223
|
+
if (rec.bsky) {
|
|
224
|
+
await agent.bskygroup?.blockDid(rec.bsky.did)
|
|
225
|
+
.catch(e => agent.log?.(`bluesky block failed for ${rec.bsky.handle}: ${e.message}`));
|
|
226
|
+
return { ok: true, actor, told: true };
|
|
227
|
+
}
|
|
228
|
+
if (rec.inbox) {
|
|
229
|
+
await agent.deliverer.deliver(rec.inbox, wire.rejectActivity({
|
|
230
|
+
urls,
|
|
231
|
+
followActivity: { id: rec.followId, type: 'Follow', actor, object: urls.actor },
|
|
232
|
+
serial: Date.now(),
|
|
233
|
+
}));
|
|
234
|
+
}
|
|
235
|
+
return { ok: true, actor, told: !!rec.inbox };
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// Group: answer a held join request. Admitting is the Accept onFollow would
|
|
239
|
+
// have sent immediately had the group not been gated.
|
|
240
|
+
export async function admitRequest(agent, actor, { publish = true } = {}) {
|
|
241
|
+
const reqs = agent.store.getRequests();
|
|
242
|
+
const req = reqs.find(r => r.actor === actor);
|
|
243
|
+
if (!req) throw new Error('no such request');
|
|
244
|
+
const contacts = agent.store.getContacts();
|
|
245
|
+
if (!contacts.followers.some(f => f.actor === actor)) {
|
|
246
|
+
// Admitting a bridged Bluesky member retires their Bluesky-only record,
|
|
247
|
+
// the same way an ungated group's follow does: one person, one membership.
|
|
248
|
+
const bridgedDid = /^https:\/\/bsky\.brid\.gy\/ap\/(did:[^/]+)$/.exec(actor)?.[1];
|
|
249
|
+
if (bridgedDid) contacts.followers = contacts.followers.filter(f => f.bsky?.did !== bridgedDid);
|
|
250
|
+
contacts.followers.push(req.bsky
|
|
251
|
+
? { actor, bsky: req.bsky }
|
|
252
|
+
: { actor, inbox: req.inbox, sharedInbox: req.sharedInbox, followId: req.activity?.id });
|
|
253
|
+
agent.store.setContacts(contacts);
|
|
254
|
+
agent.store.addNotification({ type: 'follow', actor, ...(req.bsky ? { bsky: true } : {}) });
|
|
255
|
+
}
|
|
256
|
+
agent.store.setRequests(reqs.filter(r => r.actor !== actor));
|
|
257
|
+
// A Bluesky-only member has no inbox and no Accept to receive; the follow
|
|
258
|
+
// already stands on their side. The one-time bridge nudge goes out instead.
|
|
259
|
+
if (req.bsky) {
|
|
260
|
+
await agent.bskygroup?.nudgeOnce(contacts.followers.find(f => f.actor === actor));
|
|
261
|
+
return { ok: true, actor };
|
|
262
|
+
}
|
|
263
|
+
// A bulk admit passes publish:false and republishes once for the lot.
|
|
264
|
+
if (publish) await agent.publisher.publishCollections({ followers: true, pending: true });
|
|
265
|
+
await agent.deliverer.deliver(req.inbox, wire.acceptActivity({
|
|
266
|
+
urls: agent.publisher.urls, followActivity: req.activity, serial: Date.now(),
|
|
267
|
+
}));
|
|
268
|
+
return { ok: true, actor };
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// Refusing is not a ban: they may ask again, and the queue is where you would
|
|
272
|
+
// see that. Eject is the sticky one, because it mutes.
|
|
273
|
+
export async function refuseRequest(agent, actor) {
|
|
274
|
+
const reqs = agent.store.getRequests();
|
|
275
|
+
const req = reqs.find(r => r.actor === actor);
|
|
276
|
+
if (!req) throw new Error('no such request');
|
|
277
|
+
agent.store.setRequests(reqs.filter(r => r.actor !== actor));
|
|
278
|
+
await agent.publisher?.publishCollections({ pending: true }).catch(() => {});
|
|
279
|
+
// No Reject can reach a Bluesky-only requester, so the refusal is enforced
|
|
280
|
+
// where it can be: their DID is blocked.
|
|
281
|
+
if (req.bsky) {
|
|
282
|
+
await agent.bskygroup?.blockDid(req.bsky.did)
|
|
283
|
+
.catch(e => agent.log?.(`bluesky block failed for ${req.bsky.handle}: ${e.message}`));
|
|
284
|
+
return { ok: true, actor };
|
|
285
|
+
}
|
|
286
|
+
await agent.deliverer.deliver(req.inbox, wire.rejectActivity({
|
|
287
|
+
urls: agent.publisher.urls, followActivity: req.activity, serial: Date.now(),
|
|
288
|
+
}));
|
|
289
|
+
return { ok: true, actor };
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// Group: unsay an Announce. The work is in Intake because an upstream Delete
|
|
293
|
+
// has to do the same thing, and both must use the recipient set amplify used.
|
|
294
|
+
export async function retractAnnouncement(agent, noteId) {
|
|
295
|
+
return agent.intake.retract(noteId);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// Remove an own note everywhere: Delete→followers, remote note + outbox,
|
|
299
|
+
// local pod RDF, statuses mirror.
|
|
300
|
+
//
|
|
301
|
+
// Two of the three pod deletes have to land, and both are checked. While the
|
|
302
|
+
// note document answers, anyone holding its id can still read a post its author
|
|
303
|
+
// deleted — and the `-create` beside it EMBEDS the whole note, so leaving that
|
|
304
|
+
// one behind publishes the same content under another name. A failure keeps the
|
|
305
|
+
// post here and says so, rather than reporting a deletion that did not happen:
|
|
306
|
+
// removing it locally would leave nothing to try again with, and nothing to see
|
|
307
|
+
// that it is still up.
|
|
308
|
+
// A vote is the fediverse convention: one bare Note per chosen option, whose
|
|
309
|
+
// name IS the option, inReplyTo the question, addressed to the question's
|
|
310
|
+
// author alone. The documents are stored for dereference but never listed —
|
|
311
|
+
// not in the outbox, not in the local index.
|
|
312
|
+
export async function votePoll(agent, s, choices) {
|
|
313
|
+
const { urls } = agent.publisher;
|
|
314
|
+
const poll = s.poll || {};
|
|
315
|
+
const opts = poll.options || [];
|
|
316
|
+
if (poll.closed || (poll.expiresAt && Date.parse(poll.expiresAt) < Date.now())) {
|
|
317
|
+
return { ok: false, error: 'this poll has closed' };
|
|
318
|
+
}
|
|
319
|
+
if (poll.voted) return { ok: false, error: 'already voted' };
|
|
320
|
+
const picks = [...new Set(choices)].filter(i => i >= 0 && i < opts.length);
|
|
321
|
+
if (!picks.length) return { ok: false, error: 'invalid choice' };
|
|
322
|
+
if (!poll.multiple && picks.length > 1) return { ok: false, error: 'this poll takes one choice' };
|
|
323
|
+
const author = await agent.intake.fetchAP(s.actor).catch(() => null);
|
|
324
|
+
const inbox = author?.endpoints?.sharedInbox || author?.inbox;
|
|
325
|
+
if (!inbox) return { ok: false, error: 'could not reach the poll author' };
|
|
326
|
+
const published = new Date().toISOString();
|
|
327
|
+
for (const i of picks) {
|
|
328
|
+
const id = urls.notes + published.slice(0, 10) + '-vote-' + crypto.randomBytes(4).toString('hex');
|
|
329
|
+
const note = {
|
|
330
|
+
'@context': wire.AS_CTX, id, type: 'Note',
|
|
331
|
+
attributedTo: urls.actor, name: opts[i].title,
|
|
332
|
+
inReplyTo: s.noteId, published, to: [s.actor],
|
|
333
|
+
};
|
|
334
|
+
const create = {
|
|
335
|
+
'@context': wire.AS_CTX, id: wire.createActivityId(id), type: 'Create',
|
|
336
|
+
actor: urls.actor, published, to: [s.actor], object: note,
|
|
337
|
+
};
|
|
338
|
+
await agent.remote.putJson(id, note);
|
|
339
|
+
await agent.remote.putJson(create.id, create);
|
|
340
|
+
await agent.deliverer.deliverToAll([inbox], create);
|
|
341
|
+
}
|
|
342
|
+
agent.store.updateStatus(s.noteId, {
|
|
343
|
+
poll: {
|
|
344
|
+
...poll, voted: true, ownVotes: picks,
|
|
345
|
+
options: opts.map((o, j) => (picks.includes(j) ? { ...o, votes: (o.votes || 0) + 1 } : o)),
|
|
346
|
+
},
|
|
347
|
+
});
|
|
348
|
+
return { ok: true };
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
export async function deleteNote(agent, s) {
|
|
352
|
+
const { urls } = agent.publisher;
|
|
353
|
+
const inboxes = agent.store.getContacts().followers
|
|
354
|
+
.map(f => f.sharedInbox || f.inbox).filter(Boolean);
|
|
355
|
+
await agent.deliverer.deliverToAll(inboxes, wire.deleteActivity({ urls, noteId: s.noteId }));
|
|
356
|
+
|
|
357
|
+
// The note is REPLACED with a Tombstone rather than removed, so a peer that
|
|
358
|
+
// dereferences it learns the post was deleted instead of a bare 404 (§7.4).
|
|
359
|
+
// The Tombstone must stay readable; the container above it is owner-only, so
|
|
360
|
+
// the ACL cannot be inherited. The Create activity is the withdrawn action,
|
|
361
|
+
// not the object, so it is deleted outright.
|
|
362
|
+
const stuck = [];
|
|
363
|
+
const deletedAt = new Date().toISOString();
|
|
364
|
+
try {
|
|
365
|
+
await agent.remote.putJson(s.noteId, wire.noteTombstone(s.noteId, deletedAt));
|
|
366
|
+
await agent.remote.setAcl(s.noteId, ['Read']);
|
|
367
|
+
} catch { stuck.push(s.noteId); }
|
|
368
|
+
if (!await agent.remote.delete(wire.createActivityId(s.noteId)).catch(() => false)) {
|
|
369
|
+
stuck.push(wire.createActivityId(s.noteId));
|
|
370
|
+
}
|
|
371
|
+
if (stuck.length) {
|
|
372
|
+
agent.log?.(`delete ${s.noteId}: the pod kept ${stuck.length} document(s) — the post is STILL PUBLISHED`);
|
|
373
|
+
return {
|
|
374
|
+
ok: false, stillPublished: stuck,
|
|
375
|
+
error: 'the pod would not remove the post — it is still published, and has been kept here so you can try again',
|
|
376
|
+
};
|
|
377
|
+
}
|
|
378
|
+
// Empty, and referenced by nothing once the note is gone.
|
|
379
|
+
await agent.remote.delete(wire.repliesId(s.noteId)).catch(() => {});
|
|
380
|
+
|
|
381
|
+
// The Bluesky mirror goes with the post. Best-effort: a mirror the far side
|
|
382
|
+
// already lost is not a reason to keep the post here.
|
|
383
|
+
if (s.atproto?.uri && agent.atproto?.connected()) {
|
|
384
|
+
await agent.atproto.deleteCrossPost(s.atproto.uri)
|
|
385
|
+
.then(() => agent.log?.(`bluesky mirror deleted: ${s.atproto.uri}`))
|
|
386
|
+
.catch(e => agent.log?.(`bluesky mirror not deleted (${e.message}): ${s.atproto.uri}`));
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// The note, and our own boost of it if there was one.
|
|
390
|
+
await agent.publisher.unrecordOutbox(i => i === s.noteId || (i?.id && i.id === s.announceActivity?.id));
|
|
391
|
+
if (s.slug) await agent.local.delete(agent.local.fedi + 'posts/' + s.slug).catch(() => {});
|
|
392
|
+
agent.store.removeStatus(s.noteId);
|
|
393
|
+
return { ok: true };
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// FEP-1b12: a GROUP's moderation is announced to its followers, so their
|
|
397
|
+
// servers can mirror the action. Group moderation only — a person's blocks
|
|
398
|
+
// never go on the wire (that is also the spec's own SHOULD NOT), which is why
|
|
399
|
+
// the guard lives here rather than in every caller. Never delivered to the
|
|
400
|
+
// moderated party; the announce goes to the membership.
|
|
401
|
+
export async function announceModeration(agent, activity) {
|
|
402
|
+
if (agent.store.getConfig()?.kind !== 'group') return null;
|
|
403
|
+
const { urls } = agent.publisher;
|
|
404
|
+
const act = wire.announceActivity({ urls, object: activity, serial: Date.now(), audience: urls.actor });
|
|
405
|
+
const inboxes = [...new Set(agent.store.getContacts().followers
|
|
406
|
+
.map(f => f.sharedInbox || f.inbox).filter(Boolean))];
|
|
407
|
+
await agent.deliverer.deliverToAll(inboxes, act);
|
|
408
|
+
await agent.publisher.recordOutbox(act);
|
|
409
|
+
return act;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// A block is state, not speech: the list changes and the follow ends, and
|
|
413
|
+
// nothing is delivered TO THE BLOCKED PARTY (§6.10). Intake already refuses a
|
|
414
|
+
// blocked author on arrival. A group additionally announces the ban to its
|
|
415
|
+
// members (FEP-1b12) — announceModeration keeps a person's blocks silent.
|
|
416
|
+
export async function blockActor(agent, actorUrl) {
|
|
417
|
+
if (!/^https?:\/\/\S+$/.test(String(actorUrl || ''))) throw new Error('actor must be a URL');
|
|
418
|
+
const b = agent.store.getBlocklist();
|
|
419
|
+
if (!b.actors.includes(actorUrl)) b.actors.push(actorUrl);
|
|
420
|
+
agent.store.setBlocklist(b);
|
|
421
|
+
await unfollowActor(agent, actorUrl).catch(() => {}); // not following is fine
|
|
422
|
+
await agent.publisher?.publishCollections({ blocked: true }).catch(() => {});
|
|
423
|
+
await announceModeration(agent,
|
|
424
|
+
wire.blockActivity({ urls: agent.publisher.urls, targetActor: actorUrl, serial: Date.now() }))
|
|
425
|
+
.catch(e => agent.log?.(`moderation announce failed: ${e.message}`));
|
|
426
|
+
return { ok: true, actor: actorUrl };
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
export async function unblockActor(agent, actorUrl) {
|
|
430
|
+
const b = agent.store.getBlocklist();
|
|
431
|
+
b.actors = b.actors.filter(a => a !== actorUrl);
|
|
432
|
+
agent.store.setBlocklist(b);
|
|
433
|
+
await agent.publisher?.publishCollections({ blocked: true }).catch(() => {});
|
|
434
|
+
// The unban is announced as the Undo of the ban, the ban re-stated inside it
|
|
435
|
+
// — its id was a fragment nobody stored, so receivers match on the shape.
|
|
436
|
+
await announceModeration(agent, wire.undoActivity({
|
|
437
|
+
urls: agent.publisher.urls,
|
|
438
|
+
activity: { type: 'Block', actor: agent.publisher.urls.actor, object: actorUrl },
|
|
439
|
+
serial: Date.now(),
|
|
440
|
+
})).catch(e => agent.log?.(`moderation announce failed: ${e.message}`));
|
|
441
|
+
return { ok: true, actor: actorUrl };
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
// Apply (or refuse) one queued moderation request — an action a LISTED
|
|
445
|
+
// moderator asked for over federation. Deliveries carry nothing that proves
|
|
446
|
+
// the sender, which is exactly why these wait in a queue for the operator
|
|
447
|
+
// instead of running on arrival; applying one is the operator vouching for it.
|
|
448
|
+
export async function applyModeration(agent, entry) {
|
|
449
|
+
const { urls } = agent.publisher;
|
|
450
|
+
const target = typeof entry.activity?.object === 'string'
|
|
451
|
+
? entry.activity.object : entry.activity?.object?.id;
|
|
452
|
+
switch (entry.type) {
|
|
453
|
+
case 'Block': {
|
|
454
|
+
if (!target) throw new Error('the queued Block names nobody');
|
|
455
|
+
// A member is ejected (which mutes and announces the ban); a stranger is
|
|
456
|
+
// simply blocked. Either way the ban is broadcast once.
|
|
457
|
+
const member = agent.store.getContacts().followers.some(f => f.actor === target);
|
|
458
|
+
return member ? ejectFollower(agent, target) : blockActor(agent, target);
|
|
459
|
+
}
|
|
460
|
+
case 'Undo': {
|
|
461
|
+
const inner = entry.activity?.object;
|
|
462
|
+
const unbanned = typeof inner === 'object' && inner?.type === 'Block'
|
|
463
|
+
? (typeof inner.object === 'string' ? inner.object : inner.object?.id) : null;
|
|
464
|
+
if (!unbanned) throw new Error('the queued Undo does not name a Block');
|
|
465
|
+
const muted = agent.store.getMuted();
|
|
466
|
+
muted.actors = muted.actors.filter(a => a !== unbanned);
|
|
467
|
+
agent.store.setMuted(muted);
|
|
468
|
+
return unblockActor(agent, unbanned);
|
|
469
|
+
}
|
|
470
|
+
case 'Delete': {
|
|
471
|
+
if (!target) throw new Error('the queued Delete names nothing');
|
|
472
|
+
// Removing a post from the group is unsaying its carry.
|
|
473
|
+
return agent.intake.retract(target);
|
|
474
|
+
}
|
|
475
|
+
case 'Add':
|
|
476
|
+
case 'Remove': {
|
|
477
|
+
if (!target) throw new Error('the queued roster change names nobody');
|
|
478
|
+
const cfg = { ...agent.store.getConfig() };
|
|
479
|
+
const prev = cfg.moderators || [];
|
|
480
|
+
cfg.moderators = entry.type === 'Add'
|
|
481
|
+
? [...new Set([...prev, target])]
|
|
482
|
+
: prev.filter(m => m !== target);
|
|
483
|
+
agent.store.setConfig(cfg);
|
|
484
|
+
if (agent.publisher) agent.publisher.config.moderators = cfg.moderators;
|
|
485
|
+
await agent.publisher.publishProfile();
|
|
486
|
+
await announceModeration(agent, wire.addRemoveActivity({
|
|
487
|
+
urls, type: entry.type, object: target, target: urls.moderators, serial: Date.now(),
|
|
488
|
+
})).catch(e => agent.log?.(`moderation announce failed: ${e.message}`));
|
|
489
|
+
return { ok: true, moderators: cfg.moderators };
|
|
490
|
+
}
|
|
491
|
+
default: throw new Error(`no way to apply a queued ${entry.type}`);
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
// Pin or unpin an own post. Republishing the featured collection is what makes
|
|
496
|
+
// the pin real to anyone else — the actor document names that collection, and
|
|
497
|
+
// a visitor's server reads it, so the profile is republished too (it skips
|
|
498
|
+
// itself when nothing changed).
|
|
499
|
+
export async function pinStatus(agent, s, pinned) {
|
|
500
|
+
if (s.actor !== agent.publisher.urls.actor) throw new Error('not your status');
|
|
501
|
+
const updated = agent.store.updateStatus(s.noteId, { pinned: !!pinned });
|
|
502
|
+
await agent.publisher.publishFeatured().catch(e => agent.log?.(`featured: ${e.message}`));
|
|
503
|
+
agent.publisher.publishProfile?.().catch(() => {});
|
|
504
|
+
return updated || s;
|
|
505
|
+
}
|
package/lib/storage.mjs
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
// storage.mjs — a container of documents, and the two places one can live.
|
|
2
|
+
//
|
|
3
|
+
// The store and the RDF tree only ever do four things to a container: list its
|
|
4
|
+
// children, read one, write one, remove one. Everything else an LDP server
|
|
5
|
+
// offers — membership triples, content negotiation, status codes, auxiliary
|
|
6
|
+
// resources — exists to satisfy HTTP. So the filesystem implementation does
|
|
7
|
+
// not pretend to have any of it, and nothing serialises a container listing
|
|
8
|
+
// into RDF only to parse it straight back out.
|
|
9
|
+
//
|
|
10
|
+
// Paths are relative to the container's base ('config.json', 'posts/n1'), and
|
|
11
|
+
// a name ending in '/' is a child container, in both implementations.
|
|
12
|
+
|
|
13
|
+
import fsp from 'node:fs/promises';
|
|
14
|
+
import path from 'node:path';
|
|
15
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
16
|
+
import * as $rdf from 'rdflib';
|
|
17
|
+
|
|
18
|
+
const LDP = $rdf.Namespace('http://www.w3.org/ns/ldp#');
|
|
19
|
+
const slash = (u) => (u.endsWith('/') ? u : u + '/');
|
|
20
|
+
|
|
21
|
+
// A pod, local or remote. `fetchImpl` carries whatever authentication it needs.
|
|
22
|
+
export class HttpStorage {
|
|
23
|
+
constructor(base, fetchImpl) {
|
|
24
|
+
this.base = slash(base);
|
|
25
|
+
this.fetchImpl = fetchImpl;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
get kind() { return 'pod'; }
|
|
29
|
+
|
|
30
|
+
// The same jail FileStorage has, and for the same reason: a path here can
|
|
31
|
+
// carry `..`, encodeURI leaves both `.` and `/` alone, and fetch normalises
|
|
32
|
+
// the segments away — so a name derived from remote input could name a
|
|
33
|
+
// resource outside the container entirely. PodRdf._rel only checks the
|
|
34
|
+
// PREFIX, which a concatenated URL always satisfies, so it catches nothing.
|
|
35
|
+
_url(p) {
|
|
36
|
+
const u = new URL(encodeURI(p), this.base);
|
|
37
|
+
if (!u.href.startsWith(this.base)) throw new Error(`path escapes the container: ${p}`);
|
|
38
|
+
return u.href;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async list(sub = '', { etag } = {}) {
|
|
42
|
+
const url = this._url(sub);
|
|
43
|
+
const res = await this.fetchImpl(url, {
|
|
44
|
+
headers: { accept: 'text/turtle', ...(etag ? { 'if-none-match': etag } : {}) },
|
|
45
|
+
});
|
|
46
|
+
if (res.status === 304) return { notModified: true, names: null, etag };
|
|
47
|
+
// `missing` because a 404 is ambiguous over HTTP: a pod saying "no such
|
|
48
|
+
// container" and a proxy answering for a dead backend look identical.
|
|
49
|
+
// The caller decides whether absence is an answer here.
|
|
50
|
+
if (res.status === 404) return { notModified: false, names: [], etag: null, missing: true };
|
|
51
|
+
if (res.status >= 400) throw new Error(`container unreadable (HTTP ${res.status})`);
|
|
52
|
+
const g = $rdf.graph();
|
|
53
|
+
// Throws on malformed Turtle rather than quietly matching the wrong thing,
|
|
54
|
+
// which is the entire reason this is not a regex.
|
|
55
|
+
$rdf.parse(await res.text(), g, url, 'text/turtle');
|
|
56
|
+
const here = $rdf.sym(url);
|
|
57
|
+
const names = g.each(here, LDP('contains'), null, here)
|
|
58
|
+
.map(n => n.value)
|
|
59
|
+
.filter(u => u.startsWith(url) && u !== url)
|
|
60
|
+
.map(u => decodeURIComponent(u.slice(url.length)));
|
|
61
|
+
return { notModified: false, names, etag: res.headers.get('etag') };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async read(p, { etag } = {}) {
|
|
65
|
+
const res = await this.fetchImpl(this._url(p), {
|
|
66
|
+
headers: { accept: 'text/turtle, application/json;q=0.9, */*;q=0.8', ...(etag ? { 'if-none-match': etag } : {}) },
|
|
67
|
+
});
|
|
68
|
+
if (res.status === 304) return { ok: true, notModified: true, status: 304, body: null, etag };
|
|
69
|
+
if (res.status >= 400) return { ok: false, notModified: false, status: res.status, body: null, etag: null };
|
|
70
|
+
return { ok: true, notModified: false, status: res.status, body: await res.text(), etag: res.headers.get('etag') };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async write(p, body, contentType) {
|
|
74
|
+
const url = this._url(p); // outside the try, as in FileStorage
|
|
75
|
+
try {
|
|
76
|
+
const res = await this.fetchImpl(url, {
|
|
77
|
+
method: 'PUT', headers: { 'content-type': contentType }, body,
|
|
78
|
+
});
|
|
79
|
+
if (res.status < 400) return { ok: true, retry: false, why: '' };
|
|
80
|
+
// A 4xx is an answer, not a hiccup: retrying a 403 or a 409 four more
|
|
81
|
+
// times just spends the pod's write lock to be told the same thing.
|
|
82
|
+
const retry = res.status >= 500 || res.status === 429;
|
|
83
|
+
const ra = Number(res.headers?.get?.('retry-after'));
|
|
84
|
+
return {
|
|
85
|
+
ok: false, retry, why: `HTTP ${res.status}`,
|
|
86
|
+
retryAfterMs: Number.isFinite(ra) && ra > 0 ? ra * 1000 : 0,
|
|
87
|
+
};
|
|
88
|
+
} catch (e) {
|
|
89
|
+
return { ok: false, retry: true, why: e.message, retryAfterMs: 0 };
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async remove(p) {
|
|
94
|
+
const url = this._url(p); // outside the try, as in FileStorage
|
|
95
|
+
try {
|
|
96
|
+
const res = await this.fetchImpl(url, { method: 'DELETE' });
|
|
97
|
+
return res.status < 400 || res.status === 404;
|
|
98
|
+
} catch { return false; }
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// A directory. No server, so no round-trip and nothing to be unreachable —
|
|
103
|
+
// which is why a write here is never worth retrying: an EACCES will still be
|
|
104
|
+
// an EACCES in two seconds.
|
|
105
|
+
export class FileStorage {
|
|
106
|
+
constructor(base) {
|
|
107
|
+
this.base = slash(base.startsWith('file:') ? base : pathToFileURL(base).href);
|
|
108
|
+
// resolve() drops the trailing separator, which the jail check below needs:
|
|
109
|
+
// '/a/b/' + sep is '/a/b//', and nothing under it starts with that.
|
|
110
|
+
this.dir = path.resolve(fileURLToPath(this.base));
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
get kind() { return 'files'; }
|
|
114
|
+
|
|
115
|
+
_path(p) {
|
|
116
|
+
const full = path.resolve(this.dir, decodeURIComponent(p));
|
|
117
|
+
if (full !== this.dir && !full.startsWith(this.dir + path.sep)) {
|
|
118
|
+
throw new Error(`path escapes the container: ${p}`);
|
|
119
|
+
}
|
|
120
|
+
return full;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async list(sub = '') {
|
|
124
|
+
let entries;
|
|
125
|
+
try { entries = await fsp.readdir(this._path(sub), { withFileTypes: true }); }
|
|
126
|
+
catch (e) { if (e.code === 'ENOENT') return { notModified: false, names: [], etag: null }; throw e; }
|
|
127
|
+
return {
|
|
128
|
+
notModified: false, etag: null,
|
|
129
|
+
names: entries.map(e => (e.isDirectory() ? e.name + '/' : e.name)),
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// The jail check is deliberately outside the try: a path that climbs out of
|
|
134
|
+
// its container is a bug in the caller, and reporting it as "could not read"
|
|
135
|
+
// would hide it. Only actual I/O is reported.
|
|
136
|
+
async read(p) {
|
|
137
|
+
const full = this._path(p);
|
|
138
|
+
try {
|
|
139
|
+
return { ok: true, notModified: false, status: 200, body: await fsp.readFile(full, 'utf8'), etag: null };
|
|
140
|
+
} catch (e) {
|
|
141
|
+
return { ok: false, notModified: false, status: e.code === 'ENOENT' ? 404 : 500, body: null, etag: null };
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Written to a neighbour and renamed: rename is atomic on POSIX, so a reader
|
|
146
|
+
// — or a backup — never sees half a document.
|
|
147
|
+
async write(p, body) {
|
|
148
|
+
const full = this._path(p); // throws on an escape
|
|
149
|
+
try {
|
|
150
|
+
await fsp.mkdir(path.dirname(full), { recursive: true, mode: 0o700 });
|
|
151
|
+
const tmp = `${full}.${process.pid}.tmp`;
|
|
152
|
+
await fsp.writeFile(tmp, body, { mode: 0o600 });
|
|
153
|
+
await fsp.rename(tmp, full);
|
|
154
|
+
return { ok: true, retry: false, why: '' };
|
|
155
|
+
} catch (e) {
|
|
156
|
+
return { ok: false, retry: false, why: e.message };
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async remove(p) {
|
|
161
|
+
const full = this._path(p); // throws on an escape
|
|
162
|
+
try { await fsp.rm(full, { force: true }); return true; }
|
|
163
|
+
catch { return false; }
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// `file:` (or a bare path) is a directory; anything else is a pod.
|
|
168
|
+
export function storageFor(base, fetchImpl) {
|
|
169
|
+
return /^https?:/i.test(base) ? new HttpStorage(base, fetchImpl) : new FileStorage(base);
|
|
170
|
+
}
|