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/mastoapi.mjs
ADDED
|
@@ -0,0 +1,1588 @@
|
|
|
1
|
+
// mastoapi.mjs — Mastodon client-API facade over ap-agent (M1: read + post).
|
|
2
|
+
// Modeled on snac2's approach: real implementations for the endpoints
|
|
3
|
+
// clients actually exercise, empty-collection stubs for the rest. Reached
|
|
4
|
+
// through the router (/api/*, /oauth/* → this port), so requests carry the
|
|
5
|
+
// gate; OAuth here is theater for a single already-trusted local user.
|
|
6
|
+
//
|
|
7
|
+
// Surface: oauth trio · instance v1/v2 · verify_credentials · timelines/home
|
|
8
|
+
// (M1) · notifications, relationships, lookup, follow/unfollow, thread
|
|
9
|
+
// context, /v2/search (M2) · favourite/reblog, media upload, markers,
|
|
10
|
+
// DELETE status (M3) · stub farm. Unknown /api/* GETs 404 and are LOGGED —
|
|
11
|
+
// that log is the running punch list.
|
|
12
|
+
|
|
13
|
+
import crypto from 'node:crypto';
|
|
14
|
+
import * as social from './social.mjs';
|
|
15
|
+
import { sanitizeHtml } from './wire.mjs';
|
|
16
|
+
import { authorOf } from './intake.mjs';
|
|
17
|
+
import { profileUrl, postUrl } from './bskyfeed.mjs';
|
|
18
|
+
import { Push } from './webpush.mjs';
|
|
19
|
+
|
|
20
|
+
// What an attachment is allowed to BE. Anything else is stored as bytes, which
|
|
21
|
+
// a browser downloads rather than runs.
|
|
22
|
+
const ATTACHMENT_KINDS = new Set(['image', 'video', 'audio']);
|
|
23
|
+
// image/* with an exception: an SVG is a document, it carries script, and a
|
|
24
|
+
// browser renders it rather than showing it. Mastodon does not take them as
|
|
25
|
+
// media either.
|
|
26
|
+
const NEVER = new Set(['image/svg+xml', 'image/svg']);
|
|
27
|
+
const OPAQUE = 'application/octet-stream';
|
|
28
|
+
|
|
29
|
+
export function attachmentType(claimed) {
|
|
30
|
+
const t = String(claimed || '').split(';')[0].trim().toLowerCase();
|
|
31
|
+
if (!/^[a-z0-9.+-]+\/[a-z0-9.+-]+$/.test(t) || NEVER.has(t)) return OPAQUE;
|
|
32
|
+
return ATTACHMENT_KINDS.has(t.split('/')[0]) ? t : OPAQUE;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// From the TYPE we accepted, not from the name the client sent — an `.html`
|
|
36
|
+
// suffix on a file stored as octet-stream is what a pod would serve from, and
|
|
37
|
+
// a filename is the client's to choose.
|
|
38
|
+
export function extensionFor(mediaType, filename = '') {
|
|
39
|
+
if (mediaType === OPAQUE) return 'bin';
|
|
40
|
+
const sub = mediaType.split('/')[1].replace(/[^a-z0-9]/g, '');
|
|
41
|
+
const given = String(filename || '').includes('.')
|
|
42
|
+
? filename.split('.').pop().toLowerCase().replace(/[^a-z0-9]/g, '') : '';
|
|
43
|
+
// Keep the client's suffix only when it plainly belongs to the accepted type,
|
|
44
|
+
// so jpg/jpeg and mp4/m4v survive without a table of every media type.
|
|
45
|
+
return given && (given === sub || sub.startsWith(given) || given.startsWith(sub)) ? given : (sub || 'bin');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const STUBS = new Map(Object.entries({
|
|
49
|
+
'/api/v1/filters': [],
|
|
50
|
+
'/api/v1/custom_emojis': [],
|
|
51
|
+
'/api/v1/announcements': [],
|
|
52
|
+
'/api/v1/follow_requests': [],
|
|
53
|
+
'/api/v1/instance/peers': [],
|
|
54
|
+
'/api/v1/trends/tags': [], '/api/v1/trends/links': [],
|
|
55
|
+
'/api/v2/suggestions': [],
|
|
56
|
+
'/api/v1/preferences': {},
|
|
57
|
+
'/api/v1/followed_tags': [],
|
|
58
|
+
}));
|
|
59
|
+
|
|
60
|
+
const TOKEN_TTL_MS = 90 * 24 * 60 * 60 * 1000; // tokens age out after 90 days
|
|
61
|
+
const AUTHZ_WINDOW_MS = 60_000;
|
|
62
|
+
const AUTHZ_MAX_ATTEMPTS = 5;
|
|
63
|
+
const CODE_TTL_MS = 5 * 60_000; // an authorization code is short-lived
|
|
64
|
+
const MAX_APPS = 200; // registered third-party clients, capped
|
|
65
|
+
|
|
66
|
+
export class MastoApi {
|
|
67
|
+
constructor({ agent, log = console.log, allowed = null, scheme = null, embedded = false }) {
|
|
68
|
+
this.agent = agent;
|
|
69
|
+
// A server-hosted identity has no CLI of its own, so the advice this gives
|
|
70
|
+
// when it refuses has to name the route that identity really has.
|
|
71
|
+
this.embedded = embedded;
|
|
72
|
+
this.log = log;
|
|
73
|
+
this.allowed = allowed; // authorities a redirect_uri may name
|
|
74
|
+
// The scheme this identity is reached on, when the socket cannot say —
|
|
75
|
+
// a server behind a TLS proxy terminates cleartext and still is https.
|
|
76
|
+
this.scheme = scheme;
|
|
77
|
+
this.authzAttempts = []; // password-attempt timestamps
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
get store() { return this.agent.store; }
|
|
81
|
+
get urls() { return this.agent.publisher?.urls; }
|
|
82
|
+
get push() {
|
|
83
|
+
this._push ||= new Push({
|
|
84
|
+
store: this.store,
|
|
85
|
+
subject: () => this.urls?.actor || 'https://localhost/',
|
|
86
|
+
log: this.log,
|
|
87
|
+
});
|
|
88
|
+
return this._push;
|
|
89
|
+
}
|
|
90
|
+
get host() { return this.urls ? new URL(this.urls.base).host : 'unconfigured.invalid'; }
|
|
91
|
+
|
|
92
|
+
// Where the live feed is, as the CLIENT must address it: this agent's own
|
|
93
|
+
// origin, taken from the request, not the pod's host. An instance document
|
|
94
|
+
// that leaves it empty is not merely unhelpful — clients read it without a
|
|
95
|
+
// guard and fall over, and every one of them loses live updates.
|
|
96
|
+
streamingUrl(req) {
|
|
97
|
+
const host = req?.headers?.host || `localhost:${this.port || ''}`;
|
|
98
|
+
const secure = this.scheme
|
|
99
|
+
? this.scheme.startsWith('https')
|
|
100
|
+
: req?.headers?.['x-forwarded-proto'] === 'https' || !!req?.socket?.encrypted;
|
|
101
|
+
return `${secure ? 'wss' : 'ws'}://${host}/api/v1/streaming`;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// ---- tokens ----
|
|
105
|
+
// Tokens are records {token, createdAt} and expire; legacy bare strings
|
|
106
|
+
// are read as undated and treated as expired-on-sight only if older
|
|
107
|
+
// formats can't be dated (they get an epoch of now on first migration).
|
|
108
|
+
tokenRecords() {
|
|
109
|
+
const raw = this.store.read('masto-tokens.json', []);
|
|
110
|
+
return raw.map(r => (typeof r === 'string' ? { token: r, createdAt: Date.now() } : r));
|
|
111
|
+
}
|
|
112
|
+
tokens() {
|
|
113
|
+
const now = Date.now();
|
|
114
|
+
return this.tokenRecords().filter(r => now - (r.createdAt || 0) < TOKEN_TTL_MS).map(r => r.token);
|
|
115
|
+
}
|
|
116
|
+
mintToken() {
|
|
117
|
+
const t = crypto.randomBytes(24).toString('hex');
|
|
118
|
+
const now = Date.now();
|
|
119
|
+
const kept = this.tokenRecords().filter(r => now - (r.createdAt || 0) < TOKEN_TTL_MS);
|
|
120
|
+
this.store.write('masto-tokens.json', [...kept, { token: t, createdAt: now }].slice(-20));
|
|
121
|
+
return t;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Registered OAuth apps. A third-party (browser) client registers here, and
|
|
125
|
+
// the authorization code it later receives is bound to the client_id and the
|
|
126
|
+
// redirect_uri it registered — so the code reaches only where that client
|
|
127
|
+
// said, and only that client, presenting its secret, can exchange it for a
|
|
128
|
+
// bearer. A redirect back to this agent's own origin keeps the local flow.
|
|
129
|
+
apps() { return this.store.read('oauth-apps.json', []); }
|
|
130
|
+
findApp(clientId) { return clientId ? this.apps().find(a => a.clientId === clientId) || null : null; }
|
|
131
|
+
registerApp({ name, website, redirectUris, scopes }) {
|
|
132
|
+
const app = {
|
|
133
|
+
clientId: crypto.randomBytes(16).toString('hex'),
|
|
134
|
+
clientSecret: crypto.randomBytes(32).toString('base64url'),
|
|
135
|
+
name: String(name || 'client').slice(0, 200),
|
|
136
|
+
website: String(website || '').slice(0, 500),
|
|
137
|
+
redirectUris, scopes: String(scopes || 'read'), createdAt: Date.now(),
|
|
138
|
+
};
|
|
139
|
+
this.store.write('oauth-apps.json', [...this.apps(), app].slice(-MAX_APPS));
|
|
140
|
+
return app;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// A short-lived, single-use authorization code for a registered client, kept
|
|
144
|
+
// apart from masto-tokens.json so the code is NOT a bearer until it is
|
|
145
|
+
// exchanged with the client secret.
|
|
146
|
+
mintCode({ clientId, redirectUri, scope }) {
|
|
147
|
+
const code = crypto.randomBytes(24).toString('hex');
|
|
148
|
+
const now = Date.now();
|
|
149
|
+
const kept = this.store.read('oauth-codes.json', []).filter(c => now - c.createdAt < CODE_TTL_MS);
|
|
150
|
+
this.store.write('oauth-codes.json', [...kept, { code, clientId, redirectUri, scope, createdAt: now }].slice(-50));
|
|
151
|
+
return code;
|
|
152
|
+
}
|
|
153
|
+
consumeCode(code) {
|
|
154
|
+
const now = Date.now();
|
|
155
|
+
const all = this.store.read('oauth-codes.json', []);
|
|
156
|
+
const rec = all.find(c => c.code === code && now - c.createdAt < CODE_TTL_MS);
|
|
157
|
+
if (rec) this.store.write('oauth-codes.json', all.filter(c => c.code !== code)); // single-use
|
|
158
|
+
return rec || null;
|
|
159
|
+
}
|
|
160
|
+
authed(req) {
|
|
161
|
+
const m = /^Bearer (.+)$/.exec(req.headers.authorization || '');
|
|
162
|
+
return !!m && this.tokens().includes(m[1]);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// A redirect_uri must name an authority this agent answers on — otherwise
|
|
166
|
+
// a visited page could navigate to /oauth/authorize and have the freshly
|
|
167
|
+
// minted code delivered to itself.
|
|
168
|
+
redirectAllowed(redirect) {
|
|
169
|
+
if (!redirect || redirect === 'urn:ietf:wg:oauth:2.0:oob') return true;
|
|
170
|
+
if (!this.allowed) return true; // no policy configured (tests)
|
|
171
|
+
try {
|
|
172
|
+
const u = new URL(redirect);
|
|
173
|
+
if (u.protocol !== 'http:' && u.protocol !== 'https:') return false;
|
|
174
|
+
return this.allowed.has(u.host.toLowerCase());
|
|
175
|
+
} catch { return false; }
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
rateLimited() {
|
|
179
|
+
const now = Date.now();
|
|
180
|
+
this.authzAttempts = this.authzAttempts.filter(t => now - t < AUTHZ_WINDOW_MS);
|
|
181
|
+
if (this.authzAttempts.length >= AUTHZ_MAX_ATTEMPTS) return true;
|
|
182
|
+
this.authzAttempts.push(now);
|
|
183
|
+
return false;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Every agent used to report title 'solid-activitypub', so a client holding two
|
|
187
|
+
// of them showed two identical instances and you had to read the acct to tell
|
|
188
|
+
// them apart. The title is free text no client parses — make it say who.
|
|
189
|
+
instanceTitle() {
|
|
190
|
+
const cfg = this.store.getConfig();
|
|
191
|
+
return cfg?.handle ? `@${cfg.handle}@${this.host}` : 'FediPod';
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
instanceBlurb() {
|
|
195
|
+
const kind = this.store.getConfig()?.kind === 'group' ? 'group' : 'actor';
|
|
196
|
+
return `Solid pod ActivityPub ${kind}`;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// ---- object rendering ----
|
|
200
|
+
selfAccount() {
|
|
201
|
+
// No fallback handle. `account()` already reads config for self, so a
|
|
202
|
+
// literal here only ever supplies a name that is not this actor's — and it
|
|
203
|
+
// was a real person's, so every install with no handle called itself jeff.
|
|
204
|
+
return this.account(this.urls.actor, { selfAcct: this.store.getConfig()?.handle || null });
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
account(actorUrl, { selfAcct } = {}) {
|
|
208
|
+
const cached = this.store.getActors()[actorUrl] || {};
|
|
209
|
+
let host = '', user = cached.preferredUsername || '';
|
|
210
|
+
try { host = new URL(actorUrl).host; if (!user) user = new URL(actorUrl).pathname.split('/').pop(); } catch {}
|
|
211
|
+
const self = actorUrl === this.urls?.actor;
|
|
212
|
+
if (self) user = selfAcct || this.store.getConfig()?.handle || user;
|
|
213
|
+
return {
|
|
214
|
+
id: this.store.idFor(actorUrl),
|
|
215
|
+
username: user,
|
|
216
|
+
// Self gets the FULL acct (Mastodon proper returns the bare local part
|
|
217
|
+
// here): the client's login domain is the loopback agent, so the bare
|
|
218
|
+
// form would display as user@127.0.0.1 — the full form shows the real
|
|
219
|
+
// fediverse identity, and every client renders @-containing accts as-is.
|
|
220
|
+
acct: `${self ? (selfAcct || user) : user}@${host}`,
|
|
221
|
+
// Our own profile is not in the actor cache — the cache is for other
|
|
222
|
+
// people — so read it from config, or the editor opens empty and saving
|
|
223
|
+
// wipes what was there.
|
|
224
|
+
display_name: (self ? this.store.getConfig()?.name : cached.name) || cached.name || user,
|
|
225
|
+
locked: self ? !!this.store.getConfig()?.approveJoins : false,
|
|
226
|
+
// Read from config for self, like the fields above it: our own actor is
|
|
227
|
+
// not in the actor cache — the cache is for other people — so a group
|
|
228
|
+
// asking about itself would be told it was a person.
|
|
229
|
+
bot: false, discoverable: true,
|
|
230
|
+
group: self ? this.store.getConfig()?.kind === 'group' : cached.type === 'Group',
|
|
231
|
+
created_at: '2026-01-01T00:00:00.000Z',
|
|
232
|
+
note: (self ? this.store.getConfig()?.summary : cached.summary) || '',
|
|
233
|
+
url: actorUrl, uri: actorUrl,
|
|
234
|
+
avatar: selfIcon(this, self, cached) || TRANSPARENT_PNG,
|
|
235
|
+
avatar_static: selfIcon(this, self, cached) || TRANSPARENT_PNG,
|
|
236
|
+
header: (self ? this.store.getConfig()?.image : null) || TRANSPARENT_PNG,
|
|
237
|
+
header_static: (self ? this.store.getConfig()?.image : null) || TRANSPARENT_PNG,
|
|
238
|
+
// A remote actor's counts are whatever its own collections said when we
|
|
239
|
+
// last asked; unknown stays 0 because the API has no way to say "unknown".
|
|
240
|
+
followers_count: self ? this.store.getContacts().followers.length : (cached.counts?.followers ?? 0),
|
|
241
|
+
// Accepted only, matching both the published `following` collection and
|
|
242
|
+
// the list this number opens — a pending Follow is not yet a following.
|
|
243
|
+
following_count: self ? this.store.getContacts().following.filter(f => f.accepted).length : (cached.counts?.following ?? 0),
|
|
244
|
+
statuses_count: self ? this.store.getStatuses().filter(s => s.kind === 'post').length : 0,
|
|
245
|
+
last_status_at: null, emojis: [],
|
|
246
|
+
fields: (self ? this.store.getConfig()?.fields : cached.fields) || [],
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// `all` is not an optimisation, it is the difference between one clone and
|
|
251
|
+
// one per status: store.getStatuses() structuredClones the entire array, so
|
|
252
|
+
// rendering a 40-status timeline without it cloned a 1000-entry array 40
|
|
253
|
+
// times purely to count replies. Every caller that renders more than one
|
|
254
|
+
// status passes it; this fallback is for the single-status paths.
|
|
255
|
+
// Mastodon's cursor paging, over an array already in newest-first order.
|
|
256
|
+
// Clients do not read a `next` out of the body — they follow the Link header,
|
|
257
|
+
// which nothing here emitted, so a client could only ever see the first page.
|
|
258
|
+
// `idOf` is what a cursor names. Statuses are cursored by note, account lists
|
|
259
|
+
// by actor, so the caller says which field carries the id.
|
|
260
|
+
page(items, url, { limit = 20, max = 40, idOf = (s) => this.store.idFor(s.noteId) } = {}) {
|
|
261
|
+
const n = Math.min(Number(url.searchParams.get('limit')) || limit, max);
|
|
262
|
+
// The cursor scan is linear over every status, and `idOf` defaults to
|
|
263
|
+
// store.idFor, which structuredClones the whole uncapped ids.json on every
|
|
264
|
+
// call. Scrolling a 5000-status timeline was therefore millions of object
|
|
265
|
+
// copies of synchronous, event-loop-blocking work to serve one page of 20,
|
|
266
|
+
// and it ran again for since_id and min_id. The id is a pure function of
|
|
267
|
+
// the URL, so remembering it per item within one call is free and exact.
|
|
268
|
+
const ids = new Map();
|
|
269
|
+
const idAt = (s) => {
|
|
270
|
+
if (!ids.has(s)) ids.set(s, idOf(s));
|
|
271
|
+
return ids.get(s);
|
|
272
|
+
};
|
|
273
|
+
const cut = (param) => {
|
|
274
|
+
const v = url.searchParams.get(param);
|
|
275
|
+
if (!v) return null;
|
|
276
|
+
const i = items.findIndex(s => idAt(s) === v);
|
|
277
|
+
return i < 0 ? null : i;
|
|
278
|
+
};
|
|
279
|
+
const maxAt = cut('max_id');
|
|
280
|
+
if (maxAt != null) items = items.slice(maxAt + 1); // older than this one
|
|
281
|
+
for (const p of ['since_id', 'min_id']) {
|
|
282
|
+
const at = cut(p);
|
|
283
|
+
if (at != null) items = items.slice(0, at); // newer than this one
|
|
284
|
+
}
|
|
285
|
+
const pageItems = items.slice(0, n);
|
|
286
|
+
if (!pageItems.length) return { items: pageItems, headers: {} };
|
|
287
|
+
const base = `http://${this.host}${url.pathname}`;
|
|
288
|
+
const q = (extra) => {
|
|
289
|
+
const u = new URL(base);
|
|
290
|
+
for (const [k, v] of url.searchParams) if (!['max_id', 'since_id', 'min_id'].includes(k)) u.searchParams.set(k, v);
|
|
291
|
+
for (const [k, v] of Object.entries(extra)) u.searchParams.set(k, v);
|
|
292
|
+
return u.href;
|
|
293
|
+
};
|
|
294
|
+
const links = [`<${q({ max_id: idOf(pageItems[pageItems.length - 1]) })}>; rel="next"`];
|
|
295
|
+
if (pageItems.length) links.push(`<${q({ min_id: idOf(pageItems[0]) })}>; rel="prev"`);
|
|
296
|
+
return { items: pageItems, headers: { link: links.join(', ') } };
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// A reply to a mirrored Bluesky post: a native reply from the connected
|
|
300
|
+
// account, threaded under the original. It exists only on Bluesky, so the
|
|
301
|
+
// row added here is its one local copy.
|
|
302
|
+
async bskyReply(send, body, parent, visibility) {
|
|
303
|
+
const at = this.agent.atproto;
|
|
304
|
+
if (!at?.connected()) return send(422, { error: 'this is a Bluesky post — no Bluesky account is connected to reply from' });
|
|
305
|
+
if (visibility !== 'public' && visibility !== 'unlisted') {
|
|
306
|
+
return send(422, { error: 'a Bluesky reply is public — pick public visibility' });
|
|
307
|
+
}
|
|
308
|
+
if (body.scheduled_at) return send(422, { error: 'a Bluesky reply cannot be scheduled' });
|
|
309
|
+
if ([].concat(body.media_ids || body['media_ids[]'] || []).filter(Boolean).length) {
|
|
310
|
+
return send(422, { error: 'images on a Bluesky reply are not supported' });
|
|
311
|
+
}
|
|
312
|
+
try {
|
|
313
|
+
const out = await at.reply(body.status, parent.noteId);
|
|
314
|
+
const rec = at.read();
|
|
315
|
+
const actor = profileUrl(rec.did);
|
|
316
|
+
if (!this.store.getActors()[actor]) {
|
|
317
|
+
this.store.cacheActor(actor, { name: rec.handle, preferredUsername: rec.handle, type: 'Person' });
|
|
318
|
+
}
|
|
319
|
+
const text = String(body.status).trim();
|
|
320
|
+
this.store.addStatus({
|
|
321
|
+
noteId: out.uri, actor, inReplyTo: parent.noteId,
|
|
322
|
+
content: `<p>${text.replace(/[&<>]/g, c => ({ '&': '&', '<': '<', '>': '>' }[c]))}</p>`,
|
|
323
|
+
published: new Date().toISOString(), kind: 'bsky',
|
|
324
|
+
...(out.cid ? { cid: out.cid } : {}), link: postUrl(out.uri),
|
|
325
|
+
});
|
|
326
|
+
return send(200, this.status(this.store.getStatuses().find(x => x.noteId === out.uri)));
|
|
327
|
+
} catch (e) { return send(422, { error: e.message }); }
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
status(s, { all } = {}) {
|
|
331
|
+
const replies = (all || this.store.getStatuses()).filter(x => x.inReplyTo === s.noteId).length;
|
|
332
|
+
return {
|
|
333
|
+
id: this.store.idFor(s.noteId),
|
|
334
|
+
created_at: s.published || new Date().toISOString(),
|
|
335
|
+
in_reply_to_id: s.inReplyTo ? this.store.idFor(s.inReplyTo) : null,
|
|
336
|
+
in_reply_to_account_id: null,
|
|
337
|
+
sensitive: !!s.spoiler, spoiler_text: s.spoiler || '',
|
|
338
|
+
visibility: s.visibility || 'public', language: null,
|
|
339
|
+
edited_at: s.editedAt || null,
|
|
340
|
+
uri: s.noteId, url: s.link || s.noteId,
|
|
341
|
+
replies_count: replies, reblogs_count: 0, favourites_count: 0,
|
|
342
|
+
favourited: !!s.favourited, reblogged: !!s.reblogged,
|
|
343
|
+
muted: false, bookmarked: !!s.bookmarked, pinned: !!s.pinned,
|
|
344
|
+
content: s.content || '',
|
|
345
|
+
reblog: null, application: null,
|
|
346
|
+
account: this.account(s.actor),
|
|
347
|
+
media_attachments: (s.attachments || []).map(a => this.mediaJson(a)),
|
|
348
|
+
// The mention entities are how a client knows a link is an ACCOUNT —
|
|
349
|
+
// without them, clicking a mentioned group lands on the raw actor doc.
|
|
350
|
+
mentions: (s.mentions || []).map((m) => {
|
|
351
|
+
const bare = String(m.name || '').replace(/^@/, '');
|
|
352
|
+
const user = bare.split('@')[0];
|
|
353
|
+
let host = '';
|
|
354
|
+
try { host = new URL(m.href).host; } catch { /* keep bare */ }
|
|
355
|
+
return {
|
|
356
|
+
id: this.store.idFor(m.href),
|
|
357
|
+
username: user || bare,
|
|
358
|
+
url: m.href,
|
|
359
|
+
acct: bare.includes('@') ? bare : (host ? `${user}@${host}` : bare),
|
|
360
|
+
};
|
|
361
|
+
}),
|
|
362
|
+
tags: [],
|
|
363
|
+
emojis: (s.emojis || []).map(e => ({
|
|
364
|
+
shortcode: e.shortcode, url: e.url, static_url: e.url, visible_in_picker: false,
|
|
365
|
+
})),
|
|
366
|
+
card: null, poll: s.poll ? this.pollJson(s) : null,
|
|
367
|
+
};
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// A timeline row's id may name the CARRY rather than the post — statusOrBoost
|
|
371
|
+
// mints `via:<noteId>` for the envelope. A client that asks about a row it
|
|
372
|
+
// was served has to get an answer: a 404 there makes it drop the row, which
|
|
373
|
+
// is how carried posts vanished from the home column while every other view
|
|
374
|
+
// still had them.
|
|
375
|
+
lookup(id) {
|
|
376
|
+
const raw = this.store.urlFor(id);
|
|
377
|
+
if (typeof raw !== 'string') return { s: null, wrapped: false };
|
|
378
|
+
const wrapped = raw.startsWith('via:');
|
|
379
|
+
const noteId = wrapped ? raw.slice(4) : raw;
|
|
380
|
+
return { s: this.store.getStatuses().find(x => x.noteId === noteId) || null, wrapped };
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// A carried post, the way clients expect to see one: the carrier "boosts"
|
|
384
|
+
// the inner post, so the feed says who brought it. Timeline views only —
|
|
385
|
+
// fetching the post by its own id still returns the post itself.
|
|
386
|
+
statusOrBoost(s, opts = {}) {
|
|
387
|
+
if (!s.via || s.via === s.actor) return this.status(s, opts);
|
|
388
|
+
const inner = this.status(s, opts);
|
|
389
|
+
return {
|
|
390
|
+
id: this.store.idFor('via:' + s.noteId),
|
|
391
|
+
created_at: s.announcedAt || s.published || inner.created_at,
|
|
392
|
+
in_reply_to_id: null, in_reply_to_account_id: null,
|
|
393
|
+
sensitive: false, spoiler_text: '', visibility: inner.visibility, language: null,
|
|
394
|
+
edited_at: null,
|
|
395
|
+
uri: s.announceActivity?.id || s.noteId + '#announce',
|
|
396
|
+
url: inner.url,
|
|
397
|
+
replies_count: 0, reblogs_count: 0, favourites_count: 0,
|
|
398
|
+
favourited: false, reblogged: false, muted: false, bookmarked: false, pinned: false,
|
|
399
|
+
content: '', reblog: inner, application: null,
|
|
400
|
+
account: this.account(s.via),
|
|
401
|
+
media_attachments: [], mentions: [], tags: [], emojis: [], card: null, poll: null,
|
|
402
|
+
};
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// A notification, pushed. Fire-and-forget from the store's event hook.
|
|
406
|
+
pushNotify(n) {
|
|
407
|
+
const acct = this.account(n.actor);
|
|
408
|
+
const verbs = {
|
|
409
|
+
mention: 'mentioned you', favourite: 'favourited your post',
|
|
410
|
+
reblog: 'boosted your post', follow: 'followed you',
|
|
411
|
+
'follow-request': 'asked to follow you', move: 'moved account',
|
|
412
|
+
};
|
|
413
|
+
const s = n.noteId && this.store.getStatuses().find(x => x.noteId === n.noteId);
|
|
414
|
+
return this.push.notify(n, {
|
|
415
|
+
notification_id: n.id, notification_type: n.type, preferred_locale: 'en',
|
|
416
|
+
title: `${acct.display_name || acct.acct} ${verbs[n.type] || n.type}`,
|
|
417
|
+
body: s ? htmlToText(s.content || '').slice(0, 140) : '',
|
|
418
|
+
icon: acct.avatar || '',
|
|
419
|
+
});
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
scheduledJson(e) {
|
|
423
|
+
return {
|
|
424
|
+
id: e.id, scheduled_at: e.scheduledAt,
|
|
425
|
+
params: {
|
|
426
|
+
text: e.params.status, visibility: e.params.visibility || 'public',
|
|
427
|
+
spoiler_text: e.params.spoilerText || null, sensitive: !!e.params.spoilerText,
|
|
428
|
+
in_reply_to_id: e.params.inReplyTo ? this.store.idFor(e.params.inReplyTo) : null,
|
|
429
|
+
media_ids: (e.params.attachments || []).map(a => a.id),
|
|
430
|
+
poll: null, idempotency: null, scheduled_at: e.scheduledAt, application_id: null,
|
|
431
|
+
},
|
|
432
|
+
media_attachments: (e.params.attachments || []).map(a => this.mediaJson(a)),
|
|
433
|
+
};
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
pollJson(s) {
|
|
437
|
+
const opts = s.poll.options || [];
|
|
438
|
+
const votes = opts.reduce((n, o) => n + (o.votes || 0), 0);
|
|
439
|
+
return {
|
|
440
|
+
id: this.store.idFor(s.noteId),
|
|
441
|
+
expires_at: s.poll.expiresAt || null,
|
|
442
|
+
expired: !!s.poll.closed || (!!s.poll.expiresAt && Date.parse(s.poll.expiresAt) < Date.now()),
|
|
443
|
+
multiple: !!s.poll.multiple,
|
|
444
|
+
votes_count: votes, voters_count: null,
|
|
445
|
+
options: opts.map(o => ({ title: o.title, votes_count: o.votes || 0 })),
|
|
446
|
+
voted: !!s.poll.voted, own_votes: s.poll.ownVotes || [],
|
|
447
|
+
emojis: [],
|
|
448
|
+
};
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
mediaJson(a) {
|
|
452
|
+
const kind = /^video\//.test(a.mediaType) ? 'video'
|
|
453
|
+
: /^audio\//.test(a.mediaType) ? 'audio'
|
|
454
|
+
: /^image\/gif/.test(a.mediaType) ? 'gifv' : 'image';
|
|
455
|
+
return {
|
|
456
|
+
id: a.id || this.store.idFor(a.url),
|
|
457
|
+
type: kind, url: a.url, preview_url: a.url, remote_url: null,
|
|
458
|
+
description: a.description || null, blurhash: null, meta: {},
|
|
459
|
+
};
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
relationship(actorUrl) {
|
|
463
|
+
const c = this.store.getContacts();
|
|
464
|
+
const fol = c.following.find(f => f.actor === actorUrl);
|
|
465
|
+
return {
|
|
466
|
+
id: this.store.idFor(actorUrl),
|
|
467
|
+
following: !!fol?.accepted, requested: !!fol && !fol.accepted,
|
|
468
|
+
followed_by: c.followers.some(f => f.actor === actorUrl),
|
|
469
|
+
showing_reblogs: true, notifying: false, languages: null,
|
|
470
|
+
blocking: this.store.getBlocklist().actors.includes(actorUrl),
|
|
471
|
+
blocked_by: false, domain_blocking: false,
|
|
472
|
+
muting: this.store.getMuted().actors.includes(actorUrl),
|
|
473
|
+
muting_notifications: false, endorsed: false, note: '',
|
|
474
|
+
};
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
notification(n) {
|
|
478
|
+
const out = { id: n.id, type: n.type, created_at: n.at, account: this.account(n.actor) };
|
|
479
|
+
if (n.noteId) {
|
|
480
|
+
const s = this.store.getStatuses().find(x => x.noteId === n.noteId);
|
|
481
|
+
if (s) out.status = this.status(s);
|
|
482
|
+
}
|
|
483
|
+
return out;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
// Every account this instance knows whose handle or name matches: self,
|
|
487
|
+
// contacts, cached actor docs. Handle-shaped queries resolve via webfinger.
|
|
488
|
+
async accountSearch(q) {
|
|
489
|
+
const needle = String(q || '').replace(/^@/, '').toLowerCase().trim();
|
|
490
|
+
if (!needle) return [];
|
|
491
|
+
if (/^[^@\s]+@[^@\s.]+\.[^@\s]+$/.test(needle)) {
|
|
492
|
+
try {
|
|
493
|
+
const doc = await social.resolveHandle(this.agent, needle);
|
|
494
|
+
return [this.account(doc.id)];
|
|
495
|
+
} catch (e) { this.log(`account search resolve ${needle}: ${e.message}`); }
|
|
496
|
+
return [];
|
|
497
|
+
}
|
|
498
|
+
const cfg = this.store.getConfig();
|
|
499
|
+
const seen = new Set();
|
|
500
|
+
const out = [];
|
|
501
|
+
const add = (actorUrl) => {
|
|
502
|
+
if (actorUrl && !seen.has(actorUrl)) { seen.add(actorUrl); out.push(this.account(actorUrl)); }
|
|
503
|
+
};
|
|
504
|
+
if ((cfg?.handle || '').toLowerCase().includes(needle)
|
|
505
|
+
|| (cfg?.name || '').toLowerCase().includes(needle)) add(this.urls.actor);
|
|
506
|
+
const contacts = this.store.getContacts();
|
|
507
|
+
for (const rec of [...contacts.followers, ...contacts.following]) {
|
|
508
|
+
if ((rec.handle || rec.actor || '').toLowerCase().includes(needle)) add(rec.actor);
|
|
509
|
+
}
|
|
510
|
+
for (const [u, a] of Object.entries(this.store.getActors())) {
|
|
511
|
+
if ((a.preferredUsername + ' ' + a.name + ' ' + u).toLowerCase().includes(needle)) add(u);
|
|
512
|
+
}
|
|
513
|
+
return out.slice(0, 20);
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
// ---- request handling; returns true when handled ----
|
|
517
|
+
async handle(req, res, pathname, url) {
|
|
518
|
+
const send = (status, obj, headers = {}) => {
|
|
519
|
+
const body = JSON.stringify(obj);
|
|
520
|
+
res.writeHead(status, { 'content-type': 'application/json', ...headers });
|
|
521
|
+
res.end(body);
|
|
522
|
+
return true;
|
|
523
|
+
};
|
|
524
|
+
|
|
525
|
+
// --- oauth ---
|
|
526
|
+
// With no UI password configured this is "theater": whoever can reach the
|
|
527
|
+
// loopback surface IS the trusted user, and authorize redirects at once.
|
|
528
|
+
// With a password set (required before any non-loopback exposure), the
|
|
529
|
+
// authorize step becomes a real login form.
|
|
530
|
+
if (pathname === '/api/v1/apps' && req.method === 'POST') {
|
|
531
|
+
const body = await readBody(req);
|
|
532
|
+
const redirectUris = parseRedirects(body.redirect_uris);
|
|
533
|
+
const app = this.registerApp({ name: body.client_name, website: body.website,
|
|
534
|
+
redirectUris, scopes: body.scopes });
|
|
535
|
+
return send(200, {
|
|
536
|
+
id: app.clientId, name: app.name, website: app.website,
|
|
537
|
+
client_id: app.clientId, client_secret: app.clientSecret,
|
|
538
|
+
redirect_uri: redirectUris.join(' ') || 'urn:ietf:wg:oauth:2.0:oob',
|
|
539
|
+
vapid_key: this.push.publicKey(),
|
|
540
|
+
});
|
|
541
|
+
}
|
|
542
|
+
if (pathname === '/oauth/authorize' && (req.method === 'GET' || req.method === 'POST')) {
|
|
543
|
+
const pw = this.store.getConfig()?.uiPassword; // { saltHex, hashHex } scrypt record
|
|
544
|
+
let params = url.searchParams;
|
|
545
|
+
let body = null;
|
|
546
|
+
if (req.method === 'POST') { body = await readBody(req); params = new URLSearchParams(body); }
|
|
547
|
+
const redirect = params.get('redirect_uri') || '';
|
|
548
|
+
const app = this.findApp(params.get('client_id') || '');
|
|
549
|
+
// A REGISTERED client is always the third-party flow — its code is
|
|
550
|
+
// bound to it and exchanged with its secret — even when its redirect
|
|
551
|
+
// points back at this very agent (a web client served from our own
|
|
552
|
+
// origin registers itself exactly like a phone app does). The local
|
|
553
|
+
// code-is-the-token flow is only for the built-in client, which never
|
|
554
|
+
// registers.
|
|
555
|
+
const external = !!app;
|
|
556
|
+
const client = { name: app?.name || null, redirect, scope: params.get('scope') || 'read' };
|
|
557
|
+
if (external) {
|
|
558
|
+
if (!app.redirectUris.includes(redirect)) {
|
|
559
|
+
this.log(`authorize refused: redirect_uri "${redirect}" not registered for ${app.clientId}`);
|
|
560
|
+
return send(400, { error: 'redirect_uri was not registered by this client' });
|
|
561
|
+
}
|
|
562
|
+
} else if (!this.redirectAllowed(redirect)) {
|
|
563
|
+
this.log(`authorize refused: redirect_uri "${redirect}" is not this agent`);
|
|
564
|
+
return send(400, { error: 'redirect_uri must be an address of this agent' });
|
|
565
|
+
}
|
|
566
|
+
if (req.method === 'POST') {
|
|
567
|
+
if (this.rateLimited()) {
|
|
568
|
+
this.log('authorize rate limited');
|
|
569
|
+
return sendLoginForm(res, params, 'too many attempts — wait a minute', client);
|
|
570
|
+
}
|
|
571
|
+
if (!pw || !checkPassword(pw, body.password || '')) {
|
|
572
|
+
return sendLoginForm(res, params, 'wrong password — try again', client);
|
|
573
|
+
}
|
|
574
|
+
} else if (pw) {
|
|
575
|
+
// The login/approve screen names what is asking before the owner types
|
|
576
|
+
// the password — a cross-site client cannot forge past that.
|
|
577
|
+
return sendLoginForm(res, params, '', client);
|
|
578
|
+
} else if (this.allowed && !this.allowed.isLocalRequest(req)) {
|
|
579
|
+
// No password set, and this request did not come from this machine.
|
|
580
|
+
//
|
|
581
|
+
// The instant-authorize path is honest theatre on loopback: whoever can
|
|
582
|
+
// reach it IS the trusted user. It stops being theatre the moment an
|
|
583
|
+
// operator takes the documented AP_ALLOWED_HOSTS route and puts the
|
|
584
|
+
// agent on a tailnet name or behind a reverse proxy, because anyone who
|
|
585
|
+
// reaches that name is then handed a 90-day bearer for the whole facade
|
|
586
|
+
// — update_credentials included, which is precisely the authority the
|
|
587
|
+
// isLocal check on /setup and /config exists to withhold. /oauth is
|
|
588
|
+
// dispatched before that check ever runs, so it needs its own.
|
|
589
|
+
this.log(`authorize refused: no UI password, and "${req.headers.host}" is not this machine`);
|
|
590
|
+
return send(403, {
|
|
591
|
+
error: 'this agent answers on an address outside this machine and has no password set — '
|
|
592
|
+
+ (this.embedded
|
|
593
|
+
? 'POST {"password":"…"} to the owner door\'s /config with its door secret before logging in'
|
|
594
|
+
: 'run `fedipod passwd` before logging in over that address'),
|
|
595
|
+
});
|
|
596
|
+
}
|
|
597
|
+
// External clients get a bound code; the local flow keeps code==token.
|
|
598
|
+
const code = external
|
|
599
|
+
? this.mintCode({ clientId: app.clientId, redirectUri: redirect, scope: client.scope })
|
|
600
|
+
: this.mintToken();
|
|
601
|
+
if (!redirect || redirect === 'urn:ietf:wg:oauth:2.0:oob') return send(200, { code });
|
|
602
|
+
const target = new URL(redirect);
|
|
603
|
+
target.searchParams.set('code', code);
|
|
604
|
+
if (params.get('state')) target.searchParams.set('state', params.get('state'));
|
|
605
|
+
res.writeHead(302, { location: target.href });
|
|
606
|
+
res.end();
|
|
607
|
+
return true;
|
|
608
|
+
}
|
|
609
|
+
if (pathname === '/oauth/token' && req.method === 'POST') {
|
|
610
|
+
const body = await readBody(req);
|
|
611
|
+
// A registered third-party client exchanges its bound code, proving its
|
|
612
|
+
// secret, for a real bearer — the code alone is not a token.
|
|
613
|
+
const app = this.findApp(body.client_id || '');
|
|
614
|
+
if (app && body.client_secret) {
|
|
615
|
+
const given = Buffer.from(String(body.client_secret));
|
|
616
|
+
const known = Buffer.from(app.clientSecret);
|
|
617
|
+
const okSecret = given.length === known.length && crypto.timingSafeEqual(given, known);
|
|
618
|
+
if (!okSecret) { this.log('token refused: client secret mismatch'); return send(401, { error: 'invalid_client' }); }
|
|
619
|
+
const rec = this.consumeCode(body.code || '');
|
|
620
|
+
if (!rec || rec.clientId !== app.clientId || (body.redirect_uri && rec.redirectUri !== body.redirect_uri)) {
|
|
621
|
+
this.log('token refused: code is not a live authorization for this client');
|
|
622
|
+
return send(400, { error: 'invalid_grant' });
|
|
623
|
+
}
|
|
624
|
+
return send(200, { access_token: this.mintToken(), token_type: 'Bearer',
|
|
625
|
+
scope: rec.scope || 'read', created_at: Math.floor(Date.now() / 1000) });
|
|
626
|
+
}
|
|
627
|
+
// Local flow: the code IS the token, minted by /oauth/authorize after the
|
|
628
|
+
// password gate. Minting one here for an unrecognised code handed a
|
|
629
|
+
// bearer to anyone who could reach the port — and a non-browser client
|
|
630
|
+
// sends no Origin, so the firewall never saw it. That defeated `passwd`
|
|
631
|
+
// on any agent deliberately exposed through AP_ALLOWED_HOSTS.
|
|
632
|
+
if (!body.code || !this.tokens().includes(body.code)) {
|
|
633
|
+
this.log('token refused: code is not a live authorization');
|
|
634
|
+
return send(400, { error: 'invalid_grant' });
|
|
635
|
+
}
|
|
636
|
+
return send(200, { access_token: body.code, token_type: 'Bearer', scope: body.scope || 'read write follow push', created_at: Math.floor(Date.now() / 1000) });
|
|
637
|
+
}
|
|
638
|
+
if (pathname === '/oauth/revoke' && req.method === 'POST') {
|
|
639
|
+
// It used to answer 200 and keep the token, so logging out of a client
|
|
640
|
+
// left a working 90-day bearer behind. Mastodon's endpoint takes `token`;
|
|
641
|
+
// an unknown one is still a 200, which is what the spec asks for.
|
|
642
|
+
const body = await readBody(req).catch(() => ({}));
|
|
643
|
+
const gone = body?.token;
|
|
644
|
+
if (gone) {
|
|
645
|
+
const kept = this.tokenRecords().filter(r => r.token !== gone);
|
|
646
|
+
this.store.write('masto-tokens.json', kept);
|
|
647
|
+
this.log('client token revoked');
|
|
648
|
+
}
|
|
649
|
+
return send(200, {});
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
if (!pathname.startsWith('/api/')) return false;
|
|
653
|
+
|
|
654
|
+
// --- instance (public) ---
|
|
655
|
+
if (pathname === '/api/v1/instance') {
|
|
656
|
+
return send(200, {
|
|
657
|
+
uri: this.host, title: this.instanceTitle(), short_description: this.instanceBlurb(),
|
|
658
|
+
description: this.instanceBlurb(), email: '', version: '4.2.0 (compatible; fedipod)',
|
|
659
|
+
urls: { streaming_api: this.streamingUrl(req) },
|
|
660
|
+
stats: { user_count: 1, status_count: this.store.countStatuses(), domain_count: 1 },
|
|
661
|
+
languages: ['en'], registrations: false, approval_required: false, invites_enabled: false,
|
|
662
|
+
configuration: instanceConfig(),
|
|
663
|
+
contact_account: null, rules: [],
|
|
664
|
+
});
|
|
665
|
+
}
|
|
666
|
+
if (pathname === '/api/v2/instance') {
|
|
667
|
+
return send(200, {
|
|
668
|
+
domain: this.host, title: this.instanceTitle(), version: '4.2.0 (compatible; fedipod)',
|
|
669
|
+
source_url: 'https://github.com/jeff-zucker/FediPod', description: this.instanceBlurb(),
|
|
670
|
+
usage: { users: { active_month: 1 } },
|
|
671
|
+
thumbnail: { url: TRANSPARENT_PNG },
|
|
672
|
+
languages: ['en'],
|
|
673
|
+
// Both spellings: v2 clients read configuration.urls.streaming, older
|
|
674
|
+
// ones the top-level urls.streaming_api, and some fall back blindly.
|
|
675
|
+
urls: { streaming_api: this.streamingUrl(req) },
|
|
676
|
+
configuration: {
|
|
677
|
+
...instanceConfig(),
|
|
678
|
+
urls: { streaming: this.streamingUrl(req) },
|
|
679
|
+
vapid: { public_key: this.push.publicKey() },
|
|
680
|
+
},
|
|
681
|
+
registrations: { enabled: false, approval_required: false, message: null },
|
|
682
|
+
contact: { email: '', account: null }, rules: [],
|
|
683
|
+
});
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
const stub = STUBS.get(pathname);
|
|
687
|
+
if (stub !== undefined && req.method === 'GET') return send(200, stub);
|
|
688
|
+
|
|
689
|
+
// --- everything below needs a bearer token + a configured agent ---
|
|
690
|
+
if (!this.authed(req)) return send(401, { error: 'The access token is invalid' });
|
|
691
|
+
if (!this.agent.configured()) return send(503, { error: 'agent not configured' });
|
|
692
|
+
// A viewer-mode agent (another agent holds the drain lease) may not act —
|
|
693
|
+
// but a user acting HERE outranks the idle active agent elsewhere, so a
|
|
694
|
+
// write attempt claims the lease and proceeds. Only a failed claim 503s.
|
|
695
|
+
if (this.agent.viewer && req.method !== 'GET' && req.method !== 'HEAD') {
|
|
696
|
+
const took = await this.agent.requestTakeover?.();
|
|
697
|
+
if (!took) return send(503, { error: 'another agent is active for this pod — takeover failed, try again' });
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
if (pathname === '/api/v1/accounts/verify_credentials') {
|
|
701
|
+
const cfg0 = this.store.getConfig() || {};
|
|
702
|
+
return send(200, {
|
|
703
|
+
...this.selfAccount(),
|
|
704
|
+
// `source` is what the editor fills its inputs from: the raw text it
|
|
705
|
+
// will send back, not the HTML the profile renders.
|
|
706
|
+
source: {
|
|
707
|
+
privacy: 'public', sensitive: false, language: 'en',
|
|
708
|
+
note: cfg0.summary || '',
|
|
709
|
+
fields: (cfg0.fields || []).map(f => ({ name: f.name, value: f.value })),
|
|
710
|
+
},
|
|
711
|
+
});
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
// The profile editor. Everything it can send is carried: the name and bio,
|
|
715
|
+
// both pictures, and the extra fields. An avatar or header arrives as file
|
|
716
|
+
// bytes, so it goes to the pod's media container first and the actor gets
|
|
717
|
+
// the URL — the same path a posted attachment takes.
|
|
718
|
+
if (pathname === '/api/v1/accounts/update_credentials') {
|
|
719
|
+
if (req.method !== 'PATCH' && req.method !== 'POST') return send(405, { error: 'PATCH expected' });
|
|
720
|
+
const ct = String(req.headers['content-type'] || '');
|
|
721
|
+
// readBody already covers JSON and urlencoded; only the file case differs.
|
|
722
|
+
let form = {}, files = {};
|
|
723
|
+
if (ct.includes('multipart/form-data')) ({ fields: form, files } = await readMultipart(req));
|
|
724
|
+
else form = await readBody(req);
|
|
725
|
+
|
|
726
|
+
const cfg = { ...this.store.getConfig() };
|
|
727
|
+
const putImage = async (f) => {
|
|
728
|
+
const ext = (f.filename || '').includes('.')
|
|
729
|
+
? f.filename.split('.').pop().replace(/[^\w]/g, '') : 'bin';
|
|
730
|
+
const slug = new Date().toISOString().slice(0, 10) + '-' + crypto.randomBytes(4).toString('hex') + '.' + ext;
|
|
731
|
+
const url = this.urls.media + slug;
|
|
732
|
+
await this.agent.publisher.ensureMediaContainer();
|
|
733
|
+
await this.agent.remote.put(url, f.data, f.contentType);
|
|
734
|
+
return url;
|
|
735
|
+
};
|
|
736
|
+
|
|
737
|
+
if ('display_name' in form) cfg.name = String(form.display_name).trim() || cfg.handle;
|
|
738
|
+
if ('note' in form) cfg.summary = String(form.note) || undefined;
|
|
739
|
+
if ('locked' in form) cfg.approveJoins = form.locked === 'true' || form.locked === true;
|
|
740
|
+
if (files.avatar?.data?.length) cfg.icon = await putImage(files.avatar);
|
|
741
|
+
if (files.header?.data?.length) cfg.image = await putImage(files.header);
|
|
742
|
+
|
|
743
|
+
// fields_attributes arrives as fields_attributes[0][name] etc. A row with
|
|
744
|
+
// no name is how the editor says "delete this one", so it is dropped.
|
|
745
|
+
const rows = [];
|
|
746
|
+
for (const [k, v] of Object.entries(form)) {
|
|
747
|
+
const m = /^fields_attributes\[(\d+)\]\[(name|value)\]$/.exec(k);
|
|
748
|
+
if (!m) continue;
|
|
749
|
+
(rows[Number(m[1])] ||= {})[m[2]] = String(v);
|
|
750
|
+
}
|
|
751
|
+
if (rows.length) cfg.fields = rows.filter(r => r && r.name?.trim())
|
|
752
|
+
.map(r => ({ name: r.name.trim(), value: (r.value || '').trim() }));
|
|
753
|
+
|
|
754
|
+
this.store.setConfig(cfg);
|
|
755
|
+
Object.assign(this.agent.publisher.config, {
|
|
756
|
+
name: cfg.name, summary: cfg.summary, icon: cfg.icon, image: cfg.image,
|
|
757
|
+
fields: cfg.fields, approveJoins: !!cfg.approveJoins,
|
|
758
|
+
});
|
|
759
|
+
await this.store.flush();
|
|
760
|
+
// publishProfile says whether the world can actually read the actor it
|
|
761
|
+
// just wrote. Discarding that reported success for a save that left the
|
|
762
|
+
// account undiscoverable — the one outcome the caller needed to hear.
|
|
763
|
+
const published = await this.agent.publisher.publishProfile();
|
|
764
|
+
const unreachable = published?.unreachable;
|
|
765
|
+
if (unreachable?.length) {
|
|
766
|
+
this.log(`profile saved but NOT publicly readable: ${unreachable.join(', ')}`);
|
|
767
|
+
}
|
|
768
|
+
this.log(`profile updated from a client: ${Object.keys(form).join(', ') || '(files only)'}`);
|
|
769
|
+
return send(200, this.selfAccount());
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
// home + public = everything known (follows, boosts, tag feed, own);
|
|
773
|
+
// public?local=true = own posts; trends/statuses = the same activity (a
|
|
774
|
+
// single-actor instance has no firehose — what it knows IS its public
|
|
775
|
+
// face). Sorted by publish time so tag-feed backfill interleaves.
|
|
776
|
+
if (pathname === '/api/v1/timelines/home' || pathname === '/api/v1/timelines/public'
|
|
777
|
+
|| pathname === '/api/v1/trends/statuses') {
|
|
778
|
+
const localOnly = pathname === '/api/v1/timelines/public' && url.searchParams.get('local') === 'true';
|
|
779
|
+
// The UNFILTERED list, both as the source and as the reply-count corpus:
|
|
780
|
+
// a reply can be a mention, and mentions are filtered out of timelines
|
|
781
|
+
// below — counting against the filtered set would undercount them.
|
|
782
|
+
const all = this.store.getStatuses();
|
|
783
|
+
const muted = new Set(this.store.getMuted().actors);
|
|
784
|
+
let items = all
|
|
785
|
+
// search ingests and strangers' mentions stay out of the timelines
|
|
786
|
+
// (mentions remain in notifications and /api/v1/statuses); direct
|
|
787
|
+
// posts belong to the conversations view, not a timeline; and the
|
|
788
|
+
// local/public view shows only what is actually public-facing.
|
|
789
|
+
.filter(s => s.kind !== 'remote' && s.kind !== 'mention')
|
|
790
|
+
.filter(s => !s.direct && s.visibility !== 'direct')
|
|
791
|
+
.filter(s => !muted.has(s.actor) && !muted.has(s.via))
|
|
792
|
+
.filter(s => !localOnly || (s.kind === 'post' && s.visibility !== 'private'))
|
|
793
|
+
.sort((a, b) => String(b.published || '').localeCompare(String(a.published || '')));
|
|
794
|
+
const { items: page, headers } = this.page(items, url);
|
|
795
|
+
return send(200, page.map(s => this.statusOrBoost(s, { all })), headers);
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
if (pathname === '/api/v1/statuses' && req.method === 'POST') {
|
|
799
|
+
const body = await readBody(req);
|
|
800
|
+
if (!body.status) return send(422, { error: 'status text required' });
|
|
801
|
+
const visibility = body.visibility || 'public';
|
|
802
|
+
if (!['public', 'unlisted', 'private', 'direct'].includes(visibility)) {
|
|
803
|
+
return send(422, { error: `unknown visibility "${visibility}"` });
|
|
804
|
+
}
|
|
805
|
+
const inReplyTo = body.in_reply_to_id ? this.store.urlFor(body.in_reply_to_id) : undefined;
|
|
806
|
+
// A reply to a mirrored Bluesky post goes out as a native Bluesky reply
|
|
807
|
+
// — the parent is not an AP object, so there is no AP note to make.
|
|
808
|
+
const bskyParent = inReplyTo && this.store.getStatuses().find(x => x.noteId === inReplyTo && x.kind === 'bsky');
|
|
809
|
+
if (bskyParent) return this.bskyReply(send, body, bskyParent, visibility);
|
|
810
|
+
// Private and direct posts live in an owner-only pod container — and
|
|
811
|
+
// only on a pod that provably enforces it.
|
|
812
|
+
if (visibility === 'private' || visibility === 'direct') {
|
|
813
|
+
const ready = await this.agent.publisher.privateReady();
|
|
814
|
+
if (ready !== true) return send(422, { error: ready });
|
|
815
|
+
}
|
|
816
|
+
const spoilerText = String(body.spoiler_text || '').trim() || null;
|
|
817
|
+
const mediaIds = [].concat(body.media_ids || body['media_ids[]'] || []).filter(Boolean);
|
|
818
|
+
const media = this.store.getMedia();
|
|
819
|
+
const attachments = mediaIds.map(id => media[id] && { id, ...media[id] }).filter(Boolean);
|
|
820
|
+
if (body.scheduled_at) {
|
|
821
|
+
const at = Date.parse(body.scheduled_at);
|
|
822
|
+
if (!Number.isFinite(at) || at < Date.now() + 60_000) {
|
|
823
|
+
return send(422, { error: 'scheduled_at must be at least a minute from now' });
|
|
824
|
+
}
|
|
825
|
+
const sched = this.store.getScheduled();
|
|
826
|
+
const entry = {
|
|
827
|
+
id: crypto.randomBytes(8).toString('hex'), scheduledAt: new Date(at).toISOString(),
|
|
828
|
+
params: { status: body.status, visibility, spoilerText, inReplyTo, attachments },
|
|
829
|
+
};
|
|
830
|
+
sched.push(entry);
|
|
831
|
+
this.store.setScheduled(sched);
|
|
832
|
+
return send(200, this.scheduledJson(entry));
|
|
833
|
+
}
|
|
834
|
+
const note = await this.agent.publisher.publishNote(body.status,
|
|
835
|
+
{ inReplyTo, attachments, visibility, spoilerText });
|
|
836
|
+
const s = this.store.getStatuses().find(x => x.noteId === note.id);
|
|
837
|
+
return send(200, this.status(s));
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
// The composer reads the raw text back before an edit.
|
|
841
|
+
const mSource = /^\/api\/v1\/statuses\/([a-f0-9]+)\/source$/.exec(pathname);
|
|
842
|
+
if (mSource && req.method === 'GET') {
|
|
843
|
+
const noteUrl = this.store.urlFor(mSource[1]);
|
|
844
|
+
const s = noteUrl && this.store.getStatuses().find(x => x.noteId === noteUrl);
|
|
845
|
+
if (!s) return send(404, { error: 'Record not found' });
|
|
846
|
+
return send(200, { id: mSource[1], text: s.text ?? htmlToText(s.content || ''), spoiler_text: s.spoiler || '' });
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
// One entry — the current version. Enough for the client's history view;
|
|
850
|
+
// past versions are not kept.
|
|
851
|
+
const mHistory = /^\/api\/v1\/statuses\/([a-f0-9]+)\/history$/.exec(pathname);
|
|
852
|
+
if (mHistory && req.method === 'GET') {
|
|
853
|
+
const noteUrl = this.store.urlFor(mHistory[1]);
|
|
854
|
+
const s = noteUrl && this.store.getStatuses().find(x => x.noteId === noteUrl);
|
|
855
|
+
if (!s) return send(404, { error: 'Record not found' });
|
|
856
|
+
return send(200, [{
|
|
857
|
+
content: s.content || '', spoiler_text: s.spoiler || '', sensitive: !!s.spoiler,
|
|
858
|
+
created_at: s.editedAt || s.published, account: this.account(s.actor),
|
|
859
|
+
media_attachments: (s.attachments || []).map(a => this.mediaJson(a)),
|
|
860
|
+
emojis: [], poll: null,
|
|
861
|
+
}]);
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
const mStatus = /^\/api\/v1\/statuses\/([a-f0-9]+)$/.exec(pathname);
|
|
865
|
+
if (mStatus && req.method === 'GET') {
|
|
866
|
+
const { s, wrapped } = this.lookup(mStatus[1]);
|
|
867
|
+
if (!s) return send(404, { error: 'Record not found' });
|
|
868
|
+
return send(200, wrapped ? this.statusOrBoost(s) : this.status(s));
|
|
869
|
+
}
|
|
870
|
+
if (mStatus && req.method === 'PUT') {
|
|
871
|
+
const noteUrl = this.store.urlFor(mStatus[1]);
|
|
872
|
+
const s = noteUrl && this.store.getStatuses().find(x => x.noteId === noteUrl);
|
|
873
|
+
if (!s) return send(404, { error: 'Record not found' });
|
|
874
|
+
if (s.actor !== this.urls.actor) return send(403, { error: 'not your status' });
|
|
875
|
+
const body = await readBody(req);
|
|
876
|
+
if (!body.status) return send(422, { error: 'status text required' });
|
|
877
|
+
const mediaIds = [].concat(body.media_ids || body['media_ids[]'] || []).filter(Boolean);
|
|
878
|
+
const media = this.store.getMedia();
|
|
879
|
+
const attachments = mediaIds.length
|
|
880
|
+
? mediaIds.map(id => media[id] && { id, ...media[id] }).filter(Boolean)
|
|
881
|
+
: null; // null: keep what the post has
|
|
882
|
+
const spoilerText = String(body.spoiler_text || '').trim() || null;
|
|
883
|
+
const patched = await this.agent.publisher.updateNote(s,
|
|
884
|
+
{ content: body.status, spoilerText, attachments });
|
|
885
|
+
return send(200, this.status(patched || s));
|
|
886
|
+
}
|
|
887
|
+
if (mStatus && req.method === 'DELETE') {
|
|
888
|
+
const noteUrl = this.store.urlFor(mStatus[1]);
|
|
889
|
+
const s = noteUrl && this.store.getStatuses().find(x => x.noteId === noteUrl);
|
|
890
|
+
if (!s) return send(404, { error: 'Record not found' });
|
|
891
|
+
if (s.actor !== this.urls.actor) return send(403, { error: 'not your status' });
|
|
892
|
+
const rendered = this.status(s);
|
|
893
|
+
// 502, because the refusal is the pod's: the client asked correctly and
|
|
894
|
+
// the post is still up. Reporting 200 here is what let a deleted post
|
|
895
|
+
// stay publicly readable with nothing to show for it.
|
|
896
|
+
const gone = await social.deleteNote(this.agent, s);
|
|
897
|
+
if (!gone.ok) return send(502, { error: gone.error });
|
|
898
|
+
return send(200, { ...rendered, text: s.content || '' });
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
// Threads from the mirror's inReplyTo chains.
|
|
902
|
+
const mContext = /^\/api\/v1\/statuses\/([a-f0-9]+)\/context$/.exec(pathname);
|
|
903
|
+
if (mContext) {
|
|
904
|
+
const noteUrl = this.lookup(mContext[1]).s?.noteId;
|
|
905
|
+
const all = this.store.getStatuses();
|
|
906
|
+
const byId = new Map(all.map(s => [s.noteId, s]));
|
|
907
|
+
const ancestors = [];
|
|
908
|
+
let cur = noteUrl && byId.get(noteUrl)?.inReplyTo;
|
|
909
|
+
while (cur && byId.has(cur) && ancestors.length < 40) {
|
|
910
|
+
const s = byId.get(cur);
|
|
911
|
+
ancestors.unshift(s);
|
|
912
|
+
cur = s.inReplyTo;
|
|
913
|
+
}
|
|
914
|
+
const descendants = [];
|
|
915
|
+
const queue = noteUrl ? [noteUrl] : [];
|
|
916
|
+
while (queue.length && descendants.length < 60) {
|
|
917
|
+
const id = queue.shift();
|
|
918
|
+
for (const s of all) if (s.inReplyTo === id) { descendants.push(s); queue.push(s.noteId); }
|
|
919
|
+
}
|
|
920
|
+
return send(200, {
|
|
921
|
+
ancestors: ancestors.map(s => this.status(s, { all })),
|
|
922
|
+
descendants: descendants.map(s => this.status(s, { all })),
|
|
923
|
+
});
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
// Who reacted: only what our own store witnessed, so the lists are the
|
|
927
|
+
// reactions we were notified of, not the whole fediverse's count.
|
|
928
|
+
const mWho = /^\/api\/v1\/statuses\/([a-f0-9]+)\/(reblogged_by|favourited_by)$/.exec(pathname);
|
|
929
|
+
if (mWho && req.method === 'GET') {
|
|
930
|
+
const noteUrl = this.store.urlFor(mWho[1]);
|
|
931
|
+
if (!noteUrl) return send(404, { error: 'Record not found' });
|
|
932
|
+
const type = mWho[2] === 'reblogged_by' ? 'reblog' : 'favourite';
|
|
933
|
+
const actors = [...new Set(this.store.getNotifications()
|
|
934
|
+
.filter(n => n.noteId === noteUrl && n.type === type).map(n => n.actor))];
|
|
935
|
+
return send(200, actors.map(a => this.account(a)));
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
// Pinning republishes the featured collection, so a visitor's server
|
|
939
|
+
// shows the pins too; the actor document names the collection.
|
|
940
|
+
const mPin = /^\/api\/v1\/statuses\/([a-f0-9]+)\/(pin|unpin)$/.exec(pathname);
|
|
941
|
+
if (mPin && req.method === 'POST') {
|
|
942
|
+
const noteUrl = this.store.urlFor(mPin[1]);
|
|
943
|
+
const s = noteUrl && this.store.getStatuses().find(x => x.noteId === noteUrl);
|
|
944
|
+
if (!s) return send(404, { error: 'Record not found' });
|
|
945
|
+
if (s.actor !== this.urls.actor) return send(403, { error: 'not your status' });
|
|
946
|
+
const updated = await social.pinStatus(this.agent, s, mPin[2] === 'pin');
|
|
947
|
+
return send(200, this.status(updated));
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
if (pathname === '/api/v1/reports' && req.method === 'POST') {
|
|
951
|
+
return send(422, {
|
|
952
|
+
error: 'this is your own single-user server, so there is no moderation team to receive a report. Blocking the account is the action that takes effect here.',
|
|
953
|
+
});
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
// Bookmarks are this machine's own list: nothing federates, nothing is
|
|
957
|
+
// told, which is what a bookmark means everywhere.
|
|
958
|
+
const mBookmark = /^\/api\/v1\/statuses\/([a-f0-9]+)\/(bookmark|unbookmark)$/.exec(pathname);
|
|
959
|
+
if (mBookmark && req.method === 'POST') {
|
|
960
|
+
const noteUrl = this.store.urlFor(mBookmark[1]);
|
|
961
|
+
const s = noteUrl && this.store.getStatuses().find(x => x.noteId === noteUrl);
|
|
962
|
+
if (!s) return send(404, { error: 'Record not found' });
|
|
963
|
+
const updated = this.store.updateStatus(s.noteId, { bookmarked: mBookmark[2] === 'bookmark' });
|
|
964
|
+
return send(200, this.status(updated || s));
|
|
965
|
+
}
|
|
966
|
+
if (pathname === '/api/v1/bookmarks' || pathname === '/api/v1/favourites') {
|
|
967
|
+
const key = pathname.endsWith('bookmarks') ? 'bookmarked' : 'favourited';
|
|
968
|
+
const all = this.store.getStatuses();
|
|
969
|
+
const items = all.filter(s => s[key])
|
|
970
|
+
.sort((a, b) => String(b.published || '').localeCompare(String(a.published || '')));
|
|
971
|
+
const { items: page, headers } = this.page(items, url);
|
|
972
|
+
return send(200, page.map(s => this.status(s, { all })), headers);
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
// Lists, filters and scheduled posts are the client's own arrangements —
|
|
976
|
+
// kept in local state, nothing federates.
|
|
977
|
+
const listJson = (l) => ({ id: l.id, title: l.title, replies_policy: l.repliesPolicy || 'list', exclusive: false });
|
|
978
|
+
if (pathname === '/api/v1/lists' && req.method === 'GET') {
|
|
979
|
+
return send(200, this.store.getLists().map(listJson));
|
|
980
|
+
}
|
|
981
|
+
if (pathname === '/api/v1/lists' && req.method === 'POST') {
|
|
982
|
+
const body = await readBody(req);
|
|
983
|
+
const title = String(body.title || '').trim();
|
|
984
|
+
if (!title) return send(422, { error: 'a title is required' });
|
|
985
|
+
const lists = this.store.getLists();
|
|
986
|
+
const l = { id: crypto.randomBytes(8).toString('hex'), title, repliesPolicy: body.replies_policy || 'list', members: [] };
|
|
987
|
+
lists.push(l);
|
|
988
|
+
this.store.setLists(lists);
|
|
989
|
+
return send(200, listJson(l));
|
|
990
|
+
}
|
|
991
|
+
const mList = /^\/api\/v1\/lists\/([a-f0-9]+)$/.exec(pathname);
|
|
992
|
+
if (mList) {
|
|
993
|
+
const lists = this.store.getLists();
|
|
994
|
+
const l = lists.find(x => x.id === mList[1]);
|
|
995
|
+
if (!l) return send(404, { error: 'Record not found' });
|
|
996
|
+
if (req.method === 'DELETE') {
|
|
997
|
+
this.store.setLists(lists.filter(x => x.id !== mList[1]));
|
|
998
|
+
return send(200, {});
|
|
999
|
+
}
|
|
1000
|
+
if (req.method === 'PUT') {
|
|
1001
|
+
const body = await readBody(req);
|
|
1002
|
+
if (body.title) l.title = String(body.title);
|
|
1003
|
+
if (body.replies_policy) l.repliesPolicy = body.replies_policy;
|
|
1004
|
+
this.store.setLists(lists);
|
|
1005
|
+
}
|
|
1006
|
+
return send(200, listJson(l));
|
|
1007
|
+
}
|
|
1008
|
+
const mListAcc = /^\/api\/v1\/lists\/([a-f0-9]+)\/accounts$/.exec(pathname);
|
|
1009
|
+
if (mListAcc) {
|
|
1010
|
+
const lists = this.store.getLists();
|
|
1011
|
+
const l = lists.find(x => x.id === mListAcc[1]);
|
|
1012
|
+
if (!l) return send(404, { error: 'Record not found' });
|
|
1013
|
+
if (req.method === 'GET') return send(200, (l.members || []).map(a => this.account(a)));
|
|
1014
|
+
const body = await readBody(req).catch(() => ({}));
|
|
1015
|
+
const ids = [].concat(body.account_ids || body['account_ids[]'] || url.searchParams.getAll('account_ids[]')).filter(Boolean);
|
|
1016
|
+
const actors = ids.map(id => this.store.urlFor(id)).filter(Boolean);
|
|
1017
|
+
if (req.method === 'POST') l.members = [...new Set([...(l.members || []), ...actors])];
|
|
1018
|
+
if (req.method === 'DELETE') l.members = (l.members || []).filter(a => !actors.includes(a));
|
|
1019
|
+
this.store.setLists(lists);
|
|
1020
|
+
return send(200, {});
|
|
1021
|
+
}
|
|
1022
|
+
const mListTl = /^\/api\/v1\/timelines\/list\/([a-f0-9]+)$/.exec(pathname);
|
|
1023
|
+
if (mListTl) {
|
|
1024
|
+
const l = this.store.getLists().find(x => x.id === mListTl[1]);
|
|
1025
|
+
if (!l) return send(404, { error: 'Record not found' });
|
|
1026
|
+
const members = new Set(l.members || []);
|
|
1027
|
+
const all = this.store.getStatuses();
|
|
1028
|
+
const items = all.filter(s => members.has(s.actor) || members.has(s.via))
|
|
1029
|
+
.sort((a, b) => String(b.published || '').localeCompare(String(a.published || '')));
|
|
1030
|
+
const { items: page, headers } = this.page(items, url);
|
|
1031
|
+
return send(200, page.map(s => this.statusOrBoost(s, { all })), headers);
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
// v2 filters: stored and served; the client applies them to what it shows.
|
|
1035
|
+
const filterJson = (f) => ({
|
|
1036
|
+
id: f.id, title: f.title, context: f.context || ['home'],
|
|
1037
|
+
expires_at: f.expiresAt || null, filter_action: f.action || 'warn',
|
|
1038
|
+
keywords: (f.keywords || []).map((k, i) => ({ id: `${f.id}-${i}`, keyword: k.keyword, whole_word: !!k.wholeWord })),
|
|
1039
|
+
statuses: [],
|
|
1040
|
+
});
|
|
1041
|
+
const keywordsOf = (attrs) => [].concat(attrs || [])
|
|
1042
|
+
.filter(k => k?.keyword && !(k._destroy === true || k._destroy === 'true'))
|
|
1043
|
+
.map(k => ({ keyword: String(k.keyword), wholeWord: k.whole_word === true || k.whole_word === 'true' }));
|
|
1044
|
+
if (pathname === '/api/v2/filters' && req.method === 'GET') {
|
|
1045
|
+
return send(200, this.store.getFilters().map(filterJson));
|
|
1046
|
+
}
|
|
1047
|
+
if (pathname === '/api/v2/filters' && req.method === 'POST') {
|
|
1048
|
+
const body = await readBody(req);
|
|
1049
|
+
const title = String(body.title || '').trim();
|
|
1050
|
+
if (!title) return send(422, { error: 'a title is required' });
|
|
1051
|
+
const filters = this.store.getFilters();
|
|
1052
|
+
const f = {
|
|
1053
|
+
id: crypto.randomBytes(8).toString('hex'), title,
|
|
1054
|
+
context: [].concat(body.context || ['home']),
|
|
1055
|
+
action: body.filter_action || 'warn', expiresAt: null,
|
|
1056
|
+
keywords: keywordsOf(body.keywords_attributes),
|
|
1057
|
+
};
|
|
1058
|
+
filters.push(f);
|
|
1059
|
+
this.store.setFilters(filters);
|
|
1060
|
+
return send(200, filterJson(f));
|
|
1061
|
+
}
|
|
1062
|
+
const mFilter = /^\/api\/v2\/filters\/([a-f0-9]+)$/.exec(pathname);
|
|
1063
|
+
if (mFilter) {
|
|
1064
|
+
const filters = this.store.getFilters();
|
|
1065
|
+
const f = filters.find(x => x.id === mFilter[1]);
|
|
1066
|
+
if (!f) return send(404, { error: 'Record not found' });
|
|
1067
|
+
if (req.method === 'DELETE') {
|
|
1068
|
+
this.store.setFilters(filters.filter(x => x.id !== mFilter[1]));
|
|
1069
|
+
return send(200, {});
|
|
1070
|
+
}
|
|
1071
|
+
if (req.method === 'PUT') {
|
|
1072
|
+
const body = await readBody(req);
|
|
1073
|
+
if (body.title) f.title = String(body.title);
|
|
1074
|
+
if (body.context) f.context = [].concat(body.context);
|
|
1075
|
+
if (body.filter_action) f.action = body.filter_action;
|
|
1076
|
+
if (body.keywords_attributes) f.keywords = keywordsOf(body.keywords_attributes);
|
|
1077
|
+
this.store.setFilters(filters);
|
|
1078
|
+
}
|
|
1079
|
+
return send(200, filterJson(f));
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
if (pathname === '/api/v1/scheduled_statuses' && req.method === 'GET') {
|
|
1083
|
+
return send(200, this.store.getScheduled().map(e => this.scheduledJson(e)));
|
|
1084
|
+
}
|
|
1085
|
+
const mSched = /^\/api\/v1\/scheduled_statuses\/([a-f0-9]+)$/.exec(pathname);
|
|
1086
|
+
if (mSched) {
|
|
1087
|
+
const sched = this.store.getScheduled();
|
|
1088
|
+
const e = sched.find(x => x.id === mSched[1]);
|
|
1089
|
+
if (!e) return send(404, { error: 'Record not found' });
|
|
1090
|
+
if (req.method === 'DELETE') {
|
|
1091
|
+
this.store.setScheduled(sched.filter(x => x.id !== mSched[1]));
|
|
1092
|
+
return send(200, {});
|
|
1093
|
+
}
|
|
1094
|
+
if (req.method === 'PUT') {
|
|
1095
|
+
const body = await readBody(req);
|
|
1096
|
+
const at = Date.parse(body.scheduled_at || '');
|
|
1097
|
+
if (!Number.isFinite(at)) return send(422, { error: 'scheduled_at required' });
|
|
1098
|
+
e.scheduledAt = new Date(at).toISOString();
|
|
1099
|
+
this.store.setScheduled(sched);
|
|
1100
|
+
}
|
|
1101
|
+
return send(200, this.scheduledJson(e));
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
// Conversations: direct posts, grouped by who is in them.
|
|
1105
|
+
if (pathname === '/api/v1/conversations' && req.method === 'GET') {
|
|
1106
|
+
const all = this.store.getStatuses();
|
|
1107
|
+
const me = this.urls.actor;
|
|
1108
|
+
const convos = new Map();
|
|
1109
|
+
for (const s of all) {
|
|
1110
|
+
if (!(s.direct || s.visibility === 'direct')) continue;
|
|
1111
|
+
const others = [...new Set([
|
|
1112
|
+
...(s.actor !== me ? [s.actor] : []),
|
|
1113
|
+
...((s.mentions || []).map(m => m.href).filter(a => a && a !== me)),
|
|
1114
|
+
])];
|
|
1115
|
+
const key = others.sort().join(' ') || me;
|
|
1116
|
+
const c = convos.get(key) || { accounts: new Set(), last: s };
|
|
1117
|
+
for (const o of others) c.accounts.add(o);
|
|
1118
|
+
if (String(s.published || '') > String(c.last.published || '')) c.last = s;
|
|
1119
|
+
convos.set(key, c);
|
|
1120
|
+
}
|
|
1121
|
+
const items = [...convos.entries()]
|
|
1122
|
+
.sort((a, b) => String(b[1].last.published || '').localeCompare(String(a[1].last.published || '')));
|
|
1123
|
+
return send(200, items.map(([key, c]) => ({
|
|
1124
|
+
id: this.store.idFor('conversation:' + key),
|
|
1125
|
+
unread: false,
|
|
1126
|
+
accounts: [...(c.accounts.size ? c.accounts : [me])].map(a => this.account(a)),
|
|
1127
|
+
last_status: this.status(c.last, { all }),
|
|
1128
|
+
})));
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
const mPoll = /^\/api\/v1\/polls\/([a-f0-9]+)(\/votes)?$/.exec(pathname);
|
|
1132
|
+
if (mPoll) {
|
|
1133
|
+
const noteUrl = this.store.urlFor(mPoll[1]);
|
|
1134
|
+
const s = noteUrl && this.store.getStatuses().find(x => x.noteId === noteUrl);
|
|
1135
|
+
if (!s?.poll) return send(404, { error: 'Record not found' });
|
|
1136
|
+
if (mPoll[2] && req.method === 'POST') {
|
|
1137
|
+
const body = await readBody(req);
|
|
1138
|
+
const choices = [].concat(body.choices || body['choices[]'] || []).map(Number)
|
|
1139
|
+
.filter(Number.isInteger);
|
|
1140
|
+
if (!choices.length) return send(422, { error: 'choices required' });
|
|
1141
|
+
const r = await social.votePoll(this.agent, s, choices);
|
|
1142
|
+
if (!r.ok) return send(422, { error: r.error });
|
|
1143
|
+
return send(200, this.pollJson(this.store.getStatuses().find(x => x.noteId === noteUrl)));
|
|
1144
|
+
}
|
|
1145
|
+
return send(200, this.pollJson(s));
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
const mAction = /^\/api\/v1\/statuses\/([a-f0-9]+)\/(favourite|unfavourite|reblog|unreblog)$/.exec(pathname);
|
|
1149
|
+
if (mAction && req.method === 'POST') {
|
|
1150
|
+
// Acting on a carried row acts on the post itself, as Mastodon does.
|
|
1151
|
+
const { s } = this.lookup(mAction[1]);
|
|
1152
|
+
if (!s) return send(404, { error: 'Record not found' });
|
|
1153
|
+
// A mirrored Bluesky post is not an AP object — a Like or Announce has
|
|
1154
|
+
// nothing to address. The interaction goes to Bluesky instead, as the
|
|
1155
|
+
// native record every Bluesky app writes.
|
|
1156
|
+
if (s.kind === 'bsky') {
|
|
1157
|
+
const at = this.agent.atproto;
|
|
1158
|
+
if (!at?.connected()) return send(422, { error: 'this is a Bluesky post — no Bluesky account is connected to act from' });
|
|
1159
|
+
try {
|
|
1160
|
+
let patch = null;
|
|
1161
|
+
if (mAction[2] === 'favourite' && !s.favourited) {
|
|
1162
|
+
patch = { favourited: true, bskyLike: (await at.like(s.noteId, s.cid || null)).uri };
|
|
1163
|
+
} else if (mAction[2] === 'unfavourite' && s.favourited) {
|
|
1164
|
+
if (s.bskyLike) await at.deleteCrossPost(s.bskyLike);
|
|
1165
|
+
patch = { favourited: false, bskyLike: undefined };
|
|
1166
|
+
} else if (mAction[2] === 'reblog' && !s.reblogged) {
|
|
1167
|
+
patch = { reblogged: true, bskyRepost: (await at.repost(s.noteId, s.cid || null)).uri };
|
|
1168
|
+
} else if (mAction[2] === 'unreblog' && s.reblogged) {
|
|
1169
|
+
if (s.bskyRepost) await at.deleteCrossPost(s.bskyRepost);
|
|
1170
|
+
patch = { reblogged: false, bskyRepost: undefined };
|
|
1171
|
+
}
|
|
1172
|
+
return send(200, this.status(patch ? (this.store.updateStatus(s.noteId, patch) || s) : s));
|
|
1173
|
+
} catch (e) { return send(422, { error: e.message }); }
|
|
1174
|
+
}
|
|
1175
|
+
const updated = await social[mAction[2]](this.agent, s);
|
|
1176
|
+
return send(200, this.status(updated || s));
|
|
1177
|
+
}
|
|
1178
|
+
|
|
1179
|
+
if (pathname === '/api/v1/notifications') {
|
|
1180
|
+
const limit = Math.min(Number(url.searchParams.get('limit')) || 30, 60);
|
|
1181
|
+
let items = this.store.getNotifications();
|
|
1182
|
+
const maxId = url.searchParams.get('max_id');
|
|
1183
|
+
if (maxId) {
|
|
1184
|
+
const i = items.findIndex(n => n.id === maxId);
|
|
1185
|
+
if (i >= 0) items = items.slice(i + 1);
|
|
1186
|
+
}
|
|
1187
|
+
return send(200, items.slice(0, limit).map(n => this.notification(n)));
|
|
1188
|
+
}
|
|
1189
|
+
|
|
1190
|
+
if (pathname === '/api/v1/markers') {
|
|
1191
|
+
if (req.method === 'POST') {
|
|
1192
|
+
const body = await readBody(req);
|
|
1193
|
+
const markers = this.store.read('masto-markers.json', {});
|
|
1194
|
+
for (const [k, v] of Object.entries(body)) {
|
|
1195
|
+
const lastId = v?.last_read_id || v;
|
|
1196
|
+
if (typeof lastId === 'string') {
|
|
1197
|
+
markers[k] = { last_read_id: lastId, version: (markers[k]?.version || 0) + 1, updated_at: new Date().toISOString() };
|
|
1198
|
+
}
|
|
1199
|
+
}
|
|
1200
|
+
this.store.write('masto-markers.json', markers);
|
|
1201
|
+
return send(200, markers);
|
|
1202
|
+
}
|
|
1203
|
+
return send(200, this.store.read('masto-markers.json', {}));
|
|
1204
|
+
}
|
|
1205
|
+
|
|
1206
|
+
if (pathname === '/api/v1/accounts/relationships') {
|
|
1207
|
+
const ids = [...url.searchParams.getAll('id[]'), ...url.searchParams.getAll('id')];
|
|
1208
|
+
const rels = ids.map(id => this.store.urlFor(id)).filter(Boolean).map(u => this.relationship(u));
|
|
1209
|
+
return send(200, rels);
|
|
1210
|
+
}
|
|
1211
|
+
|
|
1212
|
+
if (pathname === '/api/v1/accounts/search') {
|
|
1213
|
+
return send(200, await this.accountSearch(url.searchParams.get('q')));
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1216
|
+
if (pathname === '/api/v1/accounts/lookup') {
|
|
1217
|
+
const acct = String(url.searchParams.get('acct') || '').replace(/^@/, '');
|
|
1218
|
+
const cfg = this.store.getConfig();
|
|
1219
|
+
if (acct === cfg?.handle || acct === `${cfg?.handle}@${this.host}`) {
|
|
1220
|
+
return send(200, this.selfAccount());
|
|
1221
|
+
}
|
|
1222
|
+
const hit = Object.entries(this.store.getActors()).find(([u, a]) => {
|
|
1223
|
+
try { return `${a.preferredUsername}@${new URL(u).host}` === acct; } catch { return false; }
|
|
1224
|
+
});
|
|
1225
|
+
return hit ? send(200, this.account(hit[0])) : send(404, { error: 'Record not found' });
|
|
1226
|
+
}
|
|
1227
|
+
|
|
1228
|
+
// Block and mute, from where the trouble is seen. A block also unfollows —
|
|
1229
|
+
// intake refuses a blocked author already — and a mute is view-only: their
|
|
1230
|
+
// posts stay out of the timelines, nothing federates.
|
|
1231
|
+
const mRel = /^\/api\/v1\/accounts\/([a-f0-9]+)\/(block|unblock|mute|unmute)$/.exec(pathname);
|
|
1232
|
+
if (mRel && req.method === 'POST') {
|
|
1233
|
+
const actorUrl = this.store.urlFor(mRel[1]);
|
|
1234
|
+
if (!actorUrl) return send(404, { error: 'Record not found' });
|
|
1235
|
+
if (mRel[2] === 'block' || mRel[2] === 'unblock') {
|
|
1236
|
+
if (mRel[2] === 'block') await social.blockActor(this.agent, actorUrl);
|
|
1237
|
+
else await social.unblockActor(this.agent, actorUrl);
|
|
1238
|
+
} else {
|
|
1239
|
+
const m = this.store.getMuted();
|
|
1240
|
+
if (mRel[2] === 'mute' && !m.actors.includes(actorUrl)) m.actors.push(actorUrl);
|
|
1241
|
+
if (mRel[2] === 'unmute') m.actors = m.actors.filter(a => a !== actorUrl);
|
|
1242
|
+
this.store.setMuted(m);
|
|
1243
|
+
}
|
|
1244
|
+
return send(200, this.relationship(actorUrl));
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1247
|
+
const mFollow = /^\/api\/v1\/accounts\/([a-f0-9]+)\/(follow|unfollow)$/.exec(pathname);
|
|
1248
|
+
if (mFollow && req.method === 'POST') {
|
|
1249
|
+
const actorUrl = this.store.urlFor(mFollow[1]);
|
|
1250
|
+
if (!actorUrl) return send(404, { error: 'Record not found' });
|
|
1251
|
+
if (mFollow[2] === 'follow') await social.followActor(this.agent, actorUrl);
|
|
1252
|
+
else await social.unfollowActor(this.agent, actorUrl).catch(() => {}); // already-gone is fine
|
|
1253
|
+
return send(200, this.relationship(actorUrl));
|
|
1254
|
+
}
|
|
1255
|
+
|
|
1256
|
+
const mAccount = /^\/api\/v1\/accounts\/([a-f0-9]+)$/.exec(pathname);
|
|
1257
|
+
if (mAccount && req.method === 'GET') {
|
|
1258
|
+
const actorUrl = this.store.urlFor(mAccount[1]);
|
|
1259
|
+
return actorUrl ? send(200, this.account(actorUrl)) : send(404, { error: 'Record not found' });
|
|
1260
|
+
}
|
|
1261
|
+
// Web push: one subscription per client login. The agent pushes payloads
|
|
1262
|
+
// to the browser's push service itself, so a closed client still hears.
|
|
1263
|
+
if (pathname === '/api/v1/push/subscription') {
|
|
1264
|
+
const token = (/^Bearer (.+)$/.exec(req.headers.authorization || '') || [])[1];
|
|
1265
|
+
if (!token) return send(401, { error: 'The access token is invalid' });
|
|
1266
|
+
if (req.method === 'GET') {
|
|
1267
|
+
const sub = this.push.get(token);
|
|
1268
|
+
return sub ? send(200, this.push.json(token, sub)) : send(404, { error: 'Record not found' });
|
|
1269
|
+
}
|
|
1270
|
+
if (req.method === 'POST') {
|
|
1271
|
+
const body = await readBody(req);
|
|
1272
|
+
const sub = this.push.set(token, {
|
|
1273
|
+
endpoint: body.subscription?.endpoint,
|
|
1274
|
+
keys: body.subscription?.keys,
|
|
1275
|
+
alerts: body.data?.alerts,
|
|
1276
|
+
});
|
|
1277
|
+
if (!sub) return send(422, { error: 'a https endpoint and p256dh/auth keys are required' });
|
|
1278
|
+
return send(200, this.push.json(token, sub));
|
|
1279
|
+
}
|
|
1280
|
+
if (req.method === 'PUT') {
|
|
1281
|
+
const body = await readBody(req);
|
|
1282
|
+
const sub = this.push.setAlerts(token, body.data?.alerts);
|
|
1283
|
+
return sub ? send(200, this.push.json(token, sub)) : send(404, { error: 'Record not found' });
|
|
1284
|
+
}
|
|
1285
|
+
if (req.method === 'DELETE') {
|
|
1286
|
+
this.push.drop(token);
|
|
1287
|
+
return send(200, {});
|
|
1288
|
+
}
|
|
1289
|
+
}
|
|
1290
|
+
if (/^\/api\/v1\/accounts\/[a-f0-9]+\/featured_tags$/.test(pathname)) return send(200, []);
|
|
1291
|
+
|
|
1292
|
+
// The counts these back are rendered from the same two arrays (see
|
|
1293
|
+
// `account`), so a client that shows a number here can always open it.
|
|
1294
|
+
const mAccList = /^\/api\/v1\/accounts\/([a-f0-9]+)\/(following|followers)$/.exec(pathname);
|
|
1295
|
+
if (mAccList && req.method === 'GET') {
|
|
1296
|
+
const actorUrl = this.store.urlFor(mAccList[1]);
|
|
1297
|
+
if (!actorUrl) return send(404, { error: 'Record not found' });
|
|
1298
|
+
// Only our own lists are known. A remote actor's collections live on its
|
|
1299
|
+
// own server, and opening a profile is not worth a fetch of a stranger's
|
|
1300
|
+
// pod — an empty list, not an error, is what the API can honestly say.
|
|
1301
|
+
const mine = actorUrl === this.urls?.actor;
|
|
1302
|
+
const c = this.store.getContacts();
|
|
1303
|
+
// Reversed: the contact arrays append, and page()'s cursors read
|
|
1304
|
+
// newest-first — fed as stored, since_id answered with its complement.
|
|
1305
|
+
const recs = (!mine ? []
|
|
1306
|
+
: mAccList[2] === 'followers' ? c.followers
|
|
1307
|
+
: c.following.filter(f => f.accepted)) // pending is not following
|
|
1308
|
+
.slice().reverse();
|
|
1309
|
+
const { items, headers } = this.page(recs, url,
|
|
1310
|
+
{ limit: 40, max: 80, idOf: (r) => this.store.idFor(r.actor) });
|
|
1311
|
+
return send(200, items.map(r => this.account(r.actor)), headers);
|
|
1312
|
+
}
|
|
1313
|
+
|
|
1314
|
+
// Who among the people I follow also follows THEM — a graph we do not hold,
|
|
1315
|
+
// and the shape is an entry per requested account, not a bare list.
|
|
1316
|
+
if (pathname === '/api/v1/accounts/familiar_followers') {
|
|
1317
|
+
const ids = [...url.searchParams.getAll('id[]'), ...url.searchParams.getAll('id')];
|
|
1318
|
+
return send(200, ids.map(id => ({ id, accounts: [] })));
|
|
1319
|
+
}
|
|
1320
|
+
|
|
1321
|
+
const mAccStatuses = /^\/api\/v1\/accounts\/([a-f0-9]+)\/statuses$/.exec(pathname);
|
|
1322
|
+
if (mAccStatuses) {
|
|
1323
|
+
const actorUrl = this.store.urlFor(mAccStatuses[1]);
|
|
1324
|
+
const all = this.store.getStatuses();
|
|
1325
|
+
const pinnedOnly = url.searchParams.get('pinned') === 'true';
|
|
1326
|
+
const { items, headers } = this.page(
|
|
1327
|
+
all.filter(s => s.actor === actorUrl && (!pinnedOnly || s.pinned)), url);
|
|
1328
|
+
return send(200, items.map(s => this.statusOrBoost(s, { all })), headers);
|
|
1329
|
+
}
|
|
1330
|
+
|
|
1331
|
+
// Search: @user@host → webfinger resolve; URL → actor or note ingest;
|
|
1332
|
+
// plain text → local mirror + actor-cache scan.
|
|
1333
|
+
if (pathname === '/api/v2/search' || pathname === '/api/v1/search') {
|
|
1334
|
+
const q = String(url.searchParams.get('q') || '').trim();
|
|
1335
|
+
const type = url.searchParams.get('type');
|
|
1336
|
+
const out = { accounts: [], statuses: [], hashtags: [] };
|
|
1337
|
+
const asHandle = /^@?[^@\s]+@[^@\s.]+\.[^@\s]+$/.test(q);
|
|
1338
|
+
if (asHandle && type !== 'statuses') {
|
|
1339
|
+
try {
|
|
1340
|
+
const doc = await social.resolveHandle(this.agent, q);
|
|
1341
|
+
out.accounts.push(this.account(doc.id));
|
|
1342
|
+
} catch (e) { this.log(`search resolve ${q}: ${e.message}`); }
|
|
1343
|
+
} else if (/^https?:\/\//.test(q)) {
|
|
1344
|
+
const doc = await this.agent.intake.fetchAP(q).catch(() => null);
|
|
1345
|
+
if (doc?.type === 'Person' && doc.id) out.accounts.push(this.account(doc.id));
|
|
1346
|
+
else if (doc?.type === 'Note' && doc.id) {
|
|
1347
|
+
let s = this.store.getStatuses().find(x => x.noteId === doc.id);
|
|
1348
|
+
// Third site of the same rule: the note's own origin has to vouch for
|
|
1349
|
+
// the author before we store one. The owner chose the URL, but the
|
|
1350
|
+
// document at it still names whoever it likes.
|
|
1351
|
+
const author = authorOf(doc);
|
|
1352
|
+
if (!s && author) {
|
|
1353
|
+
s = {
|
|
1354
|
+
noteId: doc.id, actor: author, content: sanitizeHtml(doc.content),
|
|
1355
|
+
published: doc.published, inReplyTo: doc.inReplyTo, kind: 'remote',
|
|
1356
|
+
};
|
|
1357
|
+
this.store.addStatus(s);
|
|
1358
|
+
}
|
|
1359
|
+
if (s) out.statuses.push(this.status(s));
|
|
1360
|
+
}
|
|
1361
|
+
} else if (q) {
|
|
1362
|
+
const needle = q.toLowerCase();
|
|
1363
|
+
if (type !== 'accounts') {
|
|
1364
|
+
const all = this.store.getStatuses();
|
|
1365
|
+
out.statuses = all
|
|
1366
|
+
.filter(s => (s.content || '').toLowerCase().includes(needle)).slice(0, 20)
|
|
1367
|
+
.map(s => this.status(s, { all }));
|
|
1368
|
+
}
|
|
1369
|
+
if (type !== 'statuses') out.accounts = await this.accountSearch(q);
|
|
1370
|
+
}
|
|
1371
|
+
return send(200, out);
|
|
1372
|
+
}
|
|
1373
|
+
|
|
1374
|
+
// Media upload: file → remote pod /ap/media/ (public-Read), entry in the
|
|
1375
|
+
// media registry so a later POST /statuses can attach it.
|
|
1376
|
+
if ((pathname === '/api/v2/media' || pathname === '/api/v1/media') && req.method === 'POST') {
|
|
1377
|
+
const { fields, file } = await readMultipart(req);
|
|
1378
|
+
if (!file?.data?.length) return send(422, { error: 'file required' });
|
|
1379
|
+
// The client said what this is, and we used to believe it. The media
|
|
1380
|
+
// container is world-readable and sits on the pod's own origin — the same
|
|
1381
|
+
// origin as the WebID and the ACLs — so a file stored as text/html is a
|
|
1382
|
+
// page served from your identity, and script in it runs as you in any
|
|
1383
|
+
// browser already logged into that pod. A bearer is not "only you": the
|
|
1384
|
+
// facade exists so third-party clients can connect, tokens last 90 days,
|
|
1385
|
+
// and no scope is enforced. So any client you authorize could leave that
|
|
1386
|
+
// page behind.
|
|
1387
|
+
const mediaType = attachmentType(file.contentType);
|
|
1388
|
+
const ext = extensionFor(mediaType, file.filename);
|
|
1389
|
+
const slug = new Date().toISOString().slice(0, 10) + '-' + crypto.randomBytes(4).toString('hex') + '.' + ext;
|
|
1390
|
+
const mediaUrl = this.urls.media + slug;
|
|
1391
|
+
await this.agent.publisher.ensureMediaContainer();
|
|
1392
|
+
await this.agent.remote.put(mediaUrl, file.data, mediaType);
|
|
1393
|
+
const entry = { url: mediaUrl, mediaType, description: fields.description || '' };
|
|
1394
|
+
const id = this.store.idFor(mediaUrl);
|
|
1395
|
+
this.store.setMedia(id, entry);
|
|
1396
|
+
return send(200, this.mediaJson({ id, ...entry }));
|
|
1397
|
+
}
|
|
1398
|
+
const mMedia = /^\/api\/v1\/media\/([a-f0-9]+)$/.exec(pathname);
|
|
1399
|
+
if (mMedia) {
|
|
1400
|
+
const entry = this.store.getMedia()[mMedia[1]];
|
|
1401
|
+
if (!entry) return send(404, { error: 'Record not found' });
|
|
1402
|
+
if (req.method === 'PUT') {
|
|
1403
|
+
const body = await readBody(req);
|
|
1404
|
+
if (typeof body.description === 'string') {
|
|
1405
|
+
entry.description = body.description;
|
|
1406
|
+
this.store.setMedia(mMedia[1], entry);
|
|
1407
|
+
}
|
|
1408
|
+
}
|
|
1409
|
+
return send(200, this.mediaJson({ id: mMedia[1], ...entry }));
|
|
1410
|
+
}
|
|
1411
|
+
|
|
1412
|
+
this.log(`mastoapi: unhandled ${req.method} ${pathname} — punch list`);
|
|
1413
|
+
return send(404, { error: `Unimplemented: ${req.method} ${pathname}` });
|
|
1414
|
+
}
|
|
1415
|
+
}
|
|
1416
|
+
|
|
1417
|
+
function instanceConfig() {
|
|
1418
|
+
return {
|
|
1419
|
+
statuses: { max_characters: 5000, max_media_attachments: 4, characters_reserved_per_url: 23 },
|
|
1420
|
+
media_attachments: {
|
|
1421
|
+
supported_mime_types: ['image/jpeg', 'image/png', 'image/gif', 'image/webp',
|
|
1422
|
+
'video/mp4', 'video/webm', 'audio/mpeg', 'audio/ogg'],
|
|
1423
|
+
image_size_limit: 10 * 1024 * 1024, video_size_limit: 40 * 1024 * 1024,
|
|
1424
|
+
image_matrix_limit: 16777216, video_matrix_limit: 2304000,
|
|
1425
|
+
},
|
|
1426
|
+
polls: { max_options: 0 },
|
|
1427
|
+
accounts: { max_featured_tags: 0 },
|
|
1428
|
+
};
|
|
1429
|
+
}
|
|
1430
|
+
|
|
1431
|
+
// 1x1 transparent PNG — placeholder avatar/header for accounts without icons.
|
|
1432
|
+
const TRANSPARENT_PNG = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==';
|
|
1433
|
+
|
|
1434
|
+
// scrypt check for the optional UI password ({ saltHex, hashHex } record —
|
|
1435
|
+
// see hashPassword, used by the passwd CLI).
|
|
1436
|
+
export function hashPassword(password) {
|
|
1437
|
+
const salt = crypto.randomBytes(16);
|
|
1438
|
+
const hash = crypto.scryptSync(String(password), salt, 32);
|
|
1439
|
+
return { saltHex: salt.toString('hex'), hashHex: hash.toString('hex') };
|
|
1440
|
+
}
|
|
1441
|
+
function checkPassword(rec, password) {
|
|
1442
|
+
try {
|
|
1443
|
+
const hash = crypto.scryptSync(String(password), Buffer.from(rec.saltHex, 'hex'), 32);
|
|
1444
|
+
return crypto.timingSafeEqual(hash, Buffer.from(rec.hashHex, 'hex'));
|
|
1445
|
+
} catch { return false; }
|
|
1446
|
+
}
|
|
1447
|
+
|
|
1448
|
+
const escapeHtml = (s) => String(s).replace(/[&<>"']/g, c =>
|
|
1449
|
+
({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
|
1450
|
+
|
|
1451
|
+
// The authorize login page: every original OAuth param rides along as a
|
|
1452
|
+
// hidden field so the POST can complete the flow.
|
|
1453
|
+
// redirect_uris arrive as an array or a whitespace-separated string (Mastodon
|
|
1454
|
+
// accepts both); normalise to a trimmed, non-empty list.
|
|
1455
|
+
const parseRedirects = (v) => (Array.isArray(v) ? v : String(v || '').split(/\s+/))
|
|
1456
|
+
.map(s => s.trim()).filter(Boolean);
|
|
1457
|
+
|
|
1458
|
+
function sendLoginForm(res, params, error = '', client = null) {
|
|
1459
|
+
const hidden = [...params.entries()].filter(([k]) => k !== 'password')
|
|
1460
|
+
.map(([k, v]) => `<input type="hidden" name="${escapeHtml(k)}" value="${escapeHtml(v)}">`).join('\n');
|
|
1461
|
+
// Name what is asking, so the owner approves a client they can see rather
|
|
1462
|
+
// than an opaque one. The destination the code will be sent to is the fact
|
|
1463
|
+
// that matters for a cross-site client.
|
|
1464
|
+
let asking = '<p>Enter the agent password to authorize this client.</p>';
|
|
1465
|
+
if (client && (client.name || client.redirect)) {
|
|
1466
|
+
let where = '';
|
|
1467
|
+
try { where = client.redirect ? new URL(client.redirect).host : ''; } catch { /* oob or blank */ }
|
|
1468
|
+
const who = client.name ? escapeHtml(client.name) : (where ? escapeHtml(where) : 'A client');
|
|
1469
|
+
asking = `<p><strong>${who}</strong> is asking to access your account`
|
|
1470
|
+
+ `${where ? `, sending the authorization to <code>${escapeHtml(where)}</code>` : ''}.</p>`
|
|
1471
|
+
+ `<p>Scope: <code>${escapeHtml(client.scope || 'read')}</code>. Enter the agent password to allow it.</p>`;
|
|
1472
|
+
}
|
|
1473
|
+
res.writeHead(error ? 401 : 200, { 'content-type': 'text/html; charset=utf-8' });
|
|
1474
|
+
res.end(`<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
|
|
1475
|
+
<title>FediPod — authorize</title>
|
|
1476
|
+
<style>:root{color-scheme:light dark;font-size:125%;--heading:#1a4f8a}
|
|
1477
|
+
body{font:1rem system-ui,sans-serif;max-width:22rem;margin:15vh auto;padding:0 1rem}
|
|
1478
|
+
h1{color:var(--heading)}
|
|
1479
|
+
code{word-break:break-all}
|
|
1480
|
+
@media (prefers-color-scheme:dark){:root{--heading:#7fb3e8}}
|
|
1481
|
+
input,button{font:inherit;width:100%;padding:.5rem;margin:.3rem 0;box-sizing:border-box}
|
|
1482
|
+
.err{color:#b00020}
|
|
1483
|
+
@media (prefers-color-scheme:dark){.err{color:#ff8a8a}}</style></head><body>
|
|
1484
|
+
<main>
|
|
1485
|
+
<h1>FediPod</h1>
|
|
1486
|
+
${asking}
|
|
1487
|
+
${error ? `<p class="err" id="login-err" role="alert">${escapeHtml(error)}</p>` : ''}
|
|
1488
|
+
<form method="POST" action="/oauth/authorize">
|
|
1489
|
+
${hidden}
|
|
1490
|
+
<label for="password">Agent password</label>
|
|
1491
|
+
<input type="password" id="password" name="password" autofocus autocomplete="current-password"
|
|
1492
|
+
${error ? 'aria-invalid="true" aria-describedby="login-err"' : ''}>
|
|
1493
|
+
<button type="submit">Authorize</button>
|
|
1494
|
+
</form>
|
|
1495
|
+
</main></body></html>`);
|
|
1496
|
+
return true;
|
|
1497
|
+
}
|
|
1498
|
+
|
|
1499
|
+
// Minimal multipart/form-data reader for media uploads: string fields plus
|
|
1500
|
+
// at most one file part (Mastodon's media endpoints send exactly one).
|
|
1501
|
+
const selfIcon = (api, self, cached) =>
|
|
1502
|
+
(self ? api.store.getConfig()?.icon : null) || cached.icon;
|
|
1503
|
+
|
|
1504
|
+
function readMultipart(req, limit = 12e6) {
|
|
1505
|
+
return new Promise((resolve, reject) => {
|
|
1506
|
+
const chunks = [];
|
|
1507
|
+
let n = 0;
|
|
1508
|
+
req.on('data', c => {
|
|
1509
|
+
n += c.length;
|
|
1510
|
+
if (n > limit) { reject(new Error('upload too large')); req.destroy(); return; }
|
|
1511
|
+
chunks.push(c);
|
|
1512
|
+
});
|
|
1513
|
+
req.on('error', reject);
|
|
1514
|
+
req.on('end', () => {
|
|
1515
|
+
try {
|
|
1516
|
+
const m = /boundary=(?:"([^"]+)"|([^;]+))/.exec(String(req.headers['content-type'] || ''));
|
|
1517
|
+
if (!m) return resolve({ fields: {}, file: null });
|
|
1518
|
+
const buf = Buffer.concat(chunks);
|
|
1519
|
+
const boundary = Buffer.from('--' + (m[1] || m[2]).trim());
|
|
1520
|
+
const fields = {};
|
|
1521
|
+
// `file` is the last one seen, which is what the single-file media
|
|
1522
|
+
// upload wants. `files` keys them by field name, because the profile
|
|
1523
|
+
// editor submits an avatar and a header in one request.
|
|
1524
|
+
const files = {};
|
|
1525
|
+
let file = null;
|
|
1526
|
+
let i = buf.indexOf(boundary);
|
|
1527
|
+
while (i >= 0) {
|
|
1528
|
+
const start = i + boundary.length;
|
|
1529
|
+
if (buf.slice(start, start + 2).toString() === '--') break;
|
|
1530
|
+
const next = buf.indexOf(boundary, start);
|
|
1531
|
+
if (next < 0) break;
|
|
1532
|
+
const part = buf.slice(start + 2, next - 2); // strip the CRLFs framing the part
|
|
1533
|
+
const sep = part.indexOf('\r\n\r\n');
|
|
1534
|
+
if (sep >= 0) {
|
|
1535
|
+
const head = part.slice(0, sep).toString();
|
|
1536
|
+
const body = part.slice(sep + 4);
|
|
1537
|
+
const name = /name="([^"]*)"/.exec(head)?.[1];
|
|
1538
|
+
const filename = /filename="([^"]*)"/.exec(head)?.[1];
|
|
1539
|
+
if (filename !== undefined) {
|
|
1540
|
+
file = {
|
|
1541
|
+
filename,
|
|
1542
|
+
contentType: /content-type:\s*([^\r\n]+)/i.exec(head)?.[1]?.trim() || 'application/octet-stream',
|
|
1543
|
+
data: body,
|
|
1544
|
+
};
|
|
1545
|
+
if (name) files[name] = file;
|
|
1546
|
+
} else if (name) fields[name] = body.toString();
|
|
1547
|
+
}
|
|
1548
|
+
i = next;
|
|
1549
|
+
}
|
|
1550
|
+
resolve({ fields, file, files });
|
|
1551
|
+
} catch (e) { reject(e); }
|
|
1552
|
+
});
|
|
1553
|
+
});
|
|
1554
|
+
}
|
|
1555
|
+
|
|
1556
|
+
// Accepts JSON or form-encoded bodies (OAuth posts are often form-encoded).
|
|
1557
|
+
// The source of a post that predates raw-text storage: its HTML back to
|
|
1558
|
+
// typed text, near enough to edit.
|
|
1559
|
+
function htmlToText(html) {
|
|
1560
|
+
return String(html)
|
|
1561
|
+
.replace(/<br\s*\/?>/gi, '\n')
|
|
1562
|
+
.replace(/<\/p>\s*<p[^>]*>/gi, '\n\n')
|
|
1563
|
+
.replace(/<[^>]+>/g, '')
|
|
1564
|
+
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
1565
|
+
.replace(/"/g, '"').replace(/'/g, "'")
|
|
1566
|
+
.trim();
|
|
1567
|
+
}
|
|
1568
|
+
|
|
1569
|
+
function readBody(req) {
|
|
1570
|
+
return new Promise((resolve, reject) => {
|
|
1571
|
+
let data = '';
|
|
1572
|
+
req.on('data', c => {
|
|
1573
|
+
data += c;
|
|
1574
|
+
// Destroying the socket without settling left the awaiting handler
|
|
1575
|
+
// pending for the life of the process and the client waiting on a
|
|
1576
|
+
// response that would never come.
|
|
1577
|
+
if (data.length > 1e6) { req.destroy(); reject(new Error('request body too large')); }
|
|
1578
|
+
});
|
|
1579
|
+
req.on('end', () => {
|
|
1580
|
+
const ct = String(req.headers['content-type'] || '');
|
|
1581
|
+
try {
|
|
1582
|
+
if (ct.includes('application/json')) return resolve(data ? JSON.parse(data) : {});
|
|
1583
|
+
resolve(Object.fromEntries(new URLSearchParams(data)));
|
|
1584
|
+
} catch (e) { reject(e); }
|
|
1585
|
+
});
|
|
1586
|
+
req.on('error', reject);
|
|
1587
|
+
});
|
|
1588
|
+
}
|