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/admin.mjs
ADDED
|
@@ -0,0 +1,1606 @@
|
|
|
1
|
+
// admin.mjs — loopback HTTP front: admin API + Mastodon client-API facade +
|
|
2
|
+
// the bundled Phanpy UI served same-origin (no CORS, no mixed content — the
|
|
3
|
+
// role data-kitchen's router plays for the in-app pane). Loopback-bound; the
|
|
4
|
+
// gate (vendor/gate.cjs) engages only when a token is configured, exactly
|
|
5
|
+
// its standalone behavior.
|
|
6
|
+
|
|
7
|
+
import crypto from 'node:crypto';
|
|
8
|
+
import http from 'node:http';
|
|
9
|
+
import https from 'node:https';
|
|
10
|
+
import net from 'node:net';
|
|
11
|
+
import fs from 'node:fs';
|
|
12
|
+
import { spawn } from 'node:child_process';
|
|
13
|
+
import { createRequire } from 'node:module';
|
|
14
|
+
import path from 'node:path';
|
|
15
|
+
import os from 'node:os';
|
|
16
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
17
|
+
import { followHandle, followActor, unfollowActor, ejectFollower, retractAnnouncement,
|
|
18
|
+
admitRequest, refuseRequest, resolveHandle, applyModeration, announceModeration } from './social.mjs';
|
|
19
|
+
import { addRemoveActivity, webfingerHost } from './wire.mjs';
|
|
20
|
+
import { MastoApi, hashPassword } from './mastoapi.mjs';
|
|
21
|
+
import { C2S } from './c2s.mjs';
|
|
22
|
+
import { makeC2sAuth } from './oidc-auth.mjs';
|
|
23
|
+
import { Streaming } from './streaming.mjs';
|
|
24
|
+
import { nodeinfoPointer, nodeinfoDoc } from './wire.mjs';
|
|
25
|
+
import { allowedAuthorities, checkRequest, isCrossSiteNavigation, Authorities, hostLabel } from './guard.mjs';
|
|
26
|
+
import { identityHomes, rootOf, tildify, defaultProfile, writeJsonAtomic } from './home.mjs';
|
|
27
|
+
import { copyPrivateHalf, isCurrent, CURRENT_LAYOUT } from './migrate.mjs';
|
|
28
|
+
import { normalizeImport, IMPORT_KINDS } from './import.mjs';
|
|
29
|
+
import { insecureUrlReason } from './safefetch.mjs';
|
|
30
|
+
import { newRun, preflight, runSetup, setupInputError, hasCredential } from './setup.mjs';
|
|
31
|
+
import { portFree, freePortFrom } from './ports.mjs';
|
|
32
|
+
import { claimDirectory, yieldDirectory } from './directory.mjs';
|
|
33
|
+
import { localFetch } from './localapi.mjs';
|
|
34
|
+
import { ensureTrustedTls } from './certs.mjs';
|
|
35
|
+
|
|
36
|
+
const require = createRequire(import.meta.url);
|
|
37
|
+
const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
38
|
+
const { makeGate } = require(path.join(projectRoot, 'vendor/gate.cjs'));
|
|
39
|
+
|
|
40
|
+
const PHANPY_DIR = path.join(projectRoot, 'phanpy/dist');
|
|
41
|
+
const UI_DIR = path.join(projectRoot, 'ui'); // extra client dists: ui/<name>/ → /<name>/
|
|
42
|
+
// Our own pages, kept out of ui/ for two reasons: a client dist dropped in
|
|
43
|
+
// there under the same name would shadow them, and a group serves these and
|
|
44
|
+
// nothing else — so the prefix has to be one nobody is invited to write into.
|
|
45
|
+
// One surface, /admin/, with setup as its first section: /admin/setup/ is the
|
|
46
|
+
// first run, /admin/ is the record, and there is room for the rest.
|
|
47
|
+
const WEB_DIR = path.join(projectRoot, 'web');
|
|
48
|
+
const WEB_MOUNTS = ['admin'];
|
|
49
|
+
const webMount = (pathname) => {
|
|
50
|
+
const seg = decodeURIComponent(pathname).replace(/^\/+/, '').split('/')[0];
|
|
51
|
+
return WEB_MOUNTS.includes(seg) ? seg : null;
|
|
52
|
+
};
|
|
53
|
+
const SETUP_PAGE = '/admin/setup/';
|
|
54
|
+
|
|
55
|
+
// The signup carry-over: gateway and identity parameters a signup page
|
|
56
|
+
// recorded for this install (written by the installer, consumed by the first
|
|
57
|
+
// setup run). Read fresh each time — the file is deleted once consumed.
|
|
58
|
+
function readFirstRun() {
|
|
59
|
+
const file = process.env.AP_FIRST_RUN;
|
|
60
|
+
if (!file) return null;
|
|
61
|
+
try {
|
|
62
|
+
const fr = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
63
|
+
return fr && typeof fr === 'object' ? fr : null;
|
|
64
|
+
} catch { return null; }
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// The same config shape the `gateway` CLI command writes.
|
|
68
|
+
function gatewayConfigFrom(fr) {
|
|
69
|
+
if (!fr?.gateway || !fr?.secret) return null;
|
|
70
|
+
const target = String(fr.gateway);
|
|
71
|
+
return fr.fronted
|
|
72
|
+
? { url: target.replace(/ap\/actor$/, 'ap/inbox/'), frontActor: target,
|
|
73
|
+
mode: 'shadow', hmacSecret: String(fr.secret) }
|
|
74
|
+
: { url: target.replace(/\/?$/, '/'), mode: 'shadow', hmacSecret: String(fr.secret) };
|
|
75
|
+
}
|
|
76
|
+
const AGENT_VERSION = JSON.parse(fs.readFileSync(path.join(projectRoot, 'package.json'), 'utf8')).version;
|
|
77
|
+
// POSTs an unconfigured agent still answers: /block is worth having before
|
|
78
|
+
// federation starts, and /setup is how it stops being unconfigured.
|
|
79
|
+
// /shutdown is here because stopping an agent that was never set up is exactly
|
|
80
|
+
// the case it exists for; it is in LOCAL_ONLY_POSTS below, so it still answers
|
|
81
|
+
// only to this machine.
|
|
82
|
+
const OPEN_POSTS = new Set(['/block', '/unblock', '/setup', '/setup/check', '/shutdown']);
|
|
83
|
+
// Routes that manage a local agent process — spawning siblings, killing this
|
|
84
|
+
// one, moving files on the machine, running setup in a browser. Inside a pod
|
|
85
|
+
// server there is no such process and no such machine: identities come from
|
|
86
|
+
// the server's own configuration, so these are not there to be found.
|
|
87
|
+
const EMBEDDED_CUT = new Set(['/profiles', '/shutdown', '/new-actor', '/start-actor',
|
|
88
|
+
'/state-move', '/setup', '/setup/check']);
|
|
89
|
+
// AP_ALLOWED_HOSTS may name a tailnet host or a reverse-proxy domain. The
|
|
90
|
+
// fediverse is welcome there; creating accounts and editing the record is for
|
|
91
|
+
// whoever is sitting at the machine.
|
|
92
|
+
const LOCAL_ONLY_POSTS = new Set(['/setup', '/setup/check', '/config', '/new-actor', '/start-actor', '/shutdown', '/state-move', '/atproto/connect', '/gateway', '/alias', '/import', '/update']);
|
|
93
|
+
// The identity itself. Changing any of these means a different actor at a
|
|
94
|
+
// different address, which is a new setup, not an edit.
|
|
95
|
+
const PERMANENT_CONFIG = ['handle', 'remotePod', 'issuer', 'root', 'kind'];
|
|
96
|
+
// Config the actor document carries, so a change is not real until it is
|
|
97
|
+
// republished.
|
|
98
|
+
const WIRE_CONFIG = ['name', 'summary', 'icon', 'image', 'fields', 'approveJoins', 'moderators'];
|
|
99
|
+
|
|
100
|
+
const MIME = {
|
|
101
|
+
'.html': 'text/html; charset=utf-8', '.js': 'text/javascript', '.css': 'text/css',
|
|
102
|
+
'.json': 'application/json', '.map': 'application/json', '.webmanifest': 'application/manifest+json',
|
|
103
|
+
'.png': 'image/png', '.svg': 'image/svg+xml', '.ico': 'image/x-icon', '.gif': 'image/gif',
|
|
104
|
+
'.jpg': 'image/jpeg', '.webp': 'image/webp', '.txt': 'text/plain', '.woff2': 'font/woff2',
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
// Sent on every response: nosniff and no-referrer everywhere, and for HTML a
|
|
108
|
+
// CSP that keeps SCRIPTS to our own origin while still allowing the remote
|
|
109
|
+
// avatars, media and instance calls a fediverse client must make. It is not an
|
|
110
|
+
// exfiltration boundary — connect-src has to allow https: for the client to
|
|
111
|
+
// work at all — it is a code-execution one.
|
|
112
|
+
// Phanpy's index.html carries an inline bootstrap script. Rather than
|
|
113
|
+
// opening the policy with 'unsafe-inline', hash the inline scripts we
|
|
114
|
+
// actually ship and allow exactly those.
|
|
115
|
+
let inlineHashes = null;
|
|
116
|
+
function inlineScriptHashes() {
|
|
117
|
+
if (inlineHashes) return inlineHashes;
|
|
118
|
+
inlineHashes = [];
|
|
119
|
+
try {
|
|
120
|
+
const html = fs.readFileSync(path.join(PHANPY_DIR, 'index.html'), 'utf8');
|
|
121
|
+
for (const m of html.matchAll(/<script(?![^>]*\ssrc=)[^>]*>([\s\S]*?)<\/script>/gi)) {
|
|
122
|
+
const digest = crypto.createHash('sha256').update(m[1], 'utf8').digest('base64');
|
|
123
|
+
inlineHashes.push(`'sha256-${digest}'`);
|
|
124
|
+
}
|
|
125
|
+
} catch { /* no inline scripts to allow */ }
|
|
126
|
+
return inlineHashes;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// The origin a browser should be sent to for an identity. A browser keys
|
|
130
|
+
// storage per ORIGIN, so linking every identity at localhost:<port> files them
|
|
131
|
+
// all in one bucket — which is how a client ends up holding one actor's login
|
|
132
|
+
// and showing it on another's page. Named whenever the handle is a legal host
|
|
133
|
+
// label; bare loopback when it is not, because a mangled name would be an
|
|
134
|
+
// origin the other agent's own guard refuses.
|
|
135
|
+
export function namedOrigin(handle, port) {
|
|
136
|
+
const label = hostLabel(handle);
|
|
137
|
+
return `http://${label ? label + '.' : ''}localhost:${port}`;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// The https origin for an identity, on the one port it was given.
|
|
141
|
+
// This is the advertised default — what gets printed, opened and linked; the
|
|
142
|
+
// http listener stays served beside it for anything that needs cleartext.
|
|
143
|
+
export function secureOrigin(handle, httpsPort) {
|
|
144
|
+
const label = hostLabel(handle);
|
|
145
|
+
return `https://${label ? label + '.' : ''}localhost:${httpsPort}`;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export function wsOrigins(port, labels = []) {
|
|
149
|
+
const out = new Set();
|
|
150
|
+
for (const a of allowedAuthorities(port, labels)) {
|
|
151
|
+
// No IPv6 at all. Bare `::1` cannot carry a port, and Chrome rejects the
|
|
152
|
+
// bracketed form inside a CSP source expression — one invalid source makes
|
|
153
|
+
// it drop the whole directive, which is worse than not listing the socket.
|
|
154
|
+
if (a.startsWith('::') || a.startsWith('[')) continue;
|
|
155
|
+
out.add(`wss://${/:\d+$/.test(a) ? a : `${a}:${port}`}`);
|
|
156
|
+
}
|
|
157
|
+
return [...out];
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function securityHeaders(auth, isHtml) {
|
|
161
|
+
const h = {
|
|
162
|
+
'x-content-type-options': 'nosniff',
|
|
163
|
+
'referrer-policy': 'no-referrer',
|
|
164
|
+
// SAMEORIGIN, not DENY: /admin/client/ frames the bundled client so a bar
|
|
165
|
+
// of ours can sit above it. Only pages on this agent's own origins may —
|
|
166
|
+
// the same set the Host/Origin firewall already trusts.
|
|
167
|
+
'x-frame-options': 'SAMEORIGIN',
|
|
168
|
+
};
|
|
169
|
+
if (isHtml) {
|
|
170
|
+
h['content-security-policy'] = [
|
|
171
|
+
"default-src 'self'",
|
|
172
|
+
`script-src 'self' 'wasm-unsafe-eval' ${inlineScriptHashes().join(' ')}`,
|
|
173
|
+
"style-src 'self' 'unsafe-inline'",
|
|
174
|
+
"img-src 'self' https: data: blob:",
|
|
175
|
+
"media-src 'self' https: data: blob:",
|
|
176
|
+
"font-src 'self' data:",
|
|
177
|
+
// Every authority the Host/Origin firewall accepts, so browsing an agent
|
|
178
|
+
// at its own name (solo.localhost:8041, a tailnet host) keeps streaming.
|
|
179
|
+
// Pinning this to localhost blocked the socket with no visible error.
|
|
180
|
+
//
|
|
181
|
+
// `https:` is still here and the comment above no longer claims otherwise:
|
|
182
|
+
// a fediverse client fetches remote instances by design — link previews,
|
|
183
|
+
// an actor's own server, media — so there is no narrower set that leaves
|
|
184
|
+
// it working. The XSS story is `script-src 'self'` plus hashes; treat
|
|
185
|
+
// connect-src as availability, not containment.
|
|
186
|
+
`connect-src 'self' https: ${(typeof auth.wsAuthorities === 'function'
|
|
187
|
+
? auth.wsAuthorities()
|
|
188
|
+
: wsOrigins(auth.port, auth.labels())).join(' ')}`,
|
|
189
|
+
"frame-ancestors 'self'",
|
|
190
|
+
"base-uri 'self'",
|
|
191
|
+
"form-action 'self'",
|
|
192
|
+
].join('; ');
|
|
193
|
+
}
|
|
194
|
+
return h;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function sendJson(res, status, obj, auth) {
|
|
198
|
+
res.writeHead(status, { 'content-type': 'application/json', ...securityHeaders(auth, false) });
|
|
199
|
+
res.end(JSON.stringify(obj) + '\n');
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// A cross-origin form POST needs no CORS preflight, and JSON.parse does not
|
|
203
|
+
// care what Content-Type claimed — so parsing whatever arrived let a visited
|
|
204
|
+
// page reach every write route with a body of its choosing. Our own callers
|
|
205
|
+
// (web/admin/*.js, the CLI) all send application/json; `stop` sends no body at
|
|
206
|
+
// all, which is why an absent type is allowed only for an empty one.
|
|
207
|
+
function readBody(req) {
|
|
208
|
+
return new Promise((resolve, reject) => {
|
|
209
|
+
const ct = String(req.headers['content-type'] || '').split(';')[0].trim().toLowerCase();
|
|
210
|
+
const isJson = ct === 'application/json';
|
|
211
|
+
const wrongType = () => reject(new Error('expected content-type: application/json'));
|
|
212
|
+
if (ct && !isJson) { req.resume(); wrongType(); return; }
|
|
213
|
+
let data = '';
|
|
214
|
+
req.on('data', c => {
|
|
215
|
+
data += c;
|
|
216
|
+
if (data.length > 1e6) { reject(new Error('request body too large')); req.destroy(); }
|
|
217
|
+
});
|
|
218
|
+
req.on('end', () => {
|
|
219
|
+
if (data && !isJson) return wrongType();
|
|
220
|
+
try { resolve(data ? JSON.parse(data) : {}); } catch (e) { reject(e); }
|
|
221
|
+
});
|
|
222
|
+
req.on('error', reject);
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Static UI serving, path-jailed. Phanpy owns the root; any other client
|
|
227
|
+
// dist dropped into ui/<name>/ is served at /<name>/. Hash-routed apps:
|
|
228
|
+
// '/' (and directories) get their index.html; anything unknown 404s.
|
|
229
|
+
function serveStatic(res, pathname, auth) {
|
|
230
|
+
let rel = decodeURIComponent(pathname).replace(/^\/+/, '');
|
|
231
|
+
let baseDir = PHANPY_DIR;
|
|
232
|
+
const uiName = rel.split('/')[0];
|
|
233
|
+
// The mount name is joined to UI_DIR, so it must be CONTAINED by it — the
|
|
234
|
+
// same resolve-then-check sendFile does below, for the same reason.
|
|
235
|
+
//
|
|
236
|
+
// Decoding happens before the split, so a `%2f` in the first segment becomes
|
|
237
|
+
// a real separator afterwards and `..` arrives here as a mount name. It
|
|
238
|
+
// exists, and it is a directory, so baseDir was silently re-based to the
|
|
239
|
+
// project root — and sendFile's jail then enforced containment against THAT,
|
|
240
|
+
// dutifully approving `/..%2fpackage.json`, `/..%2f.git/config` and every
|
|
241
|
+
// source file under it for anyone who could reach the port.
|
|
242
|
+
// Resolved through symlinks, not just lexically: sendFile's own jail has
|
|
243
|
+
// always used realpath, and a lexical check here would still admit a mount
|
|
244
|
+
// that is a link pointing out of ui/.
|
|
245
|
+
let mount = '';
|
|
246
|
+
try {
|
|
247
|
+
if (uiName) {
|
|
248
|
+
const cand = path.resolve(UI_DIR, uiName);
|
|
249
|
+
if (cand.startsWith(UI_DIR + path.sep) && fs.statSync(cand).isDirectory()) {
|
|
250
|
+
const real = fs.realpathSync(cand);
|
|
251
|
+
if (real.startsWith(fs.realpathSync(UI_DIR) + path.sep)) mount = cand;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
} catch { /* no such mount; fall through to the default base */ }
|
|
255
|
+
if (mount) {
|
|
256
|
+
baseDir = mount;
|
|
257
|
+
rel = rel.slice(uiName.length).replace(/^\/+/, '');
|
|
258
|
+
}
|
|
259
|
+
return sendFile(res, baseDir, rel, auth);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// '/admin/setup' names a directory, so it needs the slash the browser will
|
|
263
|
+
// resolve relative URLs against. Returns the corrected path, or null.
|
|
264
|
+
function webDirRedirect(pathname) {
|
|
265
|
+
if (pathname.endsWith('/')) return null;
|
|
266
|
+
const rel = decodeURIComponent(pathname).replace(/^\/+/, '');
|
|
267
|
+
const full = path.normalize(path.join(WEB_DIR, rel));
|
|
268
|
+
if (!full.startsWith(WEB_DIR + path.sep)) return null; // not ours to stat
|
|
269
|
+
try {
|
|
270
|
+
if (fs.statSync(full).isDirectory()) return pathname + '/';
|
|
271
|
+
} catch { /* not a directory here */ }
|
|
272
|
+
return null;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// web/<mount>/ → /<mount>/. Same jail, different mount rule.
|
|
276
|
+
function serveWeb(res, pathname, mount, auth) {
|
|
277
|
+
const rel = decodeURIComponent(pathname).replace(/^\/+/, '').slice(mount.length).replace(/^\/+/, '');
|
|
278
|
+
return sendFile(res, path.join(WEB_DIR, mount), rel, auth);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function sendFile(res, baseDir, rel, auth) {
|
|
282
|
+
if (rel === '' || rel.endsWith('/')) rel += 'index.html';
|
|
283
|
+
const file = path.normalize(path.join(baseDir, rel));
|
|
284
|
+
if (!file.startsWith(baseDir + path.sep) && file !== path.join(baseDir, 'index.html')) {
|
|
285
|
+
res.writeHead(403); res.end(); return true;
|
|
286
|
+
}
|
|
287
|
+
let target = file;
|
|
288
|
+
try {
|
|
289
|
+
if (fs.statSync(target).isDirectory()) target = path.join(target, 'index.html');
|
|
290
|
+
// Resolve symlinks before reading: a link inside a UI dir must not be a
|
|
291
|
+
// way out of the jail.
|
|
292
|
+
const real = fs.realpathSync(target);
|
|
293
|
+
const realBase = fs.realpathSync(baseDir);
|
|
294
|
+
if (!real.startsWith(realBase + path.sep) && real !== path.join(realBase, 'index.html')) {
|
|
295
|
+
res.writeHead(403); res.end(); return true;
|
|
296
|
+
}
|
|
297
|
+
const ext = path.extname(real);
|
|
298
|
+
const body = fs.readFileSync(real);
|
|
299
|
+
// Our own pages are read straight off disk and change whenever the project
|
|
300
|
+
// does. With no cache headers a browser is free to reuse them without
|
|
301
|
+
// asking, so an edited page keeps rendering the old one and looks like the
|
|
302
|
+
// edit never landed. The vendored client dists have hashed filenames and
|
|
303
|
+
// are left alone.
|
|
304
|
+
const ours = real.startsWith(fs.realpathSync(WEB_DIR) + path.sep);
|
|
305
|
+
res.writeHead(200, {
|
|
306
|
+
'content-type': MIME[ext] || 'application/octet-stream',
|
|
307
|
+
...(ours ? { 'cache-control': 'no-store' } : {}),
|
|
308
|
+
...securityHeaders(auth, ext === '.html'),
|
|
309
|
+
});
|
|
310
|
+
res.end(body);
|
|
311
|
+
} catch {
|
|
312
|
+
res.writeHead(404, { 'content-type': 'text/plain', ...securityHeaders(auth, false) });
|
|
313
|
+
res.end('not found\n');
|
|
314
|
+
}
|
|
315
|
+
return true;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// The whole admin/facade surface as one request handler, with nothing that
|
|
319
|
+
// belongs to a process of its own. startAdmin wraps it in listeners; the CSS
|
|
320
|
+
// component hands it CSS's, so the same routes answer on the pod's origin.
|
|
321
|
+
//
|
|
322
|
+
// `embedded` cuts the routes that manage local agent processes — they have no
|
|
323
|
+
// meaning inside a server that is not one — and moves the gate: a pod that is
|
|
324
|
+
// a fediverse instance must let strangers reach /api and /oauth, so the gate
|
|
325
|
+
// guards the operator's door (basePath) instead of the whole surface.
|
|
326
|
+
export function buildAdminSurface({ agent, gate, allowed, log = console.log,
|
|
327
|
+
port = null, handle = null, embedded = false, basePath = '/',
|
|
328
|
+
publicOrigin = null, scheme = null }) {
|
|
329
|
+
const json = (res, status, obj) => sendJson(res, status, obj, allowed);
|
|
330
|
+
const masto = new MastoApi({ agent, log, allowed, scheme, embedded });
|
|
331
|
+
// The spec's own write API (§6), beside the facade. Its bearer fallback is
|
|
332
|
+
// the facade's token, so the two surfaces share one notion of the operator.
|
|
333
|
+
const c2s = new C2S({ agent, log, auth: makeC2sAuth({ agent, masto, log, scheme }) });
|
|
334
|
+
const streaming = new Streaming({ masto, log, allowed, gate, gateOptional: embedded });
|
|
335
|
+
// Asked per request, not once here: startAdmin runs before connect, so the
|
|
336
|
+
// kind is not known yet at mount time.
|
|
337
|
+
const isGroup = () => agent.store.getConfig()?.kind === 'group';
|
|
338
|
+
// One setup at a time, and the record outlives the run: a reloaded page
|
|
339
|
+
// must still find out how the run it started ended.
|
|
340
|
+
let setupRun = null;
|
|
341
|
+
// New statuses/notifications flow to connected streaming clients live.
|
|
342
|
+
agent.store.onEvent = (type, obj) => {
|
|
343
|
+
try {
|
|
344
|
+
if (type === 'status') streaming.broadcast('update', masto.status(obj));
|
|
345
|
+
else if (type === 'notification') {
|
|
346
|
+
streaming.broadcast('notification', masto.notification(obj));
|
|
347
|
+
// and out to any closed client, via its push subscription
|
|
348
|
+
masto.pushNotify(obj).catch(e => log(`webpush: ${e.message}`));
|
|
349
|
+
}
|
|
350
|
+
} catch (e) { log(`streaming broadcast: ${e.message}`); }
|
|
351
|
+
};
|
|
352
|
+
|
|
353
|
+
// A path as the browser must ask for it: behind the door, prefixed with it.
|
|
354
|
+
const atPath = (p_) => (basePath === '/' ? p_ : basePath.slice(0, -1) + p_);
|
|
355
|
+
|
|
356
|
+
const handler = async (req, res) => {
|
|
357
|
+
const url = new URL(req.url, 'http://localhost');
|
|
358
|
+
// Mastodon-style: the bearer-gated client API and the OAuth + nodeinfo
|
|
359
|
+
// routes answer any origin — a browser client is served the way any
|
|
360
|
+
// instance serves it. CORS headers and the preflight make that work; the
|
|
361
|
+
// bearer stays the only credential, and the Host check below (which is
|
|
362
|
+
// what stops DNS rebinding) still runs.
|
|
363
|
+
const apiPath = url.pathname.startsWith('/api/') || url.pathname.startsWith('/oauth/')
|
|
364
|
+
|| url.pathname === '/.well-known/nodeinfo' || url.pathname === '/nodeinfo/2.0';
|
|
365
|
+
if (apiPath) {
|
|
366
|
+
res.setHeader('access-control-allow-origin', '*');
|
|
367
|
+
res.setHeader('access-control-expose-headers', 'Link');
|
|
368
|
+
if (req.method === 'OPTIONS') {
|
|
369
|
+
res.writeHead(204, {
|
|
370
|
+
'access-control-allow-methods': 'GET, POST, PUT, PATCH, DELETE, OPTIONS',
|
|
371
|
+
'access-control-allow-headers': 'Authorization, Content-Type, Idempotency-Key',
|
|
372
|
+
'access-control-max-age': '86400',
|
|
373
|
+
});
|
|
374
|
+
res.end();
|
|
375
|
+
return;
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
// Host/Origin firewall: loopback binding alone does not keep a visited web
|
|
379
|
+
// page (or a rebound DNS name) out. The API paths keep the Host check but
|
|
380
|
+
// answer a foreign Origin, per above.
|
|
381
|
+
const bad = checkRequest(req, allowed, { ignoreOrigin: apiPath });
|
|
382
|
+
if (bad) {
|
|
383
|
+
log(`refused: ${bad} (${req.method} ${req.url})`);
|
|
384
|
+
res.writeHead(403, { 'content-type': 'text/plain', ...securityHeaders(allowed, false) });
|
|
385
|
+
res.end('forbidden\n');
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
388
|
+
let p = url.pathname;
|
|
389
|
+
// Embedded, the operator's door is one path on the pod's origin. Behind it
|
|
390
|
+
// is everything that was the admin server; in front of it are the protocol
|
|
391
|
+
// routes, which have to answer strangers because that is what makes the pod
|
|
392
|
+
// an instance other software can talk to.
|
|
393
|
+
let atDoor = !embedded;
|
|
394
|
+
if (embedded && basePath !== '/'
|
|
395
|
+
&& (p === basePath.slice(0, -1) || p.startsWith(basePath))) {
|
|
396
|
+
atDoor = true;
|
|
397
|
+
p = p.slice(basePath.length - 1) || '/';
|
|
398
|
+
}
|
|
399
|
+
if (embedded && atDoor && EMBEDDED_CUT.has(p)) {
|
|
400
|
+
return json(res, 404, { error: 'not available on a server-hosted identity' });
|
|
401
|
+
}
|
|
402
|
+
try {
|
|
403
|
+
// C2S (ActivityPub §6) carries its own authentication — a Solid-OIDC
|
|
404
|
+
// DPoP proof or the facade's bearer — so the dk-token gate does not
|
|
405
|
+
// stand in front of it. The Host/Origin firewall above still does.
|
|
406
|
+
if (p === '/ap/outbox' || p === '/ap/actor') {
|
|
407
|
+
if (await c2s.handle(req, res, p, url)) return;
|
|
408
|
+
}
|
|
409
|
+
if (atDoor && gate(req, res)) return;
|
|
410
|
+
if (p === '/api/v1/streaming/health') {
|
|
411
|
+
res.writeHead(200, { 'content-type': 'text/plain' }); res.end('OK'); return;
|
|
412
|
+
}
|
|
413
|
+
// NodeInfo on the agent origin — clients probe it at login.
|
|
414
|
+
if (p === '/.well-known/nodeinfo') {
|
|
415
|
+
return json(res, 200, nodeinfoPointer(
|
|
416
|
+
`${req.socket.encrypted || req.headers['x-forwarded-proto'] === 'https' ? 'https' : 'http'}://${req.headers.host}/nodeinfo/2.0`));
|
|
417
|
+
}
|
|
418
|
+
if (p === '/nodeinfo/2.0') {
|
|
419
|
+
return json(res, 200, nodeinfoDoc({
|
|
420
|
+
version: AGENT_VERSION,
|
|
421
|
+
localPosts: agent.store.countStatuses('post'),
|
|
422
|
+
}));
|
|
423
|
+
}
|
|
424
|
+
// A group serves the client too, as of 2026-08-01. It was withheld on the
|
|
425
|
+
// reasoning that a group has no timeline a human reads — which was wrong
|
|
426
|
+
// twice over: a group has statuses (what it carried) and notifications
|
|
427
|
+
// (who joined), and its operator has a bio to edit and a profile they
|
|
428
|
+
// want to see the way everyone else does. The surface this opens is a
|
|
429
|
+
// login and client tokens, and `passwd` has never had a group carve-out,
|
|
430
|
+
// so a group can be gated exactly like a person before it is exposed.
|
|
431
|
+
if (p.startsWith('/api/') || p.startsWith('/oauth/')) {
|
|
432
|
+
if (await masto.handle(req, res, p, url)) return;
|
|
433
|
+
}
|
|
434
|
+
if (embedded && !atDoor) return json(res, 404, { error: 'unknown endpoint' });
|
|
435
|
+
if (req.method === 'GET' && p === '/status') return json(res, 200, agent.status());
|
|
436
|
+
// The moderation queue: what listed moderators asked for over federation,
|
|
437
|
+
// waiting for the operator to vouch (or not). See POST /modqueue.
|
|
438
|
+
if (req.method === 'GET' && p === '/modqueue') {
|
|
439
|
+
return json(res, 200, agent.store.getConfig()?.kind === 'group'
|
|
440
|
+
? agent.store.read('modqueue.json', []) : []);
|
|
441
|
+
}
|
|
442
|
+
// The inbox gateway (optional, opt-in). Its state, and the shadow-mode
|
|
443
|
+
// measurement that says whether it is worth trusting. The HMAC secret is
|
|
444
|
+
// never returned — only whether one is set.
|
|
445
|
+
if (req.method === 'GET' && p === '/gateway') {
|
|
446
|
+
const g = agent.store.getConfig()?.gateway || null;
|
|
447
|
+
return json(res, 200, {
|
|
448
|
+
configured: !!(g && g.url),
|
|
449
|
+
url: g?.url || null, webId: g?.webId || null,
|
|
450
|
+
frontActor: g?.frontActor || null,
|
|
451
|
+
mode: g?.mode || 'off', hasSecret: !!g?.hmacSecret,
|
|
452
|
+
stats: agent.store.read('gateway-stats.json', { verified: 0, unverified: 0, lastAt: null }),
|
|
453
|
+
});
|
|
454
|
+
}
|
|
455
|
+
// The other identities on this machine, so the page can link to each one.
|
|
456
|
+
// Their `agent.json` is the only file read — a port and a handle. A
|
|
457
|
+
// sibling's credential and keys are never opened; anything else shown
|
|
458
|
+
// here comes from that agent answering /status for itself.
|
|
459
|
+
if (req.method === 'GET' && p === '/profiles') {
|
|
460
|
+
const here = path.resolve(agent.home || '');
|
|
461
|
+
// Which identity a plain command means: the last one started. A
|
|
462
|
+
// different question from `current`, which is the page you are on.
|
|
463
|
+
const wasLast = defaultProfile(rootOf(here));
|
|
464
|
+
const rows = await Promise.all(identityHomes(rootOf(here)).map(async ({ name, dir }) => {
|
|
465
|
+
// agent.json holds a port AND a handle — setup records both, exactly
|
|
466
|
+
// so the named origin can be built before that agent has said a word.
|
|
467
|
+
let rec = {};
|
|
468
|
+
try { rec = JSON.parse(fs.readFileSync(path.join(dir, 'agent.json'), 'utf8')) || {}; } catch {}
|
|
469
|
+
const port = rec.port || null;
|
|
470
|
+
// A directory under profiles/ is not an identity until it holds a
|
|
471
|
+
// credential or has run somewhere. A half-finished setup leaves one
|
|
472
|
+
// behind, and listing it offers a page that cannot exist yet.
|
|
473
|
+
if (!port && !fs.existsSync(path.join(dir, 'credential.json'))) return null;
|
|
474
|
+
const current = path.resolve(dir) === here;
|
|
475
|
+
const live = current ? agent.status() : port
|
|
476
|
+
? await localFetch(agent.home, port, '/status', { timeout: 1200 })
|
|
477
|
+
.then(r => r.json()).catch(() => null)
|
|
478
|
+
: null;
|
|
479
|
+
// The handle is right here — spending it on the origin is the whole
|
|
480
|
+
// point of having asked that agent who it is. A stopped one never
|
|
481
|
+
// answers, so its recorded handle stands in: the link has to be named
|
|
482
|
+
// before you get there, or starting it lands you on the shared origin.
|
|
483
|
+
const handle = live?.handle || rec.handle || null;
|
|
484
|
+
const origin = port
|
|
485
|
+
? secureOrigin(handle, port)
|
|
486
|
+
: null;
|
|
487
|
+
// The fediverse address, assembled from the two things /status has:
|
|
488
|
+
// the handle, and the pod host its actor URL sits on.
|
|
489
|
+
let address = null;
|
|
490
|
+
if (live?.handle && live?.actor) {
|
|
491
|
+
try { address = `${live.handle}@${new URL(live.actor).host}`; } catch { /* not a URL yet */ }
|
|
492
|
+
}
|
|
493
|
+
return {
|
|
494
|
+
name, port, current,
|
|
495
|
+
admin: origin ? `${origin}/admin/` : null,
|
|
496
|
+
app: origin ? `${origin}/` : null,
|
|
497
|
+
handle,
|
|
498
|
+
address,
|
|
499
|
+
lastUsed: name === wasLast,
|
|
500
|
+
kind: live?.kind || null,
|
|
501
|
+
mode: live ? live.mode : null,
|
|
502
|
+
};
|
|
503
|
+
}));
|
|
504
|
+
return json(res, 200, { identities: rows.filter(Boolean) });
|
|
505
|
+
}
|
|
506
|
+
if (req.method === 'GET' && p === '/blocks') return json(res, 200, agent.store.getBlocklist());
|
|
507
|
+
if (req.method === 'GET' && p === '/log') return json(res, 200, { lines: agent.logLines(200) });
|
|
508
|
+
if (req.method === 'GET' && p === '/deadletter') return json(res, 200, { items: agent.store.getDeadLetters() });
|
|
509
|
+
if (req.method === 'GET' && p === '/tagfeed') {
|
|
510
|
+
return json(res, 200, agent.tagfeed
|
|
511
|
+
? { ...agent.tagfeed.config(), lastSweep: agent.tagfeed.lastSweep, lastAdded: agent.tagfeed.lastAdded }
|
|
512
|
+
: { error: 'agent not configured' });
|
|
513
|
+
}
|
|
514
|
+
// ---- setup and configuration, for the pages under /admin/ ----
|
|
515
|
+
// These answer for a group too: a group is set up in the browser like
|
|
516
|
+
// anything else, and it has a display name to change.
|
|
517
|
+
if (req.method === 'GET' && p === '/setup/state') {
|
|
518
|
+
const home = agent.home || null;
|
|
519
|
+
const held = home && hasCredential(home) ? agent.readCredential?.() : null;
|
|
520
|
+
return json(res, 200, {
|
|
521
|
+
hasCredential: !!(home && hasCredential(home)),
|
|
522
|
+
configured: agent.configured(),
|
|
523
|
+
// Not the same question: a crashed setup leaves a credential with no
|
|
524
|
+
// actor behind it, and that is finishable without minting a second.
|
|
525
|
+
resumable: !!(home && hasCredential(home)) && !agent.configured(),
|
|
526
|
+
running: setupRun?.phase === 'running',
|
|
527
|
+
phase: setupRun?.phase || 'idle',
|
|
528
|
+
home,
|
|
529
|
+
port,
|
|
530
|
+
handle: agent.store.getConfig()?.handle || allowed.label || null,
|
|
531
|
+
kind: agent.store.getConfig()?.kind || null,
|
|
532
|
+
origins: {
|
|
533
|
+
loopback: publicOrigin
|
|
534
|
+
|| `https://localhost:${port}/`,
|
|
535
|
+
named: publicOrigin
|
|
536
|
+
? null
|
|
537
|
+
: allowed.label
|
|
538
|
+
? (true
|
|
539
|
+
? `https://${allowed.label}.localhost:${port}/`
|
|
540
|
+
: `http://${allowed.label}.localhost:${port}/`)
|
|
541
|
+
: null,
|
|
542
|
+
},
|
|
543
|
+
// Set in the environment, the password never needs to reach the page.
|
|
544
|
+
passwordSupplied: !!process.env.AP_PASSWORD,
|
|
545
|
+
identity: held ? { pod: held.remotePod, issuer: held.issuerOrigin, root: held.root || null } : null,
|
|
546
|
+
defaults: { issuer: 'https://solidcommunity.net', port: 8030 },
|
|
547
|
+
// What a signup page arranged for this install, so the form opens
|
|
548
|
+
// pre-filled. The receipt secret stays out of it — the /setup run
|
|
549
|
+
// reads that from the file itself.
|
|
550
|
+
firstRun: (() => {
|
|
551
|
+
if (agent.configured()) return null;
|
|
552
|
+
const fr = readFirstRun();
|
|
553
|
+
if (!fr?.handle) return null;
|
|
554
|
+
let gatewayHost = null;
|
|
555
|
+
try { gatewayHost = new URL(fr.gateway).host; } catch { /* no gateway named */ }
|
|
556
|
+
return { pod: fr.pod || null, issuer: fr.issuer || null, handle: String(fr.handle),
|
|
557
|
+
kind: fr.kind === 'group' ? 'group' : 'person', fronted: !!fr.fronted, gatewayHost };
|
|
558
|
+
})(),
|
|
559
|
+
});
|
|
560
|
+
}
|
|
561
|
+
if (req.method === 'GET' && p === '/setup/progress') {
|
|
562
|
+
return json(res, 200, setupRun || { phase: 'idle', steps: [], error: null, result: null });
|
|
563
|
+
}
|
|
564
|
+
if (req.method === 'GET' && p === '/config') {
|
|
565
|
+
const cfg = agent.store.getConfig();
|
|
566
|
+
if (!cfg) return json(res, 409, { error: 'agent not configured — set it up at /admin/setup/' });
|
|
567
|
+
const urls = agent.urls || agent.publisher?.urls || null;
|
|
568
|
+
const wfHost = urls ? new URL(urls.base).host : null;
|
|
569
|
+
return json(res, 200, {
|
|
570
|
+
// permanent
|
|
571
|
+
handle: cfg.handle, remotePod: cfg.remotePod, issuer: cfg.issuer,
|
|
572
|
+
root: cfg.root || null, kind: cfg.kind || 'person',
|
|
573
|
+
actor: urls?.actor || null, webId: agent.readCredential?.()?.webId || null,
|
|
574
|
+
// The opaque id the client addresses this actor by, so the record can
|
|
575
|
+
// link straight at its profile there. Derived from the actor URL the
|
|
576
|
+
// same way every other id is — computed here rather than in the page,
|
|
577
|
+
// which has no business knowing how they are made.
|
|
578
|
+
accountId: urls?.actor ? agent.store.idFor(urls.actor) : null,
|
|
579
|
+
address: wfHost ? `@${cfg.handle}@${wfHost}` : null,
|
|
580
|
+
// editable
|
|
581
|
+
name: cfg.name || null, summary: cfg.summary || null, icon: cfg.icon || null,
|
|
582
|
+
image: cfg.image || null, fields: cfg.fields || [],
|
|
583
|
+
approveJoins: !!cfg.approveJoins, review: !!cfg.review,
|
|
584
|
+
aliases: cfg.aliases || [],
|
|
585
|
+
autoAcceptFollows: !!cfg.autoAcceptFollows,
|
|
586
|
+
// never the record itself
|
|
587
|
+
hasUiPassword: !!cfg.uiPassword,
|
|
588
|
+
quiescedAt: cfg.quiescedAt || null, movedTo: cfg.movedTo || null,
|
|
589
|
+
// Per-machine, so it comes from the credential file, not pod config —
|
|
590
|
+
// and it is a fact here, not a setting: moving it means moving data,
|
|
591
|
+
// which is `fedipod state --to <url>`.
|
|
592
|
+
privateRoot: agent.readCredential?.()?.privateRoot || null,
|
|
593
|
+
mode: agent.status?.().mode || null, port, home: tildify(agent.home) || null,
|
|
594
|
+
update: agent.updateInfo || null,
|
|
595
|
+
pendingUpgrade: agent.pendingUpgrade || [],
|
|
596
|
+
// The connected Bluesky account, non-secret half. `connected` is the
|
|
597
|
+
// credential's word, so a config entry orphaned by a deleted
|
|
598
|
+
// atproto.json shows as disconnected rather than pretending.
|
|
599
|
+
atproto: cfg.atproto
|
|
600
|
+
? { ...cfg.atproto, connected: !!agent.atproto?.connected() }
|
|
601
|
+
: null,
|
|
602
|
+
origins: {
|
|
603
|
+
loopback: publicOrigin
|
|
604
|
+
|| `https://localhost:${port}/`,
|
|
605
|
+
named: publicOrigin
|
|
606
|
+
? null
|
|
607
|
+
: allowed.label
|
|
608
|
+
? (true
|
|
609
|
+
? `https://${allowed.label}.localhost:${port}/`
|
|
610
|
+
: `http://${allowed.label}.localhost:${port}/`)
|
|
611
|
+
: null,
|
|
612
|
+
},
|
|
613
|
+
});
|
|
614
|
+
}
|
|
615
|
+
// How far the CSV import has gotten — polled by the CLI while it runs.
|
|
616
|
+
if (req.method === 'GET' && p === '/import') {
|
|
617
|
+
if (!agent.importer) return json(res, 409, { error: 'agent not connected yet' });
|
|
618
|
+
return json(res, 200, agent.importer.progress());
|
|
619
|
+
}
|
|
620
|
+
// Group-only: who is here, and what the group has carried. A group cannot
|
|
621
|
+
// force an unfollow, so muting — declining to carry — is its whole lever.
|
|
622
|
+
if (req.method === 'GET' && p === '/members') {
|
|
623
|
+
if (!isGroup()) return json(res, 404, { error: 'not a group' });
|
|
624
|
+
const muted = agent.store.getMuted().actors;
|
|
625
|
+
return json(res, 200, {
|
|
626
|
+
members: agent.store.getContacts().followers
|
|
627
|
+
.map(f => ({
|
|
628
|
+
actor: f.actor,
|
|
629
|
+
handle: agent.store.handleOf(f.actor),
|
|
630
|
+
inbox: f.sharedInbox || f.inbox,
|
|
631
|
+
muted: muted.includes(f.actor),
|
|
632
|
+
})),
|
|
633
|
+
});
|
|
634
|
+
}
|
|
635
|
+
if (req.method === 'GET' && p === '/announced') {
|
|
636
|
+
if (!isGroup()) return json(res, 404, { error: 'not a group' });
|
|
637
|
+
return json(res, 200, {
|
|
638
|
+
announced: agent.store.getStatuses().filter(s => s.announcedAt)
|
|
639
|
+
.map(s => ({ noteId: s.noteId, actor: s.actor, announcedAt: s.announcedAt })),
|
|
640
|
+
});
|
|
641
|
+
}
|
|
642
|
+
if (req.method === 'GET' && p === '/requests') {
|
|
643
|
+
// Not group-only any more: a person queues unverifiable follows here
|
|
644
|
+
// too, and a queue with no way to read it is worse than no queue.
|
|
645
|
+
return json(res, 200, {
|
|
646
|
+
approveJoins: !!agent.store.getConfig()?.approveJoins,
|
|
647
|
+
requests: agent.store.getRequests().map(r => ({ actor: r.actor, at: r.at })),
|
|
648
|
+
});
|
|
649
|
+
}
|
|
650
|
+
if (req.method === 'GET' && p === '/pending') {
|
|
651
|
+
if (!isGroup()) return json(res, 404, { error: 'not a group' });
|
|
652
|
+
return json(res, 200, {
|
|
653
|
+
review: !!agent.store.getConfig()?.review,
|
|
654
|
+
pending: agent.store.getPending(),
|
|
655
|
+
});
|
|
656
|
+
}
|
|
657
|
+
if (req.method === 'GET' || req.method === 'HEAD') {
|
|
658
|
+
// Our own pages come before the group check: a group is set up in the
|
|
659
|
+
// browser like anything else, and it has a record to edit. It still
|
|
660
|
+
// serves no fediverse client — see the 404 two lines down.
|
|
661
|
+
const mount = webMount(p);
|
|
662
|
+
if (mount) {
|
|
663
|
+
// Without the slash a page's own relative <script src> resolves one
|
|
664
|
+
// level up and 404s — and that is true at any depth, so ask the
|
|
665
|
+
// filesystem rather than only special-casing the mount itself.
|
|
666
|
+
const asDir = webDirRedirect(p);
|
|
667
|
+
if (asDir) {
|
|
668
|
+
res.writeHead(302, { location: atPath(asDir), ...securityHeaders(allowed, false) });
|
|
669
|
+
res.end();
|
|
670
|
+
return;
|
|
671
|
+
}
|
|
672
|
+
return serveWeb(res, p, mount, allowed);
|
|
673
|
+
}
|
|
674
|
+
// The bare URL means "show me what this agent wants from me now".
|
|
675
|
+
// Keyed on the credential FILE, never on configured(): a healthy
|
|
676
|
+
// install whose pod is briefly unreachable reports itself
|
|
677
|
+
// unconfigured for up to an hour, and must not be sent to setup.
|
|
678
|
+
if (p === '/' || p === '/index.html') {
|
|
679
|
+
if (!(agent.home && hasCredential(agent.home))) {
|
|
680
|
+
res.writeHead(302, { location: atPath(SETUP_PAGE), ...securityHeaders(allowed, false) });
|
|
681
|
+
res.end();
|
|
682
|
+
return;
|
|
683
|
+
}
|
|
684
|
+
// Opening the bare origin gets this actor's own client — the framed
|
|
685
|
+
// view with the bar — not the unbound app. The client page frames
|
|
686
|
+
// `/` itself and that load says so (Sec-Fetch-Dest: iframe), so only
|
|
687
|
+
// a top-level navigation is sent onward. The one top-level landing
|
|
688
|
+
// that must NOT be sent onward is the OAuth return, `/?code=…`: the
|
|
689
|
+
// client registered `/` as its redirect URI and only the app at `/`
|
|
690
|
+
// can exchange the code — the framed page would drop it and leave
|
|
691
|
+
// the client logged out.
|
|
692
|
+
const q = req.url.includes('?') ? req.url.slice(req.url.indexOf('?')) : '';
|
|
693
|
+
const oauthLanding = /[?&](code|error)=/.test(q);
|
|
694
|
+
if (!oauthLanding && req.headers['sec-fetch-dest'] === 'document') {
|
|
695
|
+
res.writeHead(302, { location: atPath('/admin/client/') + q, ...securityHeaders(allowed, false) });
|
|
696
|
+
res.end();
|
|
697
|
+
return;
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
return serveStatic(res, p, allowed);
|
|
701
|
+
}
|
|
702
|
+
if (req.method !== 'POST') return json(res, 405, { error: 'POST only' });
|
|
703
|
+
// A body we refuse to read is the caller's mistake, not ours — 400 says
|
|
704
|
+
// so, where the catch-all below would have called it a server fault.
|
|
705
|
+
let body;
|
|
706
|
+
try { body = await readBody(req); }
|
|
707
|
+
catch (e) { return json(res, 400, { error: e.message }); }
|
|
708
|
+
if (!OPEN_POSTS.has(p) && !agent.configured()) {
|
|
709
|
+
return json(res, 409, { error: 'agent not configured — set it up at /admin/setup/' });
|
|
710
|
+
}
|
|
711
|
+
if (!embedded && LOCAL_ONLY_POSTS.has(p) && !allowed.isLocalRequest(req)) {
|
|
712
|
+
return json(res, 403, { error: 'setup and configuration are available on this machine only' });
|
|
713
|
+
}
|
|
714
|
+
switch (p) {
|
|
715
|
+
// Stop an agent whose pidfile is gone and which no terminal owns
|
|
716
|
+
// (backgrounded, orphaned by a closed shell). It used to sit ABOVE both
|
|
717
|
+
// the configured() gate and the isLocal one, so it was the only
|
|
718
|
+
// state-changing route that needed neither — reachable from any host
|
|
719
|
+
// AP_ALLOWED_HOSTS named, on an agent that had never been set up.
|
|
720
|
+
case '/shutdown': {
|
|
721
|
+
json(res, 200, { ok: true, stopping: process.pid });
|
|
722
|
+
setTimeout(() => process.kill(process.pid, 'SIGTERM'), 50);
|
|
723
|
+
return;
|
|
724
|
+
}
|
|
725
|
+
// ---- setup, driven by the page at /admin/setup/ ----
|
|
726
|
+
case '/setup/check': return json(res, 200, preflight(body));
|
|
727
|
+
case '/setup': {
|
|
728
|
+
// A visited page must not be able to navigate this into existence.
|
|
729
|
+
if (isCrossSiteNavigation(req)) return json(res, 403, { error: 'cross-site request' });
|
|
730
|
+
if (agent.configured()) {
|
|
731
|
+
return json(res, 409, {
|
|
732
|
+
error: 'this home already holds an identity',
|
|
733
|
+
pod: agent.store.getConfig()?.remotePod || null,
|
|
734
|
+
});
|
|
735
|
+
}
|
|
736
|
+
if (setupRun?.phase === 'running') {
|
|
737
|
+
return json(res, 409, { error: 'setup is already running', phase: 'running' });
|
|
738
|
+
}
|
|
739
|
+
const home = agent.home;
|
|
740
|
+
if (!home) return json(res, 500, { error: 'this agent has no AP_HOME to set up' });
|
|
741
|
+
// A crashed setup left a credential with no actor behind it. That is
|
|
742
|
+
// finishable, and must NOT mint a second credential — the first is
|
|
743
|
+
// unrecoverable and would be orphaned.
|
|
744
|
+
const resuming = hasCredential(home);
|
|
745
|
+
const answers = { ...body, password: body.password || process.env.AP_PASSWORD || '' };
|
|
746
|
+
// The signup carry-over: attach to the gateway the signup arranged,
|
|
747
|
+
// set before the first publish so the actor never advertises the pod
|
|
748
|
+
// inbox first. The secret comes from the installer's file, never the
|
|
749
|
+
// page.
|
|
750
|
+
const gatewayCfg = gatewayConfigFrom(readFirstRun());
|
|
751
|
+
if (gatewayCfg) answers.gateway = gatewayCfg;
|
|
752
|
+
const bad = setupInputError(answers, resuming);
|
|
753
|
+
if (bad) return json(res, 400, { error: bad });
|
|
754
|
+
if (!resuming) {
|
|
755
|
+
const pre = preflight(answers);
|
|
756
|
+
if (!pre.ok) return json(res, 400, { ...pre, error: pre.error || pre.refusal });
|
|
757
|
+
}
|
|
758
|
+
// Widen the allowlist now, not at the end: the page offers to move to
|
|
759
|
+
// the named origin, which has to answer before it is offered.
|
|
760
|
+
allowed.setHandle(answers.handle);
|
|
761
|
+
setupRun = newRun();
|
|
762
|
+
// Deliberately not awaited. The credential a CSS server mints is
|
|
763
|
+
// shown once, so what a run depends on is the file it writes, never
|
|
764
|
+
// a connection a closed tab can take with it.
|
|
765
|
+
runSetup({ home, agent, answers, run: setupRun, log })
|
|
766
|
+
.then((r) => {
|
|
767
|
+
// The carried secret is consumed; a finished setup deletes it.
|
|
768
|
+
if (r?.phase === 'done' && process.env.AP_FIRST_RUN) {
|
|
769
|
+
try { fs.unlinkSync(process.env.AP_FIRST_RUN); } catch { /* already gone */ }
|
|
770
|
+
}
|
|
771
|
+
})
|
|
772
|
+
.catch(e => { setupRun.phase = 'error'; setupRun.error = e.message; });
|
|
773
|
+
return json(res, 202, { ok: true, running: true, resuming });
|
|
774
|
+
}
|
|
775
|
+
// ---- the record, edited from the page at /admin/ ----
|
|
776
|
+
case '/config': {
|
|
777
|
+
const fixed = PERMANENT_CONFIG.filter(k => k in body);
|
|
778
|
+
if (fixed.length) {
|
|
779
|
+
return json(res, 400, {
|
|
780
|
+
error: `${fixed.join(', ')} cannot be changed — that is the identity itself, not a setting`,
|
|
781
|
+
});
|
|
782
|
+
}
|
|
783
|
+
for (const k of ['approveJoins', 'review']) {
|
|
784
|
+
if (k in body && !isGroup()) return json(res, 404, { error: 'not a group' });
|
|
785
|
+
}
|
|
786
|
+
// A user acting here outranks an idle active agent on another device:
|
|
787
|
+
// claim the lease rather than write state that one will clobber.
|
|
788
|
+
await agent.requestTakeover?.();
|
|
789
|
+
// Merge, never replace: the UI password and anything else set later
|
|
790
|
+
// must survive an edit that never mentions it.
|
|
791
|
+
const cfg = { ...agent.store.getConfig() };
|
|
792
|
+
if ('name' in body) {
|
|
793
|
+
if (!body.name) return json(res, 400, { error: 'a display name is required' });
|
|
794
|
+
cfg.name = String(body.name);
|
|
795
|
+
}
|
|
796
|
+
if ('summary' in body) cfg.summary = body.summary || undefined;
|
|
797
|
+
if ('icon' in body) cfg.icon = body.icon || undefined;
|
|
798
|
+
// The banner and the labelled rows, so a client and this page edit one
|
|
799
|
+
// record rather than each other's leftovers.
|
|
800
|
+
if ('image' in body) cfg.image = body.image || undefined;
|
|
801
|
+
if ('fields' in body) {
|
|
802
|
+
cfg.fields = (Array.isArray(body.fields) ? body.fields : [])
|
|
803
|
+
.filter(f => f?.name?.trim())
|
|
804
|
+
.map(f => ({ name: String(f.name).trim(), value: String(f.value ?? '').trim() }));
|
|
805
|
+
}
|
|
806
|
+
if ('approveJoins' in body) cfg.approveJoins = !!body.approveJoins;
|
|
807
|
+
if ('review' in body) cfg.review = !!body.review;
|
|
808
|
+
// A person's queue gate: on, and an inbound Follow is accepted on
|
|
809
|
+
// arrival instead of waiting in the requests queue. Local behavior,
|
|
810
|
+
// not on the wire — but intake reads the LIVE config, so it is
|
|
811
|
+
// patched below whether or not anything republishes.
|
|
812
|
+
if ('autoAcceptFollows' in body) cfg.autoAcceptFollows = !!body.autoAcceptFollows;
|
|
813
|
+
// The moderator roster (FEP-1b12): actor IRIs whose federated
|
|
814
|
+
// moderation asks are queued, published as attributedTo.
|
|
815
|
+
let rosterDiff = null;
|
|
816
|
+
if ('moderators' in body) {
|
|
817
|
+
if (!isGroup()) return json(res, 404, { error: 'not a group' });
|
|
818
|
+
const next = [...new Set((Array.isArray(body.moderators) ? body.moderators : [])
|
|
819
|
+
.map(String).filter(m => /^https?:\/\/\S+$/.test(m)))];
|
|
820
|
+
const prev = cfg.moderators || [];
|
|
821
|
+
rosterDiff = {
|
|
822
|
+
added: next.filter(m => !prev.includes(m)),
|
|
823
|
+
removed: prev.filter(m => !next.includes(m)),
|
|
824
|
+
};
|
|
825
|
+
cfg.moderators = next;
|
|
826
|
+
}
|
|
827
|
+
if ('password' in body) {
|
|
828
|
+
// '' clears it. The UI password is what turns the instant OAuth
|
|
829
|
+
// redirect into a login form; switching that off is a real choice.
|
|
830
|
+
if (body.password) cfg.uiPassword = hashPassword(body.password);
|
|
831
|
+
else delete cfg.uiPassword;
|
|
832
|
+
}
|
|
833
|
+
const republish = WIRE_CONFIG.some(k => k in body);
|
|
834
|
+
agent.store.setConfig(cfg);
|
|
835
|
+
if ('autoAcceptFollows' in body && agent.publisher) {
|
|
836
|
+
agent.publisher.config.autoAcceptFollows = cfg.autoAcceptFollows;
|
|
837
|
+
}
|
|
838
|
+
if (republish && agent.publisher) {
|
|
839
|
+
Object.assign(agent.publisher.config, {
|
|
840
|
+
name: cfg.name, summary: cfg.summary, icon: cfg.icon,
|
|
841
|
+
image: cfg.image, fields: cfg.fields,
|
|
842
|
+
approveJoins: !!cfg.approveJoins,
|
|
843
|
+
moderators: cfg.moderators,
|
|
844
|
+
aliases: cfg.aliases,
|
|
845
|
+
});
|
|
846
|
+
}
|
|
847
|
+
await agent.store.flush();
|
|
848
|
+
// Publishing is only half of it: publishProfile re-fetches the public
|
|
849
|
+
// documents unauthenticated and reports the ones a stranger's server
|
|
850
|
+
// could not read. Carrying that back is what lets this be the only
|
|
851
|
+
// republish control there is.
|
|
852
|
+
const pub = republish ? await agent.publisher.publishProfile() : null;
|
|
853
|
+
// Roster changes are announced to the membership (FEP-1b12), so
|
|
854
|
+
// their servers can mirror who moderates here.
|
|
855
|
+
if (rosterDiff && agent.publisher) {
|
|
856
|
+
const urls = agent.publisher.urls;
|
|
857
|
+
for (const [type, list] of [['Add', rosterDiff.added], ['Remove', rosterDiff.removed]]) {
|
|
858
|
+
for (const m of list) {
|
|
859
|
+
await announceModeration(agent, addRemoveActivity({
|
|
860
|
+
urls, type, object: m, target: urls.moderators, serial: Date.now(),
|
|
861
|
+
})).catch(e => log(`moderation announce failed: ${e.message}`));
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
return json(res, 200, {
|
|
866
|
+
ok: true, published: republish,
|
|
867
|
+
...(pub?.unreachable?.length ? { unreachable: pub.unreachable } : {}),
|
|
868
|
+
config: {
|
|
869
|
+
name: cfg.name || null, summary: cfg.summary || null, icon: cfg.icon || null,
|
|
870
|
+
image: cfg.image || null, fields: cfg.fields || [],
|
|
871
|
+
approveJoins: !!cfg.approveJoins, review: !!cfg.review,
|
|
872
|
+
autoAcceptFollows: !!cfg.autoAcceptFollows,
|
|
873
|
+
...(isGroup() ? { moderators: cfg.moderators || [] } : {}),
|
|
874
|
+
hasUiPassword: !!cfg.uiPassword,
|
|
875
|
+
},
|
|
876
|
+
});
|
|
877
|
+
}
|
|
878
|
+
// Both of these DELETE from the pod's inbox, which is the one thing the
|
|
879
|
+
// lease exists to keep to a single agent. A viewer must claim it first
|
|
880
|
+
// and give up if it cannot — the facade has refused viewer writes since
|
|
881
|
+
// multi-device landed, and these two were simply missed.
|
|
882
|
+
case '/drain': {
|
|
883
|
+
if (!await agent.requestTakeover?.()) {
|
|
884
|
+
return json(res, 503, { error: 'another agent is active for this pod — it is doing the draining' });
|
|
885
|
+
}
|
|
886
|
+
await agent.intake.drain();
|
|
887
|
+
return json(res, 200, agent.status());
|
|
888
|
+
}
|
|
889
|
+
// The owner's answer to "your inbox is very full". Never automatic:
|
|
890
|
+
// discarding someone's mail is their call, not the agent's.
|
|
891
|
+
case '/inbox/prune': {
|
|
892
|
+
if (!body.before) return json(res, 400, { error: 'before (a date) required' });
|
|
893
|
+
if (!await agent.requestTakeover?.()) {
|
|
894
|
+
return json(res, 503, { error: 'another agent is active for this pod — discard from that one' });
|
|
895
|
+
}
|
|
896
|
+
return json(res, 200, await agent.intake.prune({
|
|
897
|
+
before: body.before,
|
|
898
|
+
...(body.keepConcerning ? { keepConcerning: true } : {}),
|
|
899
|
+
}));
|
|
900
|
+
}
|
|
901
|
+
// Put back the posts a lost or restored machine no longer knows about,
|
|
902
|
+
// from what the pod still serves. It writes the statuses store, which is
|
|
903
|
+
// the lease's business, so it refuses the same way the drain does.
|
|
904
|
+
case '/rebuild': {
|
|
905
|
+
if (!await agent.requestTakeover?.()) {
|
|
906
|
+
return json(res, 503, { error: 'another agent is active for this pod — rebuild from that one' });
|
|
907
|
+
}
|
|
908
|
+
return json(res, 200, await agent.publisher.rebuildStatuses({ fromNotes: !!body.fromNotes }));
|
|
909
|
+
}
|
|
910
|
+
// One identity per home, so a new actor means a new home, a free port
|
|
911
|
+
// and a process there — then its own setup, run with the answers this
|
|
912
|
+
// page collected. The reply is the address of the new actor's page,
|
|
913
|
+
// which is where the progress of that setup is reported.
|
|
914
|
+
case '/new-actor': {
|
|
915
|
+
if (isCrossSiteNavigation(req)) return json(res, 403, { error: 'cross-site request' });
|
|
916
|
+
const handle = String(body.handle || '').trim().toLowerCase();
|
|
917
|
+
// The name first, and before anything is created: it is what becomes
|
|
918
|
+
// a directory, and "mode must be new or existing" is a poor answer to
|
|
919
|
+
// a handle that could climb out of profiles/.
|
|
920
|
+
// Named after the handle rather than generated, so `profiles` and
|
|
921
|
+
// `--profile <name>` stay legible a year from now.
|
|
922
|
+
if (!/^[a-z0-9][a-z0-9_-]{0,30}$/.test(handle)) {
|
|
923
|
+
return json(res, 400, { error: 'a handle is letters, digits, hyphens and underscores' });
|
|
924
|
+
}
|
|
925
|
+
const dir = path.join(rootOf(path.resolve(agent.home || '')), 'profiles', handle);
|
|
926
|
+
if (fs.existsSync(path.join(dir, 'credential.json'))) {
|
|
927
|
+
return json(res, 409, { error: `${handle} already exists — it is in the list above` });
|
|
928
|
+
}
|
|
929
|
+
// Then everything else setup needs, so a missing password is refused
|
|
930
|
+
// here rather than after a process has been started for it.
|
|
931
|
+
const bad = setupInputError({ ...body, handle });
|
|
932
|
+
if (bad) return json(res, 400, { error: bad });
|
|
933
|
+
const newPort = await freePortFrom(port + 1);
|
|
934
|
+
if (!newPort) return json(res, 503, { error: 'no free port in the next 50' });
|
|
935
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
936
|
+
const child = spawn(process.execPath, [path.join(projectRoot, 'run-agent.mjs')], {
|
|
937
|
+
detached: true, stdio: 'ignore',
|
|
938
|
+
env: { ...process.env, AP_HOME: dir, AP_PORT: String(newPort), AP_PROFILE: '' },
|
|
939
|
+
});
|
|
940
|
+
child.unref();
|
|
941
|
+
// Answer only once it is listening: handing back a URL that is not
|
|
942
|
+
// up yet shows a connection error instead of the setup form.
|
|
943
|
+
let listening = false;
|
|
944
|
+
for (let i = 0; i < 60 && !listening; i++) {
|
|
945
|
+
await new Promise(r => setTimeout(r, 250));
|
|
946
|
+
listening = await localFetch(agent.home, newPort, '/status', { timeout: 1000 })
|
|
947
|
+
.then(r => r.ok).catch(() => false);
|
|
948
|
+
}
|
|
949
|
+
if (!listening) return json(res, 504, { error: `started it on ${newPort} but it never answered` });
|
|
950
|
+
|
|
951
|
+
// Its own /setup owns the work — minting the credential, provisioning
|
|
952
|
+
// the pod, publishing. It answers 202 and runs in the background, so
|
|
953
|
+
// the page that opens next is the one reporting progress.
|
|
954
|
+
const started = await localFetch(agent.home, newPort, '/setup', {
|
|
955
|
+
method: 'POST',
|
|
956
|
+
headers: { 'content-type': 'application/json' },
|
|
957
|
+
body: JSON.stringify({ ...body, handle }),
|
|
958
|
+
}).then(async r => ({ status: r.status, json: await r.json().catch(() => null) }))
|
|
959
|
+
.catch(e => ({ status: 0, json: { error: e.message } }));
|
|
960
|
+
if (started.status !== 202) {
|
|
961
|
+
return json(res, started.status || 502, {
|
|
962
|
+
error: started.json?.error || `the new agent refused setup (HTTP ${started.status})`,
|
|
963
|
+
port: newPort, url: `${secureOrigin(handle, newPort)}${SETUP_PAGE}`,
|
|
964
|
+
});
|
|
965
|
+
}
|
|
966
|
+
return json(res, 200,
|
|
967
|
+
{ ok: true, handle, port: newPort,
|
|
968
|
+
url: `${secureOrigin(handle, newPort)}${SETUP_PAGE}` });
|
|
969
|
+
}
|
|
970
|
+
// Start an actor that is not running, so its page can be visited. The
|
|
971
|
+
// same spawn as /new-actor, minus the making: this one has a home
|
|
972
|
+
// already. Its recorded port is preferred so its address stays what it
|
|
973
|
+
// was, but a port something else has taken is walked past, not fought.
|
|
974
|
+
case '/start-actor': {
|
|
975
|
+
if (isCrossSiteNavigation(req)) return json(res, 403, { error: 'cross-site request' });
|
|
976
|
+
const name = String(body.name || '');
|
|
977
|
+
const found = identityHomes(rootOf(path.resolve(agent.home || ''))).find(h => h.name === name);
|
|
978
|
+
if (!found) return json(res, 404, { error: `no identity called ${name}` });
|
|
979
|
+
|
|
980
|
+
let want = null;
|
|
981
|
+
try { want = JSON.parse(fs.readFileSync(path.join(found.dir, 'agent.json'), 'utf8')).port || null; } catch {}
|
|
982
|
+
if (want) {
|
|
983
|
+
// Its own handle, not the profile directory's name: usually the
|
|
984
|
+
// same, but a renamed profile makes them differ and the origin has
|
|
985
|
+
// to be one that agent's own guard will accept.
|
|
986
|
+
const live = await localFetch(agent.home, want, '/status', { timeout: 1000 })
|
|
987
|
+
.then(r => (r.ok ? r.json() : null)).catch(() => null);
|
|
988
|
+
if (live) {
|
|
989
|
+
return json(res, 200, {
|
|
990
|
+
ok: true, name, port: want, already: true,
|
|
991
|
+
url: `${secureOrigin(live.handle || name, want)}/`,
|
|
992
|
+
});
|
|
993
|
+
}
|
|
994
|
+
}
|
|
995
|
+
// The port is not the only evidence it is running. An agent from
|
|
996
|
+
// before agent.json was written at startup has a live pidfile and no
|
|
997
|
+
// recorded port, and spawning past that gives one home two agents —
|
|
998
|
+
// which is exactly how this machine ended up with two on profiles/jeff.
|
|
999
|
+
let held = null;
|
|
1000
|
+
try { held = Number(fs.readFileSync(path.join(found.dir, 'agent.pid'), 'utf8').trim()) || null; } catch {}
|
|
1001
|
+
if (held) {
|
|
1002
|
+
let alive = false;
|
|
1003
|
+
try { process.kill(held, 0); alive = true; } catch { /* gone */ }
|
|
1004
|
+
if (alive) {
|
|
1005
|
+
return json(res, 409, {
|
|
1006
|
+
error: `${name} is already running as pid ${held}, on a port it never recorded. `
|
|
1007
|
+
+ `Stop it first: kill ${held}`,
|
|
1008
|
+
});
|
|
1009
|
+
}
|
|
1010
|
+
}
|
|
1011
|
+
// A recorded port held by the directory door is still this
|
|
1012
|
+
// identity's port — the door steps aside for its owner.
|
|
1013
|
+
if (want && !await portFree(want)) await yieldDirectory(want, { portFree });
|
|
1014
|
+
const on = (want && await portFree(want)) ? want : await freePortFrom(port + 1);
|
|
1015
|
+
if (!on) return json(res, 503, { error: 'no free port in the next 50' });
|
|
1016
|
+
|
|
1017
|
+
const started = spawn(process.execPath, [path.join(projectRoot, 'run-agent.mjs')], {
|
|
1018
|
+
detached: true, stdio: 'ignore',
|
|
1019
|
+
env: { ...process.env, AP_HOME: found.dir, AP_PORT: String(on), AP_PROFILE: '' },
|
|
1020
|
+
});
|
|
1021
|
+
started.unref();
|
|
1022
|
+
for (let i = 0; i < 60; i++) {
|
|
1023
|
+
await new Promise(r => setTimeout(r, 250));
|
|
1024
|
+
const up = await localFetch(agent.home, on, '/status', { timeout: 1000 })
|
|
1025
|
+
.then(r => (r.ok ? r.json() : null)).catch(() => null);
|
|
1026
|
+
if (up) {
|
|
1027
|
+
return json(res, 200, { ok: true, name, port: on,
|
|
1028
|
+
url: `${secureOrigin(up.handle || name, on)}/` });
|
|
1029
|
+
}
|
|
1030
|
+
}
|
|
1031
|
+
return json(res, 504, { error: `started ${name} on ${on} but it never answered` });
|
|
1032
|
+
}
|
|
1033
|
+
// Pull the latest published FediPod and restart the agents. What the
|
|
1034
|
+
// installer does on a re-run, offered where the version line is.
|
|
1035
|
+
case '/update': {
|
|
1036
|
+
if (isCrossSiteNavigation(req)) return json(res, 403, { error: 'cross-site request' });
|
|
1037
|
+
const { runUpdate, restartAgents, repoRoot } = await import('./update.mjs');
|
|
1038
|
+
const r = runUpdate({ log });
|
|
1039
|
+
if (!r.ok) return json(res, 409, { error: r.note });
|
|
1040
|
+
json(res, 200, { ok: true, note: r.note, restarting: true });
|
|
1041
|
+
// The response first, the restart a beat later — a self-restart must
|
|
1042
|
+
// not take the answer down with it.
|
|
1043
|
+
setTimeout(() => {
|
|
1044
|
+
if (restartAgents({ log }) === 'self') {
|
|
1045
|
+
const child = spawn(process.execPath, [path.join(repoRoot(), 'run-agent.mjs')],
|
|
1046
|
+
{ detached: true, stdio: 'ignore', env: process.env });
|
|
1047
|
+
child.unref();
|
|
1048
|
+
setTimeout(() => process.exit(0), 300);
|
|
1049
|
+
}
|
|
1050
|
+
}, 200);
|
|
1051
|
+
return;
|
|
1052
|
+
}
|
|
1053
|
+
case '/publish-profile': {
|
|
1054
|
+
// The explicit "republish now" control. Asking for it IS the reason.
|
|
1055
|
+
const r = await agent.publisher.publishProfile({ force: true });
|
|
1056
|
+
return json(res, 200, { ok: true, ...(r?.unreachable?.length ? { unreachable: r.unreachable } : {}) });
|
|
1057
|
+
}
|
|
1058
|
+
// ---- lifecycle: what the CLI calls park | revive | rotate-key | retire ----
|
|
1059
|
+
// Each claims the lease first, for the same reason /config does: someone
|
|
1060
|
+
// acting on this page outranks an idle active agent on another device.
|
|
1061
|
+
// Asking first is the page's job — see the warnings in its markup.
|
|
1062
|
+
case '/park': {
|
|
1063
|
+
await agent.requestTakeover?.();
|
|
1064
|
+
return json(res, 200, { ok: true, ...await agent.park() });
|
|
1065
|
+
}
|
|
1066
|
+
case '/revive': {
|
|
1067
|
+
await agent.requestTakeover?.();
|
|
1068
|
+
return json(res, 200, { ok: true, ...await agent.revive() });
|
|
1069
|
+
}
|
|
1070
|
+
case '/rotate-key': {
|
|
1071
|
+
await agent.requestTakeover?.();
|
|
1072
|
+
const r = await agent.rotateKey();
|
|
1073
|
+
return json(res, 200, { ok: true, changed: !!r?.changed });
|
|
1074
|
+
}
|
|
1075
|
+
// Hand the identity on. Federated and effectively one-way: the Move
|
|
1076
|
+
// tells every follower's server to migrate them, and the actor is left
|
|
1077
|
+
// advertising movedTo so the old handle redirects. Same typed-handle
|
|
1078
|
+
// interlock as retire — a stray click cannot produce it.
|
|
1079
|
+
case '/move': {
|
|
1080
|
+
if (!body.target) return json(res, 400, { error: 'target required' });
|
|
1081
|
+
if (!body.confirm || body.confirm !== agent.store.getConfig()?.handle) {
|
|
1082
|
+
return json(res, 400, { error: 'type the handle to confirm' });
|
|
1083
|
+
}
|
|
1084
|
+
await agent.requestTakeover?.();
|
|
1085
|
+
// A handle is not an actor URI, and the Move's target has to be one or
|
|
1086
|
+
// the far side has nothing to migrate anyone to. The CLI resolves it
|
|
1087
|
+
// the same way; doing it here rather than in the page keeps the one
|
|
1088
|
+
// WebFinger lookup on the side that already knows how.
|
|
1089
|
+
let target = String(body.target).trim();
|
|
1090
|
+
if (!/^https?:\/\//.test(target)) {
|
|
1091
|
+
const doc = await resolveHandle(agent, target);
|
|
1092
|
+
if (!doc?.id) return json(res, 400, { error: `could not resolve ${target}` });
|
|
1093
|
+
target = doc.id;
|
|
1094
|
+
}
|
|
1095
|
+
return json(res, 200, { ok: true, ...await agent.moveTo(target) });
|
|
1096
|
+
}
|
|
1097
|
+
// The inbound half of a migration: list an old account elsewhere in
|
|
1098
|
+
// this actor's alsoKnownAs, which is what the old server checks for
|
|
1099
|
+
// before it will send a Move here. Whatever is entered is resolved to
|
|
1100
|
+
// the account's canonical id — the check on the far side is an exact
|
|
1101
|
+
// string match, so a pasted profile URL stored as typed would fail it.
|
|
1102
|
+
case '/alias': {
|
|
1103
|
+
const urls = agent.publisher?.urls;
|
|
1104
|
+
if (!urls) return json(res, 409, { error: 'agent not connected yet' });
|
|
1105
|
+
if (!body.add && !body.remove) return json(res, 400, { error: 'add or remove required' });
|
|
1106
|
+
await agent.requestTakeover?.();
|
|
1107
|
+
const cfg = { ...agent.store.getConfig() };
|
|
1108
|
+
const aliases = [...(cfg.aliases || [])];
|
|
1109
|
+
if (body.add) {
|
|
1110
|
+
// A Move target nobody can resolve is a landing pad nobody lands on.
|
|
1111
|
+
if (!webfingerHost(urls.base) && !cfg.gateway?.frontActor) {
|
|
1112
|
+
return json(res, 400, {
|
|
1113
|
+
error: 'this pod is a path on a shared host, so WebFinger cannot answer for it '
|
|
1114
|
+
+ '— other servers could never resolve this account as a Move target',
|
|
1115
|
+
});
|
|
1116
|
+
}
|
|
1117
|
+
const input = String(body.add).trim().replace(/^@/, '');
|
|
1118
|
+
let doc = /^https?:\/\//.test(input)
|
|
1119
|
+
? await agent.intake.fetchAP(input).catch(() => null)
|
|
1120
|
+
: await resolveHandle(agent, input).catch(() => null);
|
|
1121
|
+
// A URL may answer with any id it likes; before that id is stored
|
|
1122
|
+
// it must vouch for itself — the intake's verify-by-deref.
|
|
1123
|
+
if (doc?.id && /^https?:\/\//.test(input) && doc.id !== input) {
|
|
1124
|
+
const own = await agent.intake.fetchAP(doc.id).catch(() => null);
|
|
1125
|
+
doc = own?.id === doc.id ? own : null;
|
|
1126
|
+
}
|
|
1127
|
+
if (!doc?.id) {
|
|
1128
|
+
return json(res, 400, { error: `could not fetch the old account (${input}) — it must exist and answer` });
|
|
1129
|
+
}
|
|
1130
|
+
if (doc.id === urls.actor) return json(res, 400, { error: 'that is this account' });
|
|
1131
|
+
if (!aliases.includes(doc.id)) aliases.push(doc.id);
|
|
1132
|
+
} else {
|
|
1133
|
+
const target = String(body.remove).trim();
|
|
1134
|
+
if (!aliases.includes(target)) return json(res, 404, { error: 'no such alias' });
|
|
1135
|
+
// Follower servers process a Move on their own retry schedules,
|
|
1136
|
+
// over days — an alias removed early strands the late ones.
|
|
1137
|
+
if (!body.confirm) {
|
|
1138
|
+
return json(res, 409, {
|
|
1139
|
+
error: 'servers still retrying the Move check this alias and would strand '
|
|
1140
|
+
+ 'their followers — send confirm: true to remove it anyway',
|
|
1141
|
+
});
|
|
1142
|
+
}
|
|
1143
|
+
aliases.splice(aliases.indexOf(target), 1);
|
|
1144
|
+
}
|
|
1145
|
+
cfg.aliases = aliases;
|
|
1146
|
+
agent.store.setConfig(cfg);
|
|
1147
|
+
agent.publisher.config.aliases = aliases;
|
|
1148
|
+
await agent.store.flush();
|
|
1149
|
+
const pub = await agent.publisher.publishProfile();
|
|
1150
|
+
return json(res, 200, {
|
|
1151
|
+
ok: true, aliases,
|
|
1152
|
+
...(pub?.unreachable?.length ? { unreachable: pub.unreachable } : {}),
|
|
1153
|
+
});
|
|
1154
|
+
}
|
|
1155
|
+
// Stage a CSV export from the old account. Rows are applied by the
|
|
1156
|
+
// agent's paced worker, not in this request — big lists take a while,
|
|
1157
|
+
// and GET /import is the window onto how far it has gotten.
|
|
1158
|
+
case '/import': {
|
|
1159
|
+
if (!agent.importer) return json(res, 409, { error: 'agent not connected yet' });
|
|
1160
|
+
// Unlike the one-shot routes, staging arms a worker that keeps
|
|
1161
|
+
// writing for minutes — a device that could not take the lease must
|
|
1162
|
+
// not run one alongside the device that holds it.
|
|
1163
|
+
const took = await agent.requestTakeover?.();
|
|
1164
|
+
if (took === false) {
|
|
1165
|
+
return json(res, 503, { error: 'another device is active for this pod — import from there' });
|
|
1166
|
+
}
|
|
1167
|
+
if (body.clear === true) {
|
|
1168
|
+
agent.importer.clear();
|
|
1169
|
+
return json(res, 200, { ok: true, cleared: true });
|
|
1170
|
+
}
|
|
1171
|
+
if (!IMPORT_KINDS.includes(body.kind)) {
|
|
1172
|
+
return json(res, 400, { error: `kind must be one of: ${IMPORT_KINDS.join(', ')}` });
|
|
1173
|
+
}
|
|
1174
|
+
if (typeof body.text !== 'string' || !body.text.trim()) {
|
|
1175
|
+
return json(res, 400, { error: 'text required — the CSV file contents' });
|
|
1176
|
+
}
|
|
1177
|
+
const { values, invalid } = normalizeImport(body.kind, body.text);
|
|
1178
|
+
const r = agent.importer.stage(body.kind, values);
|
|
1179
|
+
await agent.store.flush();
|
|
1180
|
+
return json(res, 200, {
|
|
1181
|
+
ok: true, kind: body.kind, ...r,
|
|
1182
|
+
invalid: invalid.length,
|
|
1183
|
+
...(invalid.length ? { invalidSample: invalid.slice(0, 5) } : {}),
|
|
1184
|
+
});
|
|
1185
|
+
}
|
|
1186
|
+
// Move the private half — every state document and the RDF tree — while
|
|
1187
|
+
// the agent runs: quiesce this process's writers, flush, copy, verify,
|
|
1188
|
+
// repoint the credential, then reconnect on the new location. The old
|
|
1189
|
+
// copy is left where it was, exactly as the CLI move leaves it.
|
|
1190
|
+
case '/state-move': {
|
|
1191
|
+
const cred = agent.readCredential?.();
|
|
1192
|
+
if (!cred) return json(res, 409, { error: 'no credential — run setup first' });
|
|
1193
|
+
const to = String(body.to || '').trim();
|
|
1194
|
+
if (!to) return json(res, 400, { error: 'say where it should go' });
|
|
1195
|
+
let target = null;
|
|
1196
|
+
if (to !== 'pod') {
|
|
1197
|
+
// A path or a URL — two chars before the colon, so a Windows drive
|
|
1198
|
+
// letter reads as a path rather than a scheme.
|
|
1199
|
+
const asPath = !/^[a-z][a-z0-9+.-]+:/i.test(to);
|
|
1200
|
+
const raw = asPath
|
|
1201
|
+
? pathToFileURL(path.resolve(to.replace(/^~(?=[/\\]|$)/, os.homedir()))).href
|
|
1202
|
+
: to;
|
|
1203
|
+
target = raw.endsWith('/') ? raw : raw + '/';
|
|
1204
|
+
try { new URL(target); } catch { return json(res, 400, { error: `"${to}" is not a container URL or a path` }); }
|
|
1205
|
+
if (/^https?:/i.test(target)) {
|
|
1206
|
+
const bad = insecureUrlReason(target, 'private-data address');
|
|
1207
|
+
if (bad) return json(res, 400, { error: bad });
|
|
1208
|
+
}
|
|
1209
|
+
}
|
|
1210
|
+
const whereState = (c) => (c.privateRoot ? tildify(c.privateRoot) : 'on the pod');
|
|
1211
|
+
if ((cred.privateRoot || null) === target) {
|
|
1212
|
+
return json(res, 200, { ok: true, docs: 0, notes: 0, unchanged: true, now: whereState(cred) });
|
|
1213
|
+
}
|
|
1214
|
+
await agent.requestTakeover?.();
|
|
1215
|
+
agent.intake?.stop(); agent.deliverer?.stop(); agent.tagfeed?.stop(); agent.importer?.stop();
|
|
1216
|
+
try {
|
|
1217
|
+
await agent.store.flush();
|
|
1218
|
+
const destCred = { ...cred, privateRoot: target };
|
|
1219
|
+
const copied = await copyPrivateHalf({
|
|
1220
|
+
from: { state: agent.privateStorage(cred, 'state'), fediverse: agent.privateStorage(cred, 'fediverse') },
|
|
1221
|
+
to: { state: agent.privateStorage(destCred, 'state'), fediverse: agent.privateStorage(destCred, 'fediverse') },
|
|
1222
|
+
log,
|
|
1223
|
+
});
|
|
1224
|
+
if (target) cred.privateRoot = target; else delete cred.privateRoot;
|
|
1225
|
+
if (isCurrent(cred)) cred.layout = CURRENT_LAYOUT; else delete cred.layout;
|
|
1226
|
+
writeJsonAtomic(path.join(agent.home, 'credential.json'), cred);
|
|
1227
|
+
// connect() rebuilds everything that pointed at the old location —
|
|
1228
|
+
// store, RDF tree, publisher, intake — and restarts them.
|
|
1229
|
+
agent.store.attach(agent.privateStorage(cred, 'state'));
|
|
1230
|
+
agent.stateLoaded = false;
|
|
1231
|
+
await agent.connect();
|
|
1232
|
+
return json(res, 200, { ok: true, ...copied, now: whereState(cred) });
|
|
1233
|
+
} catch (e) {
|
|
1234
|
+
// The credential is only repointed after a verified copy, so on any
|
|
1235
|
+
// failure reconnect puts the agent back to work where it was.
|
|
1236
|
+
agent.stateLoaded = false;
|
|
1237
|
+
await agent.connect().catch(() => {});
|
|
1238
|
+
return json(res, 502, { error: e.message });
|
|
1239
|
+
}
|
|
1240
|
+
}
|
|
1241
|
+
case '/retire': {
|
|
1242
|
+
// Irreversible and federated: a Delete leaves for every follower and
|
|
1243
|
+
// the actor becomes a Tombstone. The typed handle is the interlock —
|
|
1244
|
+
// a stray click cannot produce it, and nor can anything that never
|
|
1245
|
+
// read the record.
|
|
1246
|
+
if (!body.confirm || body.confirm !== agent.store.getConfig()?.handle) {
|
|
1247
|
+
return json(res, 400, { error: 'type the handle to confirm' });
|
|
1248
|
+
}
|
|
1249
|
+
await agent.requestTakeover?.();
|
|
1250
|
+
return json(res, 200, { ok: true, ...await agent.publisher.retireActor() });
|
|
1251
|
+
}
|
|
1252
|
+
case '/post': {
|
|
1253
|
+
if (!body.content) return json(res, 400, { error: 'content required' });
|
|
1254
|
+
const note = await agent.publisher.publishNote(body.content, { inReplyTo: body.inReplyTo });
|
|
1255
|
+
return json(res, 200, { ok: true, id: note.id });
|
|
1256
|
+
}
|
|
1257
|
+
case '/tagfeed': return json(res, 200, agent.tagfeed.setConfig(body));
|
|
1258
|
+
case '/atproto/connect': {
|
|
1259
|
+
// The one endpoint that ever sees the app password; loopback-only.
|
|
1260
|
+
if (!body.identifier || !body.appPassword) {
|
|
1261
|
+
return json(res, 400, { error: 'identifier and appPassword required' });
|
|
1262
|
+
}
|
|
1263
|
+
const conn = await agent.atproto.connect({
|
|
1264
|
+
service: body.service, identifier: body.identifier, appPassword: body.appPassword,
|
|
1265
|
+
});
|
|
1266
|
+
const cfg = agent.store.getConfig();
|
|
1267
|
+
agent.store.setConfig({
|
|
1268
|
+
...cfg,
|
|
1269
|
+
atproto: { ...conn, crossPost: cfg.atproto?.crossPost ?? true },
|
|
1270
|
+
});
|
|
1271
|
+
await agent.store.flush();
|
|
1272
|
+
agent.startBsky?.();
|
|
1273
|
+
return json(res, 200, { ok: true, ...agent.atproto.status() });
|
|
1274
|
+
}
|
|
1275
|
+
case '/atproto/disconnect': {
|
|
1276
|
+
agent.stopBsky?.();
|
|
1277
|
+
await agent.atproto.disconnect();
|
|
1278
|
+
const { atproto, ...rest } = agent.store.getConfig();
|
|
1279
|
+
agent.store.setConfig(rest);
|
|
1280
|
+
await agent.store.flush();
|
|
1281
|
+
return json(res, 200, { ok: true });
|
|
1282
|
+
}
|
|
1283
|
+
case '/atproto': {
|
|
1284
|
+
// Non-secret settings only — today that is the cross-post toggle.
|
|
1285
|
+
const cfg = agent.store.getConfig();
|
|
1286
|
+
if (!cfg.atproto) return json(res, 400, { error: 'no bluesky account connected' });
|
|
1287
|
+
agent.store.setConfig({
|
|
1288
|
+
...cfg, atproto: { ...cfg.atproto, crossPost: !!body.crossPost },
|
|
1289
|
+
});
|
|
1290
|
+
await agent.store.flush();
|
|
1291
|
+
return json(res, 200, { ok: true, atproto: { ...cfg.atproto, crossPost: !!body.crossPost } });
|
|
1292
|
+
}
|
|
1293
|
+
case '/archive': {
|
|
1294
|
+
// Whether drained mail's original bytes are kept in the private
|
|
1295
|
+
// half's inbox-archive/. Absent means on.
|
|
1296
|
+
const cfg = agent.store.getConfig();
|
|
1297
|
+
agent.store.setConfig({ ...cfg, archiveInbox: !!body.on });
|
|
1298
|
+
await agent.store.flush();
|
|
1299
|
+
return json(res, 200, { ok: true, archiveInbox: !!body.on });
|
|
1300
|
+
}
|
|
1301
|
+
case '/follow': {
|
|
1302
|
+
// By handle normally; by actor URL when there is no handle to resolve —
|
|
1303
|
+
// a pod on a path, or anything WebFinger cannot answer for. Pasting an
|
|
1304
|
+
// actor URL is a normal way to follow in Mastodon too.
|
|
1305
|
+
if (body.actor) { await followActor(agent, body.actor); return json(res, 200, { ok: true, actor: body.actor }); }
|
|
1306
|
+
if (!body.handle) return json(res, 400, { error: 'handle or actor required' });
|
|
1307
|
+
return json(res, 200, await followHandle(agent, body.handle));
|
|
1308
|
+
}
|
|
1309
|
+
case '/unfollow': return json(res, 200, await unfollowActor(agent, body.actor));
|
|
1310
|
+
case '/mute':
|
|
1311
|
+
case '/unmute': {
|
|
1312
|
+
if (!isGroup()) return json(res, 404, { error: 'not a group' });
|
|
1313
|
+
if (!body.actor) return json(res, 400, { error: 'actor required' });
|
|
1314
|
+
const m = agent.store.getMuted();
|
|
1315
|
+
m.actors = p === '/mute'
|
|
1316
|
+
? [...new Set([...m.actors, body.actor])]
|
|
1317
|
+
: m.actors.filter(a => a !== body.actor);
|
|
1318
|
+
agent.store.setMuted(m);
|
|
1319
|
+
await agent.store.flush();
|
|
1320
|
+
return json(res, 200, { ok: true, actors: m.actors });
|
|
1321
|
+
}
|
|
1322
|
+
case '/eject': {
|
|
1323
|
+
if (!isGroup()) return json(res, 404, { error: 'not a group' });
|
|
1324
|
+
if (!body.actor) return json(res, 400, { error: 'actor required' });
|
|
1325
|
+
const r = await ejectFollower(agent, body.actor);
|
|
1326
|
+
await agent.store.flush();
|
|
1327
|
+
return json(res, 200, r);
|
|
1328
|
+
}
|
|
1329
|
+
case '/retract': {
|
|
1330
|
+
if (!isGroup()) return json(res, 404, { error: 'not a group' });
|
|
1331
|
+
if (!body.noteId) return json(res, 400, { error: 'noteId required' });
|
|
1332
|
+
const r = await retractAnnouncement(agent, body.noteId);
|
|
1333
|
+
await agent.store.flush();
|
|
1334
|
+
return json(res, 200, r);
|
|
1335
|
+
}
|
|
1336
|
+
case '/review': {
|
|
1337
|
+
if (!isGroup()) return json(res, 404, { error: 'not a group' });
|
|
1338
|
+
agent.store.setConfig({ ...agent.store.getConfig(), review: !!body.on });
|
|
1339
|
+
await agent.store.flush();
|
|
1340
|
+
return json(res, 200, { ok: true, review: !!body.on });
|
|
1341
|
+
}
|
|
1342
|
+
case '/describe': {
|
|
1343
|
+
const cfg = { ...agent.store.getConfig() };
|
|
1344
|
+
if ('summary' in body) cfg.summary = body.summary || undefined;
|
|
1345
|
+
if ('icon' in body) cfg.icon = body.icon || undefined;
|
|
1346
|
+
agent.store.setConfig(cfg);
|
|
1347
|
+
Object.assign(agent.publisher.config, { summary: cfg.summary, icon: cfg.icon });
|
|
1348
|
+
await agent.store.flush();
|
|
1349
|
+
await agent.publisher.publishProfile(); // the bio and avatar are on the wire
|
|
1350
|
+
return json(res, 200, { ok: true, summary: cfg.summary || null, icon: cfg.icon || null });
|
|
1351
|
+
}
|
|
1352
|
+
case '/joins': {
|
|
1353
|
+
if (!isGroup()) return json(res, 404, { error: 'not a group' });
|
|
1354
|
+
const cfg = { ...agent.store.getConfig(), approveJoins: !!body.approve };
|
|
1355
|
+
agent.store.setConfig(cfg);
|
|
1356
|
+
agent.publisher.config.approveJoins = !!body.approve;
|
|
1357
|
+
await agent.store.flush();
|
|
1358
|
+
// Unlike review, this one is visible to the fediverse: the actor
|
|
1359
|
+
// document carries manuallyApprovesFollowers, so nothing changes for
|
|
1360
|
+
// anyone until it is republished.
|
|
1361
|
+
await agent.publisher.publishProfile();
|
|
1362
|
+
return json(res, 200, { ok: true, approveJoins: !!body.approve });
|
|
1363
|
+
}
|
|
1364
|
+
case '/admit':
|
|
1365
|
+
case '/refuse': {
|
|
1366
|
+
// Kind-agnostic, like the queue they act on: admitting adds a
|
|
1367
|
+
// follower and Accepts, refusing Rejects. A person needs both.
|
|
1368
|
+
// `all` answers the whole queue — the shape a migration wave arrives
|
|
1369
|
+
// in — with one collections republish for the lot.
|
|
1370
|
+
if (p === '/admit' && body.all === true) {
|
|
1371
|
+
const waiting = agent.store.getRequests().map(r => r.actor);
|
|
1372
|
+
let admitted = 0;
|
|
1373
|
+
for (const actor of waiting) {
|
|
1374
|
+
try { await admitRequest(agent, actor, { publish: false }); admitted++; }
|
|
1375
|
+
catch (e) { log(`admit ${actor} failed: ${e.message}`); }
|
|
1376
|
+
}
|
|
1377
|
+
if (admitted) await agent.publisher.publishCollections({ followers: true, pending: true });
|
|
1378
|
+
await agent.store.flush();
|
|
1379
|
+
return json(res, 200, { ok: true, admitted, requests: agent.store.getRequests().length });
|
|
1380
|
+
}
|
|
1381
|
+
if (!body.actor) return json(res, 400, { error: 'actor required' });
|
|
1382
|
+
const r = p === '/admit' ? await admitRequest(agent, body.actor)
|
|
1383
|
+
: await refuseRequest(agent, body.actor);
|
|
1384
|
+
await agent.store.flush();
|
|
1385
|
+
return json(res, 200, { ...r, requests: agent.store.getRequests().length });
|
|
1386
|
+
}
|
|
1387
|
+
case '/approve':
|
|
1388
|
+
case '/decline': {
|
|
1389
|
+
if (!isGroup()) return json(res, 404, { error: 'not a group' });
|
|
1390
|
+
if (!body.noteId) return json(res, 400, { error: 'noteId required' });
|
|
1391
|
+
const held = agent.store.getPending().some(x => x.noteId === body.noteId);
|
|
1392
|
+
if (!held) return json(res, 404, { error: 'not held for review' });
|
|
1393
|
+
if (p === '/approve') await agent.intake.amplify(body.noteId, { approved: true });
|
|
1394
|
+
else agent.store.setPending(agent.store.getPending().filter(x => x.noteId !== body.noteId));
|
|
1395
|
+
await agent.store.flush();
|
|
1396
|
+
return json(res, 200, { ok: true, noteId: body.noteId, pending: agent.store.getPending().length });
|
|
1397
|
+
}
|
|
1398
|
+
// Answer one queued moderation ask: apply it (the operator vouching
|
|
1399
|
+
// for a delivery nothing else can vouch for) or dismiss it.
|
|
1400
|
+
case '/modqueue': {
|
|
1401
|
+
if (!isGroup()) return json(res, 404, { error: 'not a group' });
|
|
1402
|
+
const q = agent.store.read('modqueue.json', []);
|
|
1403
|
+
const entry = q.find(e => e.id === body.id);
|
|
1404
|
+
if (!entry) return json(res, 404, { error: 'no such queued action' });
|
|
1405
|
+
if (body.action !== 'apply' && body.action !== 'dismiss') {
|
|
1406
|
+
return json(res, 400, { error: 'action must be apply or dismiss' });
|
|
1407
|
+
}
|
|
1408
|
+
let applied = null;
|
|
1409
|
+
if (body.action === 'apply') applied = await applyModeration(agent, entry);
|
|
1410
|
+
agent.store.write('modqueue.json', q.filter(e => e.id !== entry.id));
|
|
1411
|
+
await agent.store.flush();
|
|
1412
|
+
return json(res, 200, {
|
|
1413
|
+
ok: true, action: body.action, type: entry.type, moderator: entry.moderator,
|
|
1414
|
+
...(applied && typeof applied === 'object' ? { result: applied } : {}),
|
|
1415
|
+
remaining: agent.store.read('modqueue.json', []).length,
|
|
1416
|
+
});
|
|
1417
|
+
}
|
|
1418
|
+
// The inbox gateway lifecycle (opt-in). `configure` points at a
|
|
1419
|
+
// deployed gateway (and reveals the HMAC secret once, to paste into
|
|
1420
|
+
// its env); `mode` walks off→shadow→trust→locked and back, each step
|
|
1421
|
+
// reversible; `forget` clears it and restores the pod inbox.
|
|
1422
|
+
case '/gateway': {
|
|
1423
|
+
const cfg = { ...agent.store.getConfig() };
|
|
1424
|
+
const g = { ...(cfg.gateway || {}) };
|
|
1425
|
+
const inboxUrl = agent.publisher?.urls?.inbox;
|
|
1426
|
+
await agent.requestTakeover?.();
|
|
1427
|
+
const persist = async () => {
|
|
1428
|
+
cfg.gateway = g; agent.store.setConfig(cfg);
|
|
1429
|
+
if (agent.publisher) agent.publisher.config.gateway = g;
|
|
1430
|
+
await agent.store.flush();
|
|
1431
|
+
};
|
|
1432
|
+
if (body.action === 'configure') {
|
|
1433
|
+
if (!/^https:\/\/\S+$/.test(String(body.url || ''))) return json(res, 400, { error: 'gateway url must be https' });
|
|
1434
|
+
if (!/^https?:\/\/\S+$/.test(String(body.webId || ''))) return json(res, 400, { error: 'gateway webId must be a URL' });
|
|
1435
|
+
g.url = String(body.url); g.webId = String(body.webId);
|
|
1436
|
+
g.hmacSecret = g.hmacSecret || crypto.randomBytes(32).toString('base64');
|
|
1437
|
+
g.mode = g.mode || 'off';
|
|
1438
|
+
// The fronted actor URL, for a multi-user front that gives this
|
|
1439
|
+
// identity a @name@front handle: the agent then publishes and signs
|
|
1440
|
+
// under it. Optional; setting it on a published identity renames
|
|
1441
|
+
// every id (a Move), so it belongs at setup or a fresh identity.
|
|
1442
|
+
if ('frontActor' in body) {
|
|
1443
|
+
const fa = String(body.frontActor || '');
|
|
1444
|
+
if (fa && !/^https:\/\/\S+\/ap\/actor$/.test(fa)) {
|
|
1445
|
+
return json(res, 400, { error: 'frontActor must be an https …/ap/actor URL' });
|
|
1446
|
+
}
|
|
1447
|
+
g.frontActor = fa || undefined;
|
|
1448
|
+
}
|
|
1449
|
+
await persist();
|
|
1450
|
+
// The one time the secret is returned: the operator pastes it into
|
|
1451
|
+
// the gateway's deploy env. /gateway GET never reveals it again.
|
|
1452
|
+
return json(res, 200, { ok: true, mode: g.mode, url: g.url, webId: g.webId, hmacSecret: g.hmacSecret });
|
|
1453
|
+
}
|
|
1454
|
+
if (body.action === 'mode') {
|
|
1455
|
+
const target = body.mode;
|
|
1456
|
+
if (!['off', 'shadow', 'trust', 'locked'].includes(target)) {
|
|
1457
|
+
return json(res, 400, { error: 'mode must be off, shadow, trust or locked' });
|
|
1458
|
+
}
|
|
1459
|
+
if (target !== 'off' && !g.url) return json(res, 400, { error: 'point at a gateway first (action: configure)' });
|
|
1460
|
+
// Locking needs the door's own WebID to name in the ACL. Without it
|
|
1461
|
+
// the mode used to persist and the ACL be written for an undefined
|
|
1462
|
+
// agent, leaving config and pod disagreeing about who may write.
|
|
1463
|
+
if (target === 'locked' && !g.webId) {
|
|
1464
|
+
return json(res, 400, {
|
|
1465
|
+
error: 'locked needs the gateway\'s WebID — set it with action: configure, webId: <door-webid>',
|
|
1466
|
+
});
|
|
1467
|
+
}
|
|
1468
|
+
const prev = g.mode || 'off';
|
|
1469
|
+
// The ACL first: a lock that fails must not leave the mode recorded
|
|
1470
|
+
// as locked while the pod still accepts anyone's writes.
|
|
1471
|
+
if (target === 'locked') await agent.publisher?.lockInboxToGateway(g.webId);
|
|
1472
|
+
else if (prev === 'locked' && inboxUrl) await agent.remote.setAcl(inboxUrl, ['Append']);
|
|
1473
|
+
g.mode = target;
|
|
1474
|
+
await persist();
|
|
1475
|
+
// Advertisement flips only when crossing the off boundary (the actor
|
|
1476
|
+
// doc's inbox changes → publishProfile republishes, digest-gated).
|
|
1477
|
+
if ((prev === 'off') !== (target === 'off')) await agent.publisher?.publishProfile();
|
|
1478
|
+
else if (target !== 'off') await agent.publisher?.publishGatewayPolicy().catch(() => {});
|
|
1479
|
+
return json(res, 200, { ok: true, mode: g.mode });
|
|
1480
|
+
}
|
|
1481
|
+
if (body.action === 'forget') {
|
|
1482
|
+
const wasLocked = g.mode === 'locked';
|
|
1483
|
+
delete cfg.gateway; agent.store.setConfig(cfg);
|
|
1484
|
+
if (agent.publisher) agent.publisher.config.gateway = undefined;
|
|
1485
|
+
await agent.store.flush();
|
|
1486
|
+
if (wasLocked && inboxUrl) await agent.remote.setAcl(inboxUrl, ['Append']).catch(() => {});
|
|
1487
|
+
await agent.publisher?.publishProfile(); // re-advertise the pod inbox
|
|
1488
|
+
return json(res, 200, { ok: true, mode: 'off', forgotten: true });
|
|
1489
|
+
}
|
|
1490
|
+
return json(res, 400, { error: 'action must be configure, mode or forget' });
|
|
1491
|
+
}
|
|
1492
|
+
// Symmetrical with /block, and open for the same reason: a block made
|
|
1493
|
+
// by mistake is worth undoing before federation is even configured.
|
|
1494
|
+
case '/unblock': {
|
|
1495
|
+
const b = agent.store.getBlocklist();
|
|
1496
|
+
const before = b.domains.length + b.actors.length;
|
|
1497
|
+
if (body.actor) b.actors = b.actors.filter(a => a !== body.actor);
|
|
1498
|
+
else if (body.domain) b.domains = b.domains.filter(d => d !== body.domain);
|
|
1499
|
+
else return json(res, 400, { error: 'domain or actor required' });
|
|
1500
|
+
agent.store.setBlocklist(b);
|
|
1501
|
+
// The published blocked collection (FEP-c648) follows, best-effort.
|
|
1502
|
+
Promise.resolve(agent.publisher?.publishCollections?.({ blocked: true })).catch(() => {});
|
|
1503
|
+
return json(res, 200, {
|
|
1504
|
+
ok: true, removed: before - (b.domains.length + b.actors.length),
|
|
1505
|
+
domains: b.domains, actors: b.actors,
|
|
1506
|
+
});
|
|
1507
|
+
}
|
|
1508
|
+
case '/block': {
|
|
1509
|
+
const b = agent.store.getBlocklist();
|
|
1510
|
+
if (body.actor) {
|
|
1511
|
+
if (!/^https?:\/\/\S+$/.test(body.actor)) return json(res, 400, { error: 'actor must be a URL' });
|
|
1512
|
+
if (!b.actors.includes(body.actor)) b.actors.push(body.actor);
|
|
1513
|
+
} else if (body.domain) {
|
|
1514
|
+
if (!b.domains.includes(body.domain)) b.domains.push(body.domain);
|
|
1515
|
+
} else {
|
|
1516
|
+
return json(res, 400, { error: 'domain or actor required' });
|
|
1517
|
+
}
|
|
1518
|
+
agent.store.setBlocklist(b);
|
|
1519
|
+
// The published blocked collection (FEP-c648) follows, best-effort.
|
|
1520
|
+
Promise.resolve(agent.publisher?.publishCollections?.({ blocked: true })).catch(() => {});
|
|
1521
|
+
return json(res, 200, { ok: true, domains: b.domains, actors: b.actors });
|
|
1522
|
+
}
|
|
1523
|
+
default: return json(res, 404, { error: 'unknown endpoint' });
|
|
1524
|
+
}
|
|
1525
|
+
} catch (e) {
|
|
1526
|
+
// The caller gets the real message: this server binds loopback only, sets
|
|
1527
|
+
// no CORS headers, and its one reader is the operator. Hiding the reason
|
|
1528
|
+
// from them buys nothing and costs a trip to the log. Stack stays here.
|
|
1529
|
+
log(`admin ${p}: ${e.stack || e.message}`);
|
|
1530
|
+
return json(res, 500, { error: e.message || String(e) });
|
|
1531
|
+
}
|
|
1532
|
+
};
|
|
1533
|
+
|
|
1534
|
+
return { handler, masto, c2s, streaming };
|
|
1535
|
+
}
|
|
1536
|
+
|
|
1537
|
+
export function startAdmin({ port, gateToken, agent, log = console.log, handle = null, tls = null }) {
|
|
1538
|
+
const gate = makeGate(gateToken);
|
|
1539
|
+
// Live, so the named origin appears the moment connect() reads the handle
|
|
1540
|
+
// out of pod state — including for the OAuth redirect check in MastoApi.
|
|
1541
|
+
// The https listener's port joins the authority set: same names, second port.
|
|
1542
|
+
const allowed = new Authorities(port, handle);
|
|
1543
|
+
agent.authorities = allowed;
|
|
1544
|
+
const { handler, streaming } = buildAdminSurface({ agent, gate, allowed, log, port, handle });
|
|
1545
|
+
|
|
1546
|
+
// Loopback both ways: the canonical URL is https://localhost:<port>/, and
|
|
1547
|
+
// "localhost" resolves to ::1 on many systems before falling back to IPv4 —
|
|
1548
|
+
// answer on both so the same origin always works (one origin = one
|
|
1549
|
+
// browser storage = one login). There is one listener and it is https: the
|
|
1550
|
+
// port you name is the port you browse.
|
|
1551
|
+
// Every listener here is https, so a caller that did not bring a certificate
|
|
1552
|
+
// gets this install's own rather than an exception: throwing here killed the
|
|
1553
|
+
// signal handlers registered after the call, and the agent became unstoppable.
|
|
1554
|
+
if (!tls) {
|
|
1555
|
+
tls = ensureTrustedTls(path.join(rootOf(agent.home || os.tmpdir()), 'certs'),
|
|
1556
|
+
{ log, names: allowed.label ? [`${allowed.label}.localhost`] : [] });
|
|
1557
|
+
}
|
|
1558
|
+
const tlsOpts = { key: tls.key, cert: tls.cert };
|
|
1559
|
+
const server = https.createServer(tlsOpts, handler);
|
|
1560
|
+
streaming.attach(server);
|
|
1561
|
+
const server6 = https.createServer(tlsOpts, handler);
|
|
1562
|
+
streaming.attach(server6);
|
|
1563
|
+
server6.on('error', () => { /* no IPv6 loopback on this system — IPv4 covers it */ });
|
|
1564
|
+
const onListen = () => {
|
|
1565
|
+
// Pidfile for `fedipod stop` — written only AFTER the listen
|
|
1566
|
+
// succeeds, so a port-race loser can never clobber the live agent's pid.
|
|
1567
|
+
try {
|
|
1568
|
+
if (agent.home) fs.writeFileSync(path.join(agent.home, 'agent.pid'), String(process.pid) + '\n');
|
|
1569
|
+
} catch { /* stop will report no pidfile */ }
|
|
1570
|
+
// The named origin when there is one: on a detached start this line is the
|
|
1571
|
+
// only record of where to browse, and sending you to the shared origin is
|
|
1572
|
+
// how two identities end up in one browser storage bucket.
|
|
1573
|
+
log(`FediPod on ${secureOrigin(allowed.label, port)}/ (UI + API)`
|
|
1574
|
+
+ (tls.trust ? '' : ' (self-signed — your client may ask once to trust it)'));
|
|
1575
|
+
// Hold the well-known door: whoever answers on the directory port sends the
|
|
1576
|
+
// browser to its own record page, which lists every identity on this
|
|
1577
|
+
// machine. Only a configured agent qualifies — an unconfigured one's record
|
|
1578
|
+
// page is a setup form with no identity list.
|
|
1579
|
+
const door = claimDirectory({ port,
|
|
1580
|
+
origin: () => secureOrigin(allowed.label, port),
|
|
1581
|
+
log, eligible: () => agent.configured(), tls });
|
|
1582
|
+
// Closing the IPv4 listener has to take the IPv6 one and the door with it,
|
|
1583
|
+
// or a shutdown leaves a listening handle and the process never exits.
|
|
1584
|
+
server.on('close', () => { door.stop(); try { server6.close(); } catch { /* already down */ } });
|
|
1585
|
+
};
|
|
1586
|
+
let retriedDoor = false;
|
|
1587
|
+
server.on('error', async (e) => {
|
|
1588
|
+
if (e.code === 'EADDRINUSE') {
|
|
1589
|
+
// The directory door yields to a real owner of its port; anything else
|
|
1590
|
+
// holding it is a genuine conflict.
|
|
1591
|
+
if (!retriedDoor && await yieldDirectory(port, { portFree, home: agent.home })) {
|
|
1592
|
+
retriedDoor = true;
|
|
1593
|
+
server.listen(port, '127.0.0.1', onListen);
|
|
1594
|
+
if (!server6.listening) server6.listen(port, '::1');
|
|
1595
|
+
return;
|
|
1596
|
+
}
|
|
1597
|
+
log(`port ${port} is already in use by another server — set AP_PORT to a free port and retry`);
|
|
1598
|
+
process.exit(1);
|
|
1599
|
+
}
|
|
1600
|
+
throw e;
|
|
1601
|
+
});
|
|
1602
|
+
server.listen(port, '127.0.0.1', onListen);
|
|
1603
|
+
server6.listen(port, '::1');
|
|
1604
|
+
|
|
1605
|
+
return server;
|
|
1606
|
+
}
|