fedipod-server 0.10.0 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/claims.js +1 -1
- package/dist/handler.d.ts +1 -1
- package/dist/handler.js +35 -2
- package/dist/handler.jsonld +1 -1
- package/lib/admin.mjs +3 -3
- package/lib/deliver.mjs +6 -0
- package/lib/export-collections.mjs +1 -1
- package/lib/front-core.mjs +272 -49
- package/lib/gateway-core.mjs +3 -7
- package/lib/httpsig.mjs +33 -2
- package/lib/import.mjs +1 -1
- package/lib/intake.mjs +335 -118
- package/lib/links.mjs +6 -32
- package/lib/mastoapi.mjs +372 -35
- package/lib/migrate.mjs +4 -18
- package/lib/pod/README.md +61 -0
- package/lib/pod/actor.mjs +96 -0
- package/lib/pod/collection.mjs +77 -0
- package/lib/pod/containers.mjs +136 -0
- package/lib/pod/discovery.mjs +46 -0
- package/lib/pod/featured.mjs +18 -0
- package/lib/pod/followers.mjs +24 -0
- package/lib/pod/following.mjs +10 -0
- package/lib/pod/http.mjs +74 -0
- package/lib/pod/inbox.mjs +144 -0
- package/lib/pod/links.mjs +35 -0
- package/lib/pod/media.mjs +27 -0
- package/lib/pod/notes.mjs +80 -0
- package/lib/pod/notifications.mjs +89 -0
- package/lib/pod/outbox.mjs +18 -0
- package/lib/pod/package.json +19 -0
- package/lib/pod/policy.mjs +58 -0
- package/lib/pod/private.mjs +19 -0
- package/lib/pod/root.mjs +63 -0
- package/lib/pod/state.mjs +39 -0
- package/lib/pod/transport.mjs +477 -0
- package/lib/pod/urls.mjs +71 -0
- package/lib/publisher.mjs +112 -183
- package/lib/remote.mjs +30 -395
- package/lib/social.mjs +18 -9
- package/lib/storage.mjs +2 -2
- package/lib/store.mjs +8 -1
- package/lib/tagfeed.mjs +19 -2
- package/lib/wire.mjs +48 -67
- package/package.json +1 -1
- package/run-agent.mjs +14 -39
- package/web/admin/admin.js +141 -74
- package/web/admin/bar.css +9 -13
- package/web/admin/bar.js +2 -2
- package/web/admin/client/client.js +1 -1
- package/web/admin/client/index.html +5 -6
- package/web/admin/index.html +53 -65
- package/web/admin/setup/index.html +2 -1
- package/web/admin/setup/setup.js +1 -1
- package/web/admin/tokens.css +56 -0
- package/web/app/README.md +77 -0
- package/web/app/admin-facade.mjs +549 -0
- package/web/app/agent.mjs +490 -0
- package/web/app/atproto-browser.mjs +86 -0
- package/web/app/boot.mjs +340 -0
- package/web/app/deliver-relay.mjs +72 -0
- package/web/app/dist/boot.js +34067 -0
- package/web/app/dist/boot.js.map +7 -0
- package/web/app/dist/sw.js +53745 -0
- package/web/app/dist/sw.js.map +7 -0
- package/web/app/fediacct-browser.mjs +146 -0
- package/web/app/idb-kv.mjs +35 -0
- package/web/app/index.html +203 -0
- package/web/app/index.html~ +152 -0
- package/web/app/keys-browser.mjs +46 -0
- package/web/app/keystore.mjs +99 -0
- package/web/app/oidc-session.mjs +189 -0
- package/web/app/pod-auth.mjs +248 -0
- package/web/app/pod-remote.mjs +84 -0
- package/web/app/shims/_globals.mjs +2 -0
- package/web/app/shims/fedify-sig.mjs +77 -0
- package/web/app/shims/node-crypto.mjs +113 -0
- package/web/app/shims/node-fs.mjs +17 -0
- package/web/app/shims/node-path.mjs +19 -0
- package/web/app/shims/node-url.mjs +7 -0
- package/web/app/shims/prelude.js +58 -0
- package/web/app/shims/safefetch.mjs +58 -0
- package/web/app/shims/web-push.mjs +14 -0
- package/web/app/signup.mjs +248 -0
- package/web/app/site/_headers +9 -0
- package/web/app/site/_redirects +21 -0
- package/web/app/site/admin/admin.js +1181 -0
- package/web/app/site/admin/bar.css +60 -0
- package/web/app/site/admin/bar.js +61 -0
- package/web/app/site/admin/client/client.js +66 -0
- package/web/app/site/admin/client/index.html +43 -0
- package/web/app/site/admin/index.html +556 -0
- package/web/app/site/admin/setup/index.html +191 -0
- package/web/app/site/admin/setup/setup.js +308 -0
- package/web/app/site/admin/tokens.css +56 -0
- package/web/app/site/admin/window.css +55 -0
- package/web/app/site/admin/window.js +136 -0
- package/web/app/site/app/404.html +40 -0
- package/web/app/site/app/_headers +2 -0
- package/web/app/site/app/apple-touch-icon.png +0 -0
- package/web/app/site/app/assets/ICONS-DIPJeU0C.js +2 -0
- package/web/app/site/app/assets/ICONS-DIPJeU0C.js.map +1 -0
- package/web/app/site/app/assets/ScheduledAtField-D-Abfo4J.js +183 -0
- package/web/app/site/app/assets/ScheduledAtField-D-Abfo4J.js.map +1 -0
- package/web/app/site/app/assets/Temml-CCjoacWt.woff2 +0 -0
- package/web/app/site/app/assets/__vite-browser-external-C7iut881.js +2 -0
- package/web/app/site/app/assets/__vite-browser-external-C7iut881.js.map +1 -0
- package/web/app/site/app/assets/boosts-carousel-BiOaNdMT.jpg +0 -0
- package/web/app/site/app/assets/catch-up-O5q5C75Z.png +0 -0
- package/web/app/site/app/assets/chunk-aKtaBQYM.js +1 -0
- package/web/app/site/app/assets/compose-CFEx7wb4.js +2 -0
- package/web/app/site/app/assets/compose-CFEx7wb4.js.map +1 -0
- package/web/app/site/app/assets/compose-ZJN5xSb4.js +35 -0
- package/web/app/site/app/assets/compose-ZJN5xSb4.js.map +1 -0
- package/web/app/site/app/assets/debug.module-YFQgwnIr.js +52 -0
- package/web/app/site/app/assets/debug.module-YFQgwnIr.js.map +1 -0
- package/web/app/site/app/assets/dom-p1YcJ5Rw.js +2 -0
- package/web/app/site/app/assets/dom-p1YcJ5Rw.js.map +1 -0
- package/web/app/site/app/assets/home-mobile-dark@2x-Dc-J8-SM.png +0 -0
- package/web/app/site/app/assets/home-mobile-light@2x-Bd3fod8m.png +0 -0
- package/web/app/site/app/assets/home-tablet-dark@2x-BgcNI8le.png +0 -0
- package/web/app/site/app/assets/home-tablet-light@2x-BW2RV9T6.png +0 -0
- package/web/app/site/app/assets/icons/add-circle-line-WD0mwORD.js +1 -0
- package/web/app/site/app/assets/icons/alert-line-CpFUMf9s.js +1 -0
- package/web/app/site/app/assets/icons/android-2-line-Bzxnrd3S.js +1 -0
- package/web/app/site/app/assets/icons/announcement-line-5nwaJFJm.js +1 -0
- package/web/app/site/app/assets/icons/arrow-down-circle-line-a7Id9-Iv.js +1 -0
- package/web/app/site/app/assets/icons/arrow-down-line-B8uzXzq6.js +1 -0
- package/web/app/site/app/assets/icons/arrow-left-line-CGrGT9C-.js +1 -0
- package/web/app/site/app/assets/icons/arrow-right-line-CE-HERfX.js +1 -0
- package/web/app/site/app/assets/icons/arrow-to-up-line-CvdQjmcy.js +1 -0
- package/web/app/site/app/assets/icons/arrow-up-circle-line-DEparSNF.js +1 -0
- package/web/app/site/app/assets/icons/arrow-up-line-C7yBeugc.js +1 -0
- package/web/app/site/app/assets/icons/arrows-right-line-BtbXmJGP.js +1 -0
- package/web/app/site/app/assets/icons/at-line-CF66Bznk.js +1 -0
- package/web/app/site/app/assets/icons/attachment-line-BY8It-l9.js +1 -0
- package/web/app/site/app/assets/icons/blockquote-line-BS6pU_hm.js +1 -0
- package/web/app/site/app/assets/icons/board-line-C0UWXyA6.js +1 -0
- package/web/app/site/app/assets/icons/bookmark-line-1CtX5JLF.js +1 -0
- package/web/app/site/app/assets/icons/building-5-line-Dd59MzbO.js +1 -0
- package/web/app/site/app/assets/icons/bus-2-line-6_dvA0GG.js +1 -0
- package/web/app/site/app/assets/icons/calendar-day-line-Crpl_fmu.js +1 -0
- package/web/app/site/app/assets/icons/calendar-month-line-vommts2C.js +1 -0
- package/web/app/site/app/assets/icons/calendar-time-add-line-BU_DTlQk.js +1 -0
- package/web/app/site/app/assets/icons/camera-line-Cv69MEei.js +1 -0
- package/web/app/site/app/assets/icons/celebrate-line-B8DvIoi0.js +1 -0
- package/web/app/site/app/assets/icons/chart-bar-line-FK-QOmtF.js +1 -0
- package/web/app/site/app/assets/icons/chart-line-line-BGcJAFWw.js +1 -0
- package/web/app/site/app/assets/icons/chat-3-line-YFK2-hwz.js +1 -0
- package/web/app/site/app/assets/icons/check-circle-line-DsAxFr5-.js +1 -0
- package/web/app/site/app/assets/icons/clipboard-line-BBp364gJ.js +1 -0
- package/web/app/site/app/assets/icons/close-circle-line-CICdiuGz.js +1 -0
- package/web/app/site/app/assets/icons/close-line-DYrFWcjv.js +1 -0
- package/web/app/site/app/assets/icons/cloud-line-tiQAbah8.js +1 -0
- package/web/app/site/app/assets/icons/code-line-fAl8iiV3.js +1 -0
- package/web/app/site/app/assets/icons/comment-2-line-CR3g7WeB.js +1 -0
- package/web/app/site/app/assets/icons/copy-2-line-CLW69yF5.js +1 -0
- package/web/app/site/app/assets/icons/delete-2-line-B8xmbr4n.js +1 -0
- package/web/app/site/app/assets/icons/document-3-line-DBGNhAg2.js +1 -0
- package/web/app/site/app/assets/icons/document-line-C7mb9sb7.js +1 -0
- package/web/app/site/app/assets/icons/down-line-BGqSSThd.js +1 -0
- package/web/app/site/app/assets/icons/edit-4-line-DcgGaEhx.js +1 -0
- package/web/app/site/app/assets/icons/emoji-2-line-D8YgnM-y.js +1 -0
- package/web/app/site/app/assets/icons/exit-line-Dc07BS-x.js +1 -0
- package/web/app/site/app/assets/icons/external-link-line-BvpSkmjO.js +1 -0
- package/web/app/site/app/assets/icons/eye-2-line-CiWXx-f_.js +1 -0
- package/web/app/site/app/assets/icons/eye-close-line-BgTaSJ4P.js +1 -0
- package/web/app/site/app/assets/icons/filter-2-line-B_mR2usy.js +1 -0
- package/web/app/site/app/assets/icons/filter-line-C_nm-3cU.js +1 -0
- package/web/app/site/app/assets/icons/flag-1-line-C2HB05I0.js +1 -0
- package/web/app/site/app/assets/icons/forbid-circle-line-D3LTT9yd.js +1 -0
- package/web/app/site/app/assets/icons/formula-line-DLrt2d6j.js +1 -0
- package/web/app/site/app/assets/icons/grid-line-CgUmEikf.js +1 -0
- package/web/app/site/app/assets/icons/group-line-C5fnX0wU.js +1 -0
- package/web/app/site/app/assets/icons/hand-finger-2-line-BGgxbuL9.js +1 -0
- package/web/app/site/app/assets/icons/hashtag-line-CyTEEXBb.js +1 -0
- package/web/app/site/app/assets/icons/heart-crack-line-BZddbO2C.js +1 -0
- package/web/app/site/app/assets/icons/heart-line-CJ7K47J4.js +1 -0
- package/web/app/site/app/assets/icons/history-2-line-kWWvFzqv.js +1 -0
- package/web/app/site/app/assets/icons/history-line-bGQiwbTH.js +1 -0
- package/web/app/site/app/assets/icons/home-3-line-fRU1zeLG.js +1 -0
- package/web/app/site/app/assets/icons/information-line-iNk6-sOY.js +1 -0
- package/web/app/site/app/assets/icons/keyboard-line-DsCyuQNz.js +1 -0
- package/web/app/site/app/assets/icons/layout-4-line-CZkOX52Z.js +1 -0
- package/web/app/site/app/assets/icons/layout-5-line-B_znt5wO.js +1 -0
- package/web/app/site/app/assets/icons/left-line-eUw0tya4.js +1 -0
- package/web/app/site/app/assets/icons/lightning-line-DaKoDdix.js +1 -0
- package/web/app/site/app/assets/icons/link-2-line-DCqLmMA7.js +1 -0
- package/web/app/site/app/assets/icons/list-check-line-BAxosBh9.js +1 -0
- package/web/app/site/app/assets/icons/lock-line-CPoaXeUW.js +1 -0
- package/web/app/site/app/assets/icons/mail-line-B0P8aa1L.js +1 -0
- package/web/app/site/app/assets/icons/moon-line-CEqNAToP.js +1 -0
- package/web/app/site/app/assets/icons/more-1-fill-Dw2e5xab.js +1 -0
- package/web/app/site/app/assets/icons/more-3-line-BIiVjYTz.js +1 -0
- package/web/app/site/app/assets/icons/notification-line-BZZq2Gtu.js +1 -0
- package/web/app/site/app/assets/icons/pencil-line-CKh8-A1v.js +1 -0
- package/web/app/site/app/assets/icons/photo-album-line-Bnfg2u40.js +1 -0
- package/web/app/site/app/assets/icons/pin-line-DpTolyhs.js +1 -0
- package/web/app/site/app/assets/icons/play-fill-B0-G_8lX.js +1 -0
- package/web/app/site/app/assets/icons/qrcode-2-line-B6SPCaHn.js +1 -0
- package/web/app/site/app/assets/icons/quill-pen-line-B6LUrnCP.js +1 -0
- package/web/app/site/app/assets/icons/quote-left-fill-hQZ1cj_6.js +1 -0
- package/web/app/site/app/assets/icons/radar-line-CKyroTth.js +1 -0
- package/web/app/site/app/assets/icons/react-line-CoIKhhtK.js +1 -0
- package/web/app/site/app/assets/icons/refresh-2-line-D0mTF97S.js +1 -0
- package/web/app/site/app/assets/icons/right-line-B3KWJaqc.js +1 -0
- package/web/app/site/app/assets/icons/rocket-line-4KZl537D.js +1 -0
- package/web/app/site/app/assets/icons/round-fill-C69NTrZ0.js +1 -0
- package/web/app/site/app/assets/icons/round-line-B9k_1uo-.js +1 -0
- package/web/app/site/app/assets/icons/route-line-B28ReHcE.js +1 -0
- package/web/app/site/app/assets/icons/rows-4-line-CrBniagM.js +1 -0
- package/web/app/site/app/assets/icons/scan-line-DSf74YdP.js +1 -0
- package/web/app/site/app/assets/icons/search-2-line-B6geIU1z.js +1 -0
- package/web/app/site/app/assets/icons/settings-3-line-azJwK47w.js +1 -0
- package/web/app/site/app/assets/icons/settings-6-line-C_5zc5YR.js +1 -0
- package/web/app/site/app/assets/icons/share-2-line-D5MxWjrm.js +1 -0
- package/web/app/site/app/assets/icons/share-forward-line-Cp61fZzZ.js +1 -0
- package/web/app/site/app/assets/icons/sparkles-2-line-DYYyXTaW.js +1 -0
- package/web/app/site/app/assets/icons/sparkles-line-C2hY1s6I.js +1 -0
- package/web/app/site/app/assets/icons/time-line-BF3eet1v.js +1 -0
- package/web/app/site/app/assets/icons/transfer-4-line-oisfvTBw.js +1 -0
- package/web/app/site/app/assets/icons/translate-line-BFWol2Ce.js +1 -0
- package/web/app/site/app/assets/icons/unlock-line-X_x5vwv3.js +1 -0
- package/web/app/site/app/assets/icons/upload-3-line-DgzwUJeW.js +1 -0
- package/web/app/site/app/assets/icons/user-4-line-BVSynZCp.js +1 -0
- package/web/app/site/app/assets/icons/user-add-2-line-BlGS5Q8q.js +1 -0
- package/web/app/site/app/assets/icons/user-add-line-BbXdHG4z.js +1 -0
- package/web/app/site/app/assets/icons/user-edit-line-BFAHOV0X.js +1 -0
- package/web/app/site/app/assets/icons/user-follow-line-BXux0wSd.js +1 -0
- package/web/app/site/app/assets/icons/user-star-line-DdYlpHLU.js +1 -0
- package/web/app/site/app/assets/icons/user-warning-line-Cj43j0_E.js +1 -0
- package/web/app/site/app/assets/icons/user-x-line-DIDgNlPH.js +1 -0
- package/web/app/site/app/assets/icons/volume-line-Bs390KQt.js +1 -0
- package/web/app/site/app/assets/icons/volume-mute-line-DzlzIqFZ.js +1 -0
- package/web/app/site/app/assets/icons/walk-line-yNXfB-md.js +1 -0
- package/web/app/site/app/assets/icons/world-2-line-CenkGIwj.js +1 -0
- package/web/app/site/app/assets/icons/zoom-in-line-CaENUphf.js +1 -0
- package/web/app/site/app/assets/icons/zoom-out-line-BDNk0EWo.js +1 -0
- package/web/app/site/app/assets/instances-BNPptnn-.json +333 -0
- package/web/app/site/app/assets/locales/ar-SA-CfheRbCu.js +1 -0
- package/web/app/site/app/assets/locales/ca-ES-DHRW86Tz.js +1 -0
- package/web/app/site/app/assets/locales/cs-CZ-CKoqEuA1.js +1 -0
- package/web/app/site/app/assets/locales/de-DE-ByRPMRFy.js +1 -0
- package/web/app/site/app/assets/locales/eo-UY-CPC7wLCS.js +1 -0
- package/web/app/site/app/assets/locales/es-ES-dJa0Fd9t.js +1 -0
- package/web/app/site/app/assets/locales/eu-ES-C3djlPR2.js +1 -0
- package/web/app/site/app/assets/locales/fa-IR-DTT490Ck.js +1 -0
- package/web/app/site/app/assets/locales/fi-FI-B23MCPuz.js +1 -0
- package/web/app/site/app/assets/locales/fr-FR-BcaQsO5C.js +1 -0
- package/web/app/site/app/assets/locales/gl-ES-BR-CVKBN.js +1 -0
- package/web/app/site/app/assets/locales/he-IL-CfheRbCu.js +1 -0
- package/web/app/site/app/assets/locales/hu-HU-CfheRbCu.js +1 -0
- package/web/app/site/app/assets/locales/it-IT-C1136UFV.js +1 -0
- package/web/app/site/app/assets/locales/ja-JP-CRZmRAhD.js +1 -0
- package/web/app/site/app/assets/locales/kab-Cz8xH_TT.js +1 -0
- package/web/app/site/app/assets/locales/ko-KR-CG9UiY4o.js +1 -0
- package/web/app/site/app/assets/locales/lt-LT-CJcI6sHZ.js +1 -0
- package/web/app/site/app/assets/locales/nb-NO-CfheRbCu.js +1 -0
- package/web/app/site/app/assets/locales/nl-NL-DxN4Yc1t.js +1 -0
- package/web/app/site/app/assets/locales/oc-FR-CfheRbCu.js +1 -0
- package/web/app/site/app/assets/locales/pl-PL-DkwGXf6P.js +1 -0
- package/web/app/site/app/assets/locales/pseudo-LOCALE-CQ4XxkQ0.js +1 -0
- package/web/app/site/app/assets/locales/pt-BR-nGL0HdFS.js +1 -0
- package/web/app/site/app/assets/locales/pt-PT-Cp17aj2z.js +1 -0
- package/web/app/site/app/assets/locales/ru-RU-0Y8cIoaK.js +1 -0
- package/web/app/site/app/assets/locales/th-TH-CfheRbCu.js +1 -0
- package/web/app/site/app/assets/locales/tok-CfheRbCu.js +1 -0
- package/web/app/site/app/assets/locales/tr-TR-CfheRbCu.js +1 -0
- package/web/app/site/app/assets/locales/uk-UA-C5E3dFBf.js +1 -0
- package/web/app/site/app/assets/locales/zh-CN-BHJi7_et.js +1 -0
- package/web/app/site/app/assets/locales/zh-TW-CfheRbCu.js +1 -0
- package/web/app/site/app/assets/main-BdqNbG-a.js +26 -0
- package/web/app/site/app/assets/main-BdqNbG-a.js.map +1 -0
- package/web/app/site/app/assets/mock-home-DGhR__He.js +2 -0
- package/web/app/site/app/assets/mock-home-DGhR__He.js.map +1 -0
- package/web/app/site/app/assets/multi-column-ROck0NVt.jpg +0 -0
- package/web/app/site/app/assets/multi-hashtag-timeline-DH5INVHi.jpg +0 -0
- package/web/app/site/app/assets/nested-comments-thread-hv59kZLc.jpg +0 -0
- package/web/app/site/app/assets/polyfill-force-H8sUkLGp.js +2 -0
- package/web/app/site/app/assets/polyfill-force-H8sUkLGp.js.map +1 -0
- package/web/app/site/app/assets/pwa-viewport-ogKbF51K.js +20 -0
- package/web/app/site/app/assets/pwa-viewport-ogKbF51K.js.map +1 -0
- package/web/app/site/app/assets/style-B1iLPTPR.css +2 -0
- package/web/app/site/app/assets/temml-DuyIQMei.js +97 -0
- package/web/app/site/app/assets/temml-DuyIQMei.js.map +1 -0
- package/web/app/site/app/assets/tinyld.light.node-DtbBhLYC.js +2 -0
- package/web/app/site/app/assets/tinyld.light.node-DtbBhLYC.js.map +1 -0
- package/web/app/site/app/assets/year-in-posts-B2lywqdx.png +0 -0
- package/web/app/site/app/assets/year-in-posts-DJv_star.js +3 -0
- package/web/app/site/app/assets/year-in-posts-DJv_star.js.map +1 -0
- package/web/app/site/app/compose/index.html +20 -0
- package/web/app/site/app/favicon.ico +0 -0
- package/web/app/site/app/index.html +73 -0
- package/web/app/site/app/logo-192.png +0 -0
- package/web/app/site/app/logo-512.png +0 -0
- package/web/app/site/app/logo-badge-72.png +0 -0
- package/web/app/site/app/logo-maskable-512.png +0 -0
- package/web/app/site/app/logo-monochrome-512.png +0 -0
- package/web/app/site/app/logo-monochrome-maskable-512.png +0 -0
- package/web/app/site/app/manifest.webmanifest +1 -0
- package/web/app/site/app/og-image-2.jpg +0 -0
- package/web/app/site/app/og-image.png +0 -0
- package/web/app/site/app/robots.txt +2 -0
- package/web/app/site/app/version.json +1 -0
- package/web/app/site/boot.js +34067 -0
- package/web/app/site/index.html +203 -0
- package/web/app/site/sw.js +53745 -0
- package/web/app/sw-src.mjs +205 -0
- package/web/front/#new-account.html# +43 -0
- package/web/front/admin.html +1 -81
- package/web/front/admin.js +85 -0
- package/web/front/new-account.html +1 -9
- package/web/front/new-account.js +13 -0
- package/web/front/run.html +1 -92
- package/web/front/run.js +107 -0
- package/lib/podrdf.mjs +0 -132
package/lib/mastoapi.mjs
CHANGED
|
@@ -11,7 +11,9 @@
|
|
|
11
11
|
// that log is the running punch list.
|
|
12
12
|
|
|
13
13
|
import crypto from 'node:crypto';
|
|
14
|
+
import * as podMedia from './pod/media.mjs';
|
|
14
15
|
import * as social from './social.mjs';
|
|
16
|
+
import { isCrossSiteNavigation } from './guard.mjs';
|
|
15
17
|
import { sanitizeHtml, followsNeedApproval, publicHandle } from './wire.mjs';
|
|
16
18
|
import { authorOf } from './intake.mjs';
|
|
17
19
|
import { profileUrl, postUrl } from './bskyfeed.mjs';
|
|
@@ -74,13 +76,11 @@ const STUBS = new Map(Object.entries({
|
|
|
74
76
|
'/api/v1/filters': [],
|
|
75
77
|
'/api/v1/custom_emojis': [],
|
|
76
78
|
'/api/v1/announcements': [],
|
|
77
|
-
'/api/v1/follow_requests': [],
|
|
78
79
|
'/api/v1/instance/peers': [],
|
|
79
80
|
'/api/v1/trends/tags': [], '/api/v1/trends/links': [],
|
|
80
81
|
'/api/v2/suggestions': [],
|
|
81
82
|
'/api/v1/preferences': {},
|
|
82
|
-
|
|
83
|
-
}));
|
|
83
|
+
})); // followed_tags is served live from the tag feed, not stubbed
|
|
84
84
|
|
|
85
85
|
const TOKEN_TTL_MS = 90 * 24 * 60 * 60 * 1000; // tokens age out after 90 days
|
|
86
86
|
const AUTHZ_WINDOW_MS = 60_000;
|
|
@@ -88,10 +88,14 @@ const AUTHZ_MAX_ATTEMPTS = 5;
|
|
|
88
88
|
const CODE_TTL_MS = 5 * 60_000; // an authorization code is short-lived
|
|
89
89
|
const MAX_APPS = 200; // registered third-party clients, capped
|
|
90
90
|
const CLIENT_DOC_TTL_MS = 10 * 60_000; // how long a fetched client document is trusted
|
|
91
|
+
const CLIENT_DOC_MAX_CACHED = 200; // ids remembered at once; oldest out
|
|
92
|
+
const CLIENT_DOC_WINDOW_MS = 60_000;
|
|
93
|
+
const CLIENT_DOC_MAX_FETCHES = 20; // outbound lookups per window
|
|
91
94
|
const CLIENT_DOC_MAX = 64 * 1024; // it names a client; it is not a payload
|
|
92
95
|
|
|
93
96
|
export class MastoApi {
|
|
94
|
-
constructor({ agent, log = console.log, allowed = null, scheme = null, embedded = false
|
|
97
|
+
constructor({ agent, log = console.log, allowed = null, scheme = null, embedded = false,
|
|
98
|
+
streaming = true, webPush = true, scheduling = true }) {
|
|
95
99
|
this.agent = agent;
|
|
96
100
|
// A server-hosted identity has no CLI of its own, so the advice this gives
|
|
97
101
|
// when it refuses has to name the route that identity really has.
|
|
@@ -101,6 +105,29 @@ export class MastoApi {
|
|
|
101
105
|
// The scheme this identity is reached on, when the socket cannot say —
|
|
102
106
|
// a server behind a TLS proxy terminates cleartext and still is https.
|
|
103
107
|
this.scheme = scheme;
|
|
108
|
+
// Whether this deployment can serve the Mastodon streaming WebSocket. A
|
|
109
|
+
// Node agent can; the in-browser service-worker facade cannot (it is
|
|
110
|
+
// fetch-only), so it advertises NO streaming URL. Otherwise the client
|
|
111
|
+
// opens wss://localhost/... (the worker has no request host to name), which
|
|
112
|
+
// not only fails but, from a public origin, trips Chrome's private-network
|
|
113
|
+
// permission prompt ("access other apps and services on this device").
|
|
114
|
+
// With no streaming URL the client falls back to polling, which is served.
|
|
115
|
+
this.streaming = streaming;
|
|
116
|
+
// The same idea for two more capabilities the browser build does not have.
|
|
117
|
+
// A client decides what to offer from what the instance document says, so
|
|
118
|
+
// the honest thing is to say nothing rather than advertise and no-op.
|
|
119
|
+
//
|
|
120
|
+
// `webPush`: the browser build's web-push is a shim that mints a random
|
|
121
|
+
// placeholder VAPID key and whose sendNotification does nothing. Advertised,
|
|
122
|
+
// the client shows a notifications toggle that looks on and never fires.
|
|
123
|
+
// Omit `vapid` and it hides the toggle instead.
|
|
124
|
+
this.webPush = webPush;
|
|
125
|
+
// `scheduling`: a scheduled post is only a stored row until something
|
|
126
|
+
// publishes it when its time comes, and the only such tick is the Node
|
|
127
|
+
// agent's (run-agent.mjs). Where nothing ticks, accepting one is silent
|
|
128
|
+
// loss — the client says "scheduled" and the post never appears. Refusing
|
|
129
|
+
// it is worse UX and better behaviour.
|
|
130
|
+
this.scheduling = scheduling;
|
|
104
131
|
this.authzAttempts = []; // password-attempt timestamps
|
|
105
132
|
}
|
|
106
133
|
|
|
@@ -121,6 +148,7 @@ export class MastoApi {
|
|
|
121
148
|
// that leaves it empty is not merely unhelpful — clients read it without a
|
|
122
149
|
// guard and fall over, and every one of them loses live updates.
|
|
123
150
|
streamingUrl(req) {
|
|
151
|
+
if (!this.streaming) return null; // fetch-only facade: no WebSocket, no wss://localhost prompt
|
|
124
152
|
const host = req?.headers?.host || `localhost:${this.port || ''}`;
|
|
125
153
|
const secure = this.scheme
|
|
126
154
|
? this.scheme.startsWith('https')
|
|
@@ -140,14 +168,54 @@ export class MastoApi {
|
|
|
140
168
|
const now = Date.now();
|
|
141
169
|
return this.tokenRecords().filter(r => now - (r.createdAt || 0) < TOKEN_TTL_MS).map(r => r.token);
|
|
142
170
|
}
|
|
143
|
-
|
|
171
|
+
// `scope` is what the owner actually granted at /oauth/authorize. It used to
|
|
172
|
+
// be discarded: every token was full authority, so a client that asked for
|
|
173
|
+
// `read` could post, delete, and edit the profile. Recorded now, and enforced
|
|
174
|
+
// at the one gate every client route passes (see scopeFor / authed).
|
|
175
|
+
mintToken(scope = null) {
|
|
144
176
|
const t = crypto.randomBytes(24).toString('hex');
|
|
145
177
|
const now = Date.now();
|
|
146
178
|
const kept = this.tokenRecords().filter(r => now - (r.createdAt || 0) < TOKEN_TTL_MS);
|
|
147
|
-
this.store.write('masto-tokens.json',
|
|
179
|
+
this.store.write('masto-tokens.json',
|
|
180
|
+
[...kept, { token: t, createdAt: now, ...(scope ? { scope } : {}) }].slice(-20));
|
|
148
181
|
return t;
|
|
149
182
|
}
|
|
150
183
|
|
|
184
|
+
// What one request needs. Mastodon's four coarse scopes; a client that was
|
|
185
|
+
// granted a granular `write:statuses` satisfies `write` here, which is the
|
|
186
|
+
// direction that cannot let anything through that `write` would not.
|
|
187
|
+
//
|
|
188
|
+
// Reads are `read`, writes are `write`, and the two Mastodon carves out are
|
|
189
|
+
// kept: relationship changes accept the legacy `follow`, and push
|
|
190
|
+
// subscriptions want `push`. Nothing here grants across: `write` does NOT
|
|
191
|
+
// imply `read`, exactly as on Mastodon, so a write-only client cannot read
|
|
192
|
+
// the owner's direct messages.
|
|
193
|
+
static scopeFor(method, pathname) {
|
|
194
|
+
if (/^\/api\/v\d\/push\//u.test(pathname)) return 'push';
|
|
195
|
+
const relationship = /^\/api\/v1\/(accounts\/[a-f0-9]+\/(follow|unfollow|block|unblock|mute|unmute|remove_from_followers)|follow_requests\/)/u;
|
|
196
|
+
if (method !== 'GET' && method !== 'HEAD') {
|
|
197
|
+
return relationship.test(pathname) ? 'follow' : 'write';
|
|
198
|
+
}
|
|
199
|
+
return 'read';
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Whether a token's granted scopes satisfy `need`.
|
|
203
|
+
//
|
|
204
|
+
// A record with NO scope is a token minted before scopes were kept — it is
|
|
205
|
+
// full authority, because that is what it was granted, and quietly demoting
|
|
206
|
+
// live 90-day tokens would sign people out of working clients for a bug that
|
|
207
|
+
// was ours. New tokens all carry one.
|
|
208
|
+
static scopeAllows(granted, need) {
|
|
209
|
+
if (granted == null) return true; // pre-scope token
|
|
210
|
+
const have = String(granted).split(/[\s,+]+/u).filter(Boolean);
|
|
211
|
+
if (!have.length) return true;
|
|
212
|
+
if (need === 'follow') {
|
|
213
|
+
// Mastodon's `follow` is the legacy spelling; `write` covers it too.
|
|
214
|
+
return have.some((g) => g === 'follow' || g === 'write' || g.startsWith('write:'));
|
|
215
|
+
}
|
|
216
|
+
return have.some((g) => g === need || g.startsWith(`${need}:`));
|
|
217
|
+
}
|
|
218
|
+
|
|
151
219
|
// Registered OAuth apps. A third-party (browser) client registers here, and
|
|
152
220
|
// the authorization code it later receives is bound to the client_id and the
|
|
153
221
|
// redirect_uri it registered — so the code reaches only where that client
|
|
@@ -194,31 +262,55 @@ export class MastoApi {
|
|
|
194
262
|
if (!/^https:\/\//iu.test(String(clientId || ''))) return null; // cleartext is refused
|
|
195
263
|
this.clientDocs = this.clientDocs || new Map();
|
|
196
264
|
const seen = this.clientDocs.get(clientId);
|
|
265
|
+
// A cached FAILURE counts. Only successes were remembered, so a client id
|
|
266
|
+
// that 404s (or is not a client document at all) was re-fetched on every
|
|
267
|
+
// single call — and this runs on an unauthenticated GET, so a page could
|
|
268
|
+
// fire these in a loop and have the owner's machine hammer an address of
|
|
269
|
+
// the attacker's choosing, from the owner's IP, indefinitely.
|
|
197
270
|
if (seen && Date.now() - seen.at < CLIENT_DOC_TTL_MS) return seen.client;
|
|
271
|
+
// And a budget, because caching alone still lets a fresh id per request
|
|
272
|
+
// through. `safeFetch` keeps every one of these to a public address, so
|
|
273
|
+
// this is not internal SSRF — it is amplification, and a cap is what
|
|
274
|
+
// amplification needs.
|
|
275
|
+
if (!this._clientDocFetches || Date.now() - this._clientDocWindow > CLIENT_DOC_WINDOW_MS) {
|
|
276
|
+
this._clientDocWindow = Date.now();
|
|
277
|
+
this._clientDocFetches = 0;
|
|
278
|
+
}
|
|
279
|
+
if (this._clientDocFetches >= CLIENT_DOC_MAX_FETCHES) {
|
|
280
|
+
this.log(`client document ${clientId} not fetched: too many lookups this minute`);
|
|
281
|
+
return null;
|
|
282
|
+
}
|
|
283
|
+
this._clientDocFetches += 1;
|
|
284
|
+
// The Map has a TTL but had no ceiling, so it grew for as long as an
|
|
285
|
+
// attacker cared to serve distinct documents. Oldest out when it is full;
|
|
286
|
+
// Map iterates in insertion order, so the first key is the oldest.
|
|
287
|
+
if (this.clientDocs.size >= CLIENT_DOC_MAX_CACHED) {
|
|
288
|
+
this.clientDocs.delete(this.clientDocs.keys().next().value);
|
|
289
|
+
}
|
|
290
|
+
const remember = (client) => { this.clientDocs.set(clientId, { at: Date.now(), client }); return client; };
|
|
198
291
|
let doc;
|
|
199
292
|
try {
|
|
200
293
|
const res = await safeFetch(clientId, { headers: { accept: 'application/json' } });
|
|
201
|
-
if (res.status >= 400) { this.log(`client document ${clientId} → ${res.status}`); return null; }
|
|
294
|
+
if (res.status >= 400) { this.log(`client document ${clientId} → ${res.status}`); return remember(null); }
|
|
202
295
|
doc = JSON.parse(await readCapped(res, CLIENT_DOC_MAX));
|
|
203
296
|
} catch (e) {
|
|
204
297
|
this.log(`client document ${clientId} could not be read: ${e.message}`);
|
|
205
|
-
return null;
|
|
298
|
+
return remember(null);
|
|
206
299
|
}
|
|
207
300
|
// It must claim to be itself: a document naming some other id would let
|
|
208
301
|
// one client borrow another's name.
|
|
209
302
|
if (doc?.client_id !== clientId) {
|
|
210
303
|
this.log(`client document ${clientId} names ${doc?.client_id ?? 'nothing'} — refused`);
|
|
211
|
-
return null;
|
|
304
|
+
return remember(null);
|
|
212
305
|
}
|
|
213
306
|
const redirectUris = [].concat(doc.redirect_uris || []).filter((u) => typeof u === 'string');
|
|
214
|
-
if (!redirectUris.length) { this.log(`client document ${clientId} names no redirect — refused`); return null; }
|
|
307
|
+
if (!redirectUris.length) { this.log(`client document ${clientId} names no redirect — refused`); return remember(null); }
|
|
215
308
|
const client = {
|
|
216
309
|
clientId, redirectUris,
|
|
217
310
|
name: String(doc.client_name || clientId).slice(0, 200),
|
|
218
311
|
scopes: 'read write follow',
|
|
219
312
|
};
|
|
220
|
-
|
|
221
|
-
return client;
|
|
313
|
+
return remember(client);
|
|
222
314
|
}
|
|
223
315
|
|
|
224
316
|
/**
|
|
@@ -294,10 +386,15 @@ export class MastoApi {
|
|
|
294
386
|
if (rec) this.store.write('oauth-codes.json', all.filter(c => c.code !== code)); // single-use
|
|
295
387
|
return rec || null;
|
|
296
388
|
}
|
|
297
|
-
|
|
389
|
+
// The live record for the bearer on this request, or null.
|
|
390
|
+
tokenOf(req) {
|
|
298
391
|
const m = /^Bearer (.+)$/.exec(req.headers.authorization || '');
|
|
299
|
-
|
|
392
|
+
if (!m) return null;
|
|
393
|
+
const now = Date.now();
|
|
394
|
+
return this.tokenRecords().find(
|
|
395
|
+
(r) => r.token === m[1] && now - (r.createdAt || 0) < TOKEN_TTL_MS) || null;
|
|
300
396
|
}
|
|
397
|
+
authed(req) { return !!this.tokenOf(req); }
|
|
301
398
|
|
|
302
399
|
// A redirect_uri must name an authority this agent answers on — otherwise
|
|
303
400
|
// a visited page could navigate to /oauth/authorize and have the freshly
|
|
@@ -328,6 +425,14 @@ export class MastoApi {
|
|
|
328
425
|
return cfg?.handle ? `@${cfg.handle}@${this.host}` : 'FediPod';
|
|
329
426
|
}
|
|
330
427
|
|
|
428
|
+
// A Mastodon Tag object. The client reads `following` in its Followed
|
|
429
|
+
// Hashtags view and toggles it with the follow/unfollow endpoints. No usage
|
|
430
|
+
// history — a single-actor instance has no firehose stats to report.
|
|
431
|
+
tagObject(name, following, req) {
|
|
432
|
+
const host = req?.headers?.host || this.host;
|
|
433
|
+
return { name, url: `https://${host}/tags/${name}`, history: [], following: !!following };
|
|
434
|
+
}
|
|
435
|
+
|
|
331
436
|
instanceBlurb() {
|
|
332
437
|
const kind = this.store.getConfig()?.kind === 'group' ? 'group' : 'actor';
|
|
333
438
|
return `Solid pod ActivityPub ${kind}`;
|
|
@@ -575,9 +680,52 @@ export class MastoApi {
|
|
|
575
680
|
shortcode: e.shortcode, url: e.url, static_url: e.url, visible_in_picker: false,
|
|
576
681
|
})),
|
|
577
682
|
card: null, poll: s.poll ? this.pollJson(s) : null,
|
|
683
|
+
// What a filter matched, if any. Mastodon's clients read this and do the
|
|
684
|
+
// hiding or warning; they do NOT match keywords themselves — Phanpy does
|
|
685
|
+
// not — so filters that were stored and served but never applied were a
|
|
686
|
+
// setting that did nothing. The README named them as a feature.
|
|
687
|
+
filtered: this.filtersFor(s),
|
|
578
688
|
};
|
|
579
689
|
}
|
|
580
690
|
|
|
691
|
+
// v2 filters against one status. `context` is the timeline the client is
|
|
692
|
+
// showing, which we do not know here, so every non-expired filter is offered
|
|
693
|
+
// and the client drops the ones whose context does not match — the same
|
|
694
|
+
// information it uses to decide anyway.
|
|
695
|
+
filtersFor(s) {
|
|
696
|
+
const now = Date.now();
|
|
697
|
+
const hay = `${s.content || ''} ${s.spoiler || ''}`
|
|
698
|
+
.replace(/<[^>]*>/gu, ' ') // the text, not the markup around it
|
|
699
|
+
.toLowerCase();
|
|
700
|
+
if (!hay.trim()) return [];
|
|
701
|
+
const out = [];
|
|
702
|
+
// `status()` renders on every timeline read, including from the Bluesky and
|
|
703
|
+
// connected-account paths whose stores are narrower than the pod's. A
|
|
704
|
+
// missing filter list means no filters, never a thrown render.
|
|
705
|
+
for (const f of this.store.getFilters?.() || []) {
|
|
706
|
+
if (f.expiresAt && Date.parse(f.expiresAt) <= now) continue;
|
|
707
|
+
const hit = (f.keywords || []).filter((k) => {
|
|
708
|
+
const word = String(k.keyword || '').toLowerCase().trim();
|
|
709
|
+
if (!word) return false;
|
|
710
|
+
if (!k.wholeWord) return hay.includes(word);
|
|
711
|
+
// A whole word, by the same rule Mastodon uses: a boundary that is not
|
|
712
|
+
// itself a word character, at both ends.
|
|
713
|
+
const esc = word.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&');
|
|
714
|
+
return new RegExp(`(?:^|[^\\p{L}\\p{N}_])${esc}(?:[^\\p{L}\\p{N}_]|$)`, 'u').test(hay);
|
|
715
|
+
}).map((k) => k.keyword);
|
|
716
|
+
if (!hit.length) continue;
|
|
717
|
+
out.push({
|
|
718
|
+
filter: {
|
|
719
|
+
id: f.id, title: f.title, context: f.context || ['home'],
|
|
720
|
+
expires_at: f.expiresAt || null, filter_action: f.action || 'warn',
|
|
721
|
+
},
|
|
722
|
+
keyword_matches: hit,
|
|
723
|
+
status_matches: [],
|
|
724
|
+
});
|
|
725
|
+
}
|
|
726
|
+
return out;
|
|
727
|
+
}
|
|
728
|
+
|
|
581
729
|
// A timeline row's id may name the CARRY rather than the post — statusOrBoost
|
|
582
730
|
// mints `via:<noteId>` for the envelope. A client that asks about a row it
|
|
583
731
|
// was served has to get an answer: a 404 there makes it drop the row, which
|
|
@@ -662,7 +810,11 @@ export class MastoApi {
|
|
|
662
810
|
mediaJson(a) {
|
|
663
811
|
const kind = /^video\//.test(a.mediaType) ? 'video'
|
|
664
812
|
: /^audio\//.test(a.mediaType) ? 'audio'
|
|
665
|
-
|
|
813
|
+
// NOT 'gifv'. Mastodon uses gifv for a silent MP4 it transcoded a GIF
|
|
814
|
+
// into, and a client renders one with <video src>; handed the raw .gif
|
|
815
|
+
// URL, every GIF came out blank. Nothing here transcodes, so what we
|
|
816
|
+
// have is an image and saying so is what makes it show.
|
|
817
|
+
: 'image';
|
|
666
818
|
return {
|
|
667
819
|
id: a.id || this.store.idFor(a.url),
|
|
668
820
|
type: kind, url: a.url, preview_url: a.url, remote_url: null,
|
|
@@ -685,8 +837,14 @@ export class MastoApi {
|
|
|
685
837
|
};
|
|
686
838
|
}
|
|
687
839
|
|
|
840
|
+
// Ours is stored with a hyphen; Mastodon's API spells it with an underscore,
|
|
841
|
+
// and a client that does not know the type shows "Unknown notification type"
|
|
842
|
+
// — which is what every Mastodon client did with a follow request here. The
|
|
843
|
+
// stored spelling is left alone so existing state keeps reading.
|
|
844
|
+
notificationType(t) { return t === 'follow-request' ? 'follow_request' : t; }
|
|
845
|
+
|
|
688
846
|
notification(n) {
|
|
689
|
-
const out = { id: n.id, type: n.type, created_at: n.at, account: this.account(n.actor) };
|
|
847
|
+
const out = { id: n.id, type: this.notificationType(n.type), created_at: n.at, account: this.account(n.actor) };
|
|
690
848
|
if (n.noteId) {
|
|
691
849
|
const s = this.store.getStatuses().find(x => x.noteId === n.noteId);
|
|
692
850
|
if (s) out.status = this.status(s);
|
|
@@ -747,7 +905,7 @@ export class MastoApi {
|
|
|
747
905
|
id: app.clientId, name: app.name, website: app.website,
|
|
748
906
|
client_id: app.clientId, client_secret: app.clientSecret,
|
|
749
907
|
redirect_uri: redirectUris.join(' ') || 'urn:ietf:wg:oauth:2.0:oob',
|
|
750
|
-
vapid_key: this.push.publicKey(),
|
|
908
|
+
...(this.webPush ? { vapid_key: this.push.publicKey() } : {}),
|
|
751
909
|
});
|
|
752
910
|
}
|
|
753
911
|
if (pathname === '/oauth/authorize' && (req.method === 'GET' || req.method === 'POST')) {
|
|
@@ -768,6 +926,42 @@ export class MastoApi {
|
|
|
768
926
|
// code-is-the-token flow is only for the built-in client, which never
|
|
769
927
|
// registers.
|
|
770
928
|
const external = !!app || !!doc;
|
|
929
|
+
|
|
930
|
+
// S36. Two doors were open at once, and together they handed a bearer for
|
|
931
|
+
// this account to any page the owner happened to be reading.
|
|
932
|
+
//
|
|
933
|
+
// /api/v1/apps takes any redirect_uri from anyone — as it must, that is
|
|
934
|
+
// how a fediverse client registers — and the instant-authorize path below
|
|
935
|
+
// mints without a password whenever the request "is this machine". A page
|
|
936
|
+
// in another tab is this machine. So: register a client whose redirect is
|
|
937
|
+
// your own server, navigate the owner's browser to /oauth/authorize, and
|
|
938
|
+
// the code arrives at your address. The agent's own CA is in the browser's
|
|
939
|
+
// trust store, so https://localhost:<port> loads without a murmur.
|
|
940
|
+
//
|
|
941
|
+
// Two locks, either of which alone would do, both cheap:
|
|
942
|
+
//
|
|
943
|
+
// 1. A cross-site NAVIGATION may not reach the mint. A client signing in
|
|
944
|
+
// navigates from its own page and is same-site; a page on somebody
|
|
945
|
+
// else's site is not. (Absent Sec-Fetch-Site — curl, an old browser —
|
|
946
|
+
// is not cross-site and still passes, as everywhere else in this
|
|
947
|
+
// project; the second lock is what covers that case.)
|
|
948
|
+
if (isCrossSiteNavigation(req)) {
|
|
949
|
+
this.log(`authorize refused: cross-site navigation to the mint from ${req.headers.referer || 'nowhere'}`);
|
|
950
|
+
return send(403, { error: 'a cross-site navigation may not authorize a client' });
|
|
951
|
+
}
|
|
952
|
+
// 2. With NO password set there is nothing to authorize a third party
|
|
953
|
+
// WITH. The password-less path is honest only for a client on an
|
|
954
|
+
// address this agent itself answers on — where "whoever reaches the
|
|
955
|
+
// port is the owner" is a statement about the machine rather than
|
|
956
|
+
// about a web page. Anything pointing elsewhere has to be approved by
|
|
957
|
+
// somebody who knows the password.
|
|
958
|
+
if (external && !this.store.getConfig()?.uiPassword && !this.redirectAllowed(redirect)) {
|
|
959
|
+
this.log(`authorize refused: no UI password, and "${redirect}" is not an address of this agent`);
|
|
960
|
+
return send(403, {
|
|
961
|
+
error: 'this client asks to be sent somewhere other than this agent, and no password is '
|
|
962
|
+
+ 'set to approve that with. Run `fedipod passwd` and try again.',
|
|
963
|
+
});
|
|
964
|
+
}
|
|
771
965
|
const client = { name: app?.name || doc?.name || null, redirect, scope: params.get('scope') || 'read' };
|
|
772
966
|
if (app) {
|
|
773
967
|
if (!app.redirectUris.includes(redirect)) {
|
|
@@ -830,7 +1024,7 @@ export class MastoApi {
|
|
|
830
1024
|
? this.mintCode({ clientId: (app || doc).clientId, redirectUri: redirect, scope: client.scope,
|
|
831
1025
|
challenge: params.get('code_challenge') || null,
|
|
832
1026
|
challengeMethod: params.get('code_challenge_method') || null })
|
|
833
|
-
: this.mintToken();
|
|
1027
|
+
: this.mintToken(client.scope);
|
|
834
1028
|
if (!redirect || redirect === 'urn:ietf:wg:oauth:2.0:oob') return send(200, { code });
|
|
835
1029
|
const target = new URL(redirect);
|
|
836
1030
|
target.searchParams.set('code', code);
|
|
@@ -858,7 +1052,7 @@ export class MastoApi {
|
|
|
858
1052
|
this.log('token refused: the verifier does not answer the challenge this code was made with');
|
|
859
1053
|
return send(400, { error: 'invalid_grant' });
|
|
860
1054
|
}
|
|
861
|
-
return send(200, { access_token: this.mintToken(), token_type: 'Bearer',
|
|
1055
|
+
return send(200, { access_token: this.mintToken(rec.scope || 'read'), token_type: 'Bearer',
|
|
862
1056
|
scope: rec.scope || 'read', created_at: Math.floor(Date.now() / 1000),
|
|
863
1057
|
...(this.urls?.actor ? { activitypub_actor_id: this.urls.actor } : {}) });
|
|
864
1058
|
}
|
|
@@ -878,7 +1072,7 @@ export class MastoApi {
|
|
|
878
1072
|
this.log('token refused: the verifier does not answer the challenge this code was made with');
|
|
879
1073
|
return send(400, { error: 'invalid_grant' });
|
|
880
1074
|
}
|
|
881
|
-
return send(200, { access_token: this.mintToken(), token_type: 'Bearer',
|
|
1075
|
+
return send(200, { access_token: this.mintToken(rec.scope || 'read'), token_type: 'Bearer',
|
|
882
1076
|
scope: rec.scope || 'read', created_at: Math.floor(Date.now() / 1000),
|
|
883
1077
|
...(this.urls?.actor ? { activitypub_actor_id: this.urls.actor } : {}) });
|
|
884
1078
|
}
|
|
@@ -898,7 +1092,7 @@ export class MastoApi {
|
|
|
898
1092
|
this.log('token refused: this code was made with a challenge and the verifier does not answer it');
|
|
899
1093
|
return send(400, { error: 'invalid_grant' });
|
|
900
1094
|
}
|
|
901
|
-
return send(200, { access_token: this.mintToken(), token_type: 'Bearer',
|
|
1095
|
+
return send(200, { access_token: this.mintToken(rec.scope || 'read'), token_type: 'Bearer',
|
|
902
1096
|
scope: rec.scope || 'read', created_at: Math.floor(Date.now() / 1000),
|
|
903
1097
|
// Which actor the token acts for. A Mastodon client ignores it; an
|
|
904
1098
|
// ActivityPub API client needs it, and asking for it separately
|
|
@@ -914,8 +1108,13 @@ export class MastoApi {
|
|
|
914
1108
|
this.log('token refused: code is not a live authorization');
|
|
915
1109
|
return send(400, { error: 'invalid_grant' });
|
|
916
1110
|
}
|
|
1111
|
+
// The scope this token was actually minted with, not whatever the client
|
|
1112
|
+
// asks to be told — reporting one and enforcing another is how a client
|
|
1113
|
+
// ends up surprised by a 403 it was promised it would not get.
|
|
1114
|
+
const granted = this.tokenRecords().find((r) => r.token === body.code)?.scope
|
|
1115
|
+
|| 'read write follow push';
|
|
917
1116
|
return send(200, { access_token: body.code, token_type: 'Bearer',
|
|
918
|
-
scope:
|
|
1117
|
+
scope: granted, created_at: Math.floor(Date.now() / 1000),
|
|
919
1118
|
...(this.urls?.actor ? { activitypub_actor_id: this.urls.actor } : {}) });
|
|
920
1119
|
}
|
|
921
1120
|
if (pathname === '/oauth/revoke' && req.method === 'POST') {
|
|
@@ -936,10 +1135,11 @@ export class MastoApi {
|
|
|
936
1135
|
|
|
937
1136
|
// --- instance (public) ---
|
|
938
1137
|
if (pathname === '/api/v1/instance') {
|
|
1138
|
+
const su = this.streamingUrl(req);
|
|
939
1139
|
return send(200, {
|
|
940
1140
|
uri: this.host, title: this.instanceTitle(), short_description: this.instanceBlurb(),
|
|
941
1141
|
description: this.instanceBlurb(), email: '', version: '4.2.0 (compatible; fedipod)',
|
|
942
|
-
urls: { streaming_api:
|
|
1142
|
+
urls: su ? { streaming_api: su } : {},
|
|
943
1143
|
stats: { user_count: 1, status_count: this.store.countStatuses(), domain_count: 1 },
|
|
944
1144
|
languages: ['en'], registrations: false, approval_required: false, invites_enabled: false,
|
|
945
1145
|
configuration: instanceConfig(),
|
|
@@ -947,6 +1147,7 @@ export class MastoApi {
|
|
|
947
1147
|
});
|
|
948
1148
|
}
|
|
949
1149
|
if (pathname === '/api/v2/instance') {
|
|
1150
|
+
const su = this.streamingUrl(req);
|
|
950
1151
|
return send(200, {
|
|
951
1152
|
domain: this.host, title: this.instanceTitle(), version: '4.2.0 (compatible; fedipod)',
|
|
952
1153
|
source_url: 'https://github.com/jeff-zucker/FediPod', description: this.instanceBlurb(),
|
|
@@ -955,11 +1156,12 @@ export class MastoApi {
|
|
|
955
1156
|
languages: ['en'],
|
|
956
1157
|
// Both spellings: v2 clients read configuration.urls.streaming, older
|
|
957
1158
|
// ones the top-level urls.streaming_api, and some fall back blindly.
|
|
958
|
-
|
|
1159
|
+
// Omitted entirely when there is no streaming, so the client polls.
|
|
1160
|
+
urls: su ? { streaming_api: su } : {},
|
|
959
1161
|
configuration: {
|
|
960
1162
|
...instanceConfig(),
|
|
961
|
-
urls: { streaming:
|
|
962
|
-
vapid: { public_key: this.push.publicKey() },
|
|
1163
|
+
...(su ? { urls: { streaming: su } } : {}),
|
|
1164
|
+
...(this.webPush ? { vapid: { public_key: this.push.publicKey() } } : {}),
|
|
963
1165
|
},
|
|
964
1166
|
registrations: { enabled: false, approval_required: false, message: null },
|
|
965
1167
|
contact: { email: '', account: null }, rules: [],
|
|
@@ -970,7 +1172,16 @@ export class MastoApi {
|
|
|
970
1172
|
if (stub !== undefined && req.method === 'GET') return send(200, stub);
|
|
971
1173
|
|
|
972
1174
|
// --- everything below needs a bearer token + a configured agent ---
|
|
973
|
-
|
|
1175
|
+
const bearer = this.tokenOf(req);
|
|
1176
|
+
if (!bearer) return send(401, { error: 'The access token is invalid' });
|
|
1177
|
+
// One gate for every client route below, rather than a check in each of
|
|
1178
|
+
// the fifty-odd branches — which is how the scope came to be recorded and
|
|
1179
|
+
// never consulted in the first place.
|
|
1180
|
+
const need = MastoApi.scopeFor(req.method, pathname);
|
|
1181
|
+
if (!MastoApi.scopeAllows(bearer.scope, need)) {
|
|
1182
|
+
this.log(`refused ${req.method} ${pathname}: token has "${bearer.scope}", needs "${need}"`);
|
|
1183
|
+
return send(403, { error: `This action is outside the authorized scopes (needs ${need})` });
|
|
1184
|
+
}
|
|
974
1185
|
if (!this.agent.configured()) return send(503, { error: 'agent not configured' });
|
|
975
1186
|
// A viewer-mode agent (another agent holds the drain lease) may not act —
|
|
976
1187
|
// but a user acting HERE outranks the idle active agent elsewhere, so a
|
|
@@ -1013,7 +1224,7 @@ export class MastoApi {
|
|
|
1013
1224
|
const slug = new Date().toISOString().slice(0, 10) + '-' + crypto.randomBytes(4).toString('hex') + '.' + ext;
|
|
1014
1225
|
const url = this.urls.media + slug;
|
|
1015
1226
|
await this.agent.publisher.ensureMediaContainer();
|
|
1016
|
-
await this.agent.remote
|
|
1227
|
+
await podMedia.write(this.agent.remote, url, f.data, f.contentType);
|
|
1017
1228
|
return url;
|
|
1018
1229
|
};
|
|
1019
1230
|
|
|
@@ -1078,6 +1289,41 @@ export class MastoApi {
|
|
|
1078
1289
|
return send(200, page.map(s => this.statusOrBoost(s, { all })), headers);
|
|
1079
1290
|
}
|
|
1080
1291
|
|
|
1292
|
+
// --- hashtags: the client's Followed Hashtags surface drives the tag feed ---
|
|
1293
|
+
// followed_tags is what the feed currently pulls; follow/unfollow add and
|
|
1294
|
+
// remove a hashtag (unfollow the last and the topical feed goes quiet); the
|
|
1295
|
+
// tag timeline is the mirrored notes carrying that tag.
|
|
1296
|
+
if (pathname === '/api/v1/followed_tags') {
|
|
1297
|
+
const tags = this.agent.tagfeed?.config().tags || [];
|
|
1298
|
+
return send(200, tags.map(t => this.tagObject(t, true, req)));
|
|
1299
|
+
}
|
|
1300
|
+
const mTagFollow = pathname.match(/^\/api\/v1\/tags\/([^/]+)\/(follow|unfollow)$/);
|
|
1301
|
+
if (mTagFollow && req.method === 'POST') {
|
|
1302
|
+
const name = decodeURIComponent(mTagFollow[1]).replace(/^#/, '').toLowerCase();
|
|
1303
|
+
const follow = mTagFollow[2] === 'follow';
|
|
1304
|
+
const tf = this.agent.tagfeed;
|
|
1305
|
+
if (tf) {
|
|
1306
|
+
const cur = tf.config().tags;
|
|
1307
|
+
tf.setConfig({ tags: follow ? [...new Set([...cur, name])] : cur.filter(t => t !== name) });
|
|
1308
|
+
}
|
|
1309
|
+
return send(200, this.tagObject(name, follow, req));
|
|
1310
|
+
}
|
|
1311
|
+
const mTagGet = pathname.match(/^\/api\/v1\/tags\/([^/]+)$/);
|
|
1312
|
+
if (mTagGet && req.method === 'GET') {
|
|
1313
|
+
const name = decodeURIComponent(mTagGet[1]).replace(/^#/, '').toLowerCase();
|
|
1314
|
+
const following = (this.agent.tagfeed?.config().tags || []).includes(name);
|
|
1315
|
+
return send(200, this.tagObject(name, following, req));
|
|
1316
|
+
}
|
|
1317
|
+
const mTagTl = pathname.match(/^\/api\/v1\/timelines\/tag\/([^/]+)$/);
|
|
1318
|
+
if (mTagTl && req.method === 'GET') {
|
|
1319
|
+
const name = decodeURIComponent(mTagTl[1]).replace(/^#/, '').toLowerCase();
|
|
1320
|
+
const all = this.store.getStatuses();
|
|
1321
|
+
const items = all.filter(s => s.kind === 'tag' && s.tag === name)
|
|
1322
|
+
.sort((a, b) => String(b.published || '').localeCompare(String(a.published || '')));
|
|
1323
|
+
const { items: page, headers } = this.page(items, url);
|
|
1324
|
+
return send(200, page.map(s => this.statusOrBoost(s, { all })), headers);
|
|
1325
|
+
}
|
|
1326
|
+
|
|
1081
1327
|
if (pathname === '/api/v1/statuses' && req.method === 'POST') {
|
|
1082
1328
|
const body = await readBody(req);
|
|
1083
1329
|
if (!body.status) return send(422, { error: 'status text required' });
|
|
@@ -1140,6 +1386,12 @@ export class MastoApi {
|
|
|
1140
1386
|
}
|
|
1141
1387
|
|
|
1142
1388
|
if (body.scheduled_at) {
|
|
1389
|
+
// Nothing here publishes it when the time comes (see `scheduling` in the
|
|
1390
|
+
// constructor), so saying yes would be losing the post quietly.
|
|
1391
|
+
if (!this.scheduling) {
|
|
1392
|
+
return send(422, { error: 'this instance cannot schedule posts — it has no process '
|
|
1393
|
+
+ 'running between now and then to publish one. Post it when you want it sent.' });
|
|
1394
|
+
}
|
|
1143
1395
|
const at = Date.parse(body.scheduled_at);
|
|
1144
1396
|
if (!Number.isFinite(at) || at < Date.now() + 60_000) {
|
|
1145
1397
|
return send(422, { error: 'scheduled_at must be at least a minute from now' });
|
|
@@ -1369,7 +1621,9 @@ export class MastoApi {
|
|
|
1369
1621
|
return send(200, page.map(s => this.statusOrBoost(s, { all })), headers);
|
|
1370
1622
|
}
|
|
1371
1623
|
|
|
1372
|
-
// v2 filters: stored and
|
|
1624
|
+
// v2 filters: stored, served, and APPLIED — every status carries what it
|
|
1625
|
+
// matched in `filtered` (see filtersFor), because no client matches
|
|
1626
|
+
// keywords for itself.
|
|
1373
1627
|
const filterJson = (f) => ({
|
|
1374
1628
|
id: f.id, title: f.title, context: f.context || ['home'],
|
|
1375
1629
|
expires_at: f.expiresAt || null, filter_action: f.action || 'warn',
|
|
@@ -1519,15 +1773,94 @@ export class MastoApi {
|
|
|
1519
1773
|
return send(200, this.status(updated || s));
|
|
1520
1774
|
}
|
|
1521
1775
|
|
|
1776
|
+
// Follow requests. The queue, and the two answers to it, have existed since
|
|
1777
|
+
// groups did — `agent.store.getRequests()`, `admitRequest`, `refuseRequest`,
|
|
1778
|
+
// all driven from the record page — but the facade stubbed the list to `[]`
|
|
1779
|
+
// and offered no authorize/reject. So a locked account could see and answer
|
|
1780
|
+
// its requests in FediPod's own page and in NO Mastodon client: Phanpy,
|
|
1781
|
+
// Tuba and Whalebird all showed nothing waiting.
|
|
1782
|
+
if (pathname === '/api/v1/follow_requests' && req.method === 'GET') {
|
|
1783
|
+
const limit = Math.min(Number(url.searchParams.get('limit')) || 40, 80);
|
|
1784
|
+
return send(200, this.store.getRequests().slice(0, limit)
|
|
1785
|
+
.map((r) => this.account(r.actor)));
|
|
1786
|
+
}
|
|
1787
|
+
const mReq = /^\/api\/v1\/follow_requests\/([a-f0-9]+)\/(authorize|reject)$/.exec(pathname);
|
|
1788
|
+
if (mReq && req.method === 'POST') {
|
|
1789
|
+
// The client addresses an account by the id it was given for it, which is
|
|
1790
|
+
// this store's own hash of the actor URL — the same one every other
|
|
1791
|
+
// account route here uses.
|
|
1792
|
+
const actorUrl = this.store.urlFor(mReq[1]);
|
|
1793
|
+
if (!actorUrl) return send(404, { error: 'Record not found' });
|
|
1794
|
+
if (!this.store.getRequests().some((r) => r.actor === actorUrl)) {
|
|
1795
|
+
return send(404, { error: 'Record not found' });
|
|
1796
|
+
}
|
|
1797
|
+
try {
|
|
1798
|
+
if (mReq[2] === 'authorize') await social.admitRequest(this.agent, actorUrl);
|
|
1799
|
+
else await social.refuseRequest(this.agent, actorUrl);
|
|
1800
|
+
} catch (e) { return send(422, { error: e.message }); }
|
|
1801
|
+
await this.store.flush();
|
|
1802
|
+
return send(200, this.relationship(actorUrl));
|
|
1803
|
+
}
|
|
1804
|
+
|
|
1522
1805
|
if (pathname === '/api/v1/notifications') {
|
|
1523
1806
|
const limit = Math.min(Number(url.searchParams.get('limit')) || 30, 60);
|
|
1807
|
+
const q = url.searchParams;
|
|
1808
|
+
// Newest first, which is what every id rule below assumes.
|
|
1524
1809
|
let items = this.store.getNotifications();
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1810
|
+
|
|
1811
|
+
// Which KINDS. A client that asks for mentions and is handed favourites
|
|
1812
|
+
// and boosts renders them as mentions — Phanpy's Mentions column did
|
|
1813
|
+
// exactly that, and re-pilled on every poll. Both spellings: clients send
|
|
1814
|
+
// `types[]=mention` and some send bare `types=mention`.
|
|
1815
|
+
const listParam = (name) => {
|
|
1816
|
+
const all = [...q.getAll(`${name}[]`), ...q.getAll(name)]
|
|
1817
|
+
.flatMap((v) => String(v).split(',')).map((v) => v.trim()).filter(Boolean);
|
|
1818
|
+
return all.length ? new Set(all) : null;
|
|
1819
|
+
};
|
|
1820
|
+
const want = listParam('types');
|
|
1821
|
+
const skip = listParam('exclude_types');
|
|
1822
|
+
// Mastodon spells the type with an underscore; ours is stored with a
|
|
1823
|
+
// hyphen, and notification() translates on the way out — so match on what
|
|
1824
|
+
// the client would have been given, not on what is on disk.
|
|
1825
|
+
const shown = (n) => this.notificationType(n.type);
|
|
1826
|
+
if (want) items = items.filter((n) => want.has(shown(n)));
|
|
1827
|
+
if (skip) items = items.filter((n) => !skip.has(shown(n)));
|
|
1828
|
+
|
|
1829
|
+
// Which SLICE. `max_id` walks backwards into history; `since_id` and
|
|
1830
|
+
// `min_id` both mean "newer than this", which is how a client asks
|
|
1831
|
+
// "anything since I last looked?". Ignoring them meant every poll
|
|
1832
|
+
// returned the whole list, so the client always saw unread items and the
|
|
1833
|
+
// bell never went out.
|
|
1834
|
+
const cut = (id, keepNewer) => {
|
|
1835
|
+
const i = items.findIndex((n) => n.id === id);
|
|
1836
|
+
if (i < 0) return;
|
|
1837
|
+
items = keepNewer ? items.slice(0, i) : items.slice(i + 1);
|
|
1838
|
+
};
|
|
1839
|
+
const maxId = q.get('max_id');
|
|
1840
|
+
if (maxId) cut(maxId, false);
|
|
1841
|
+
const sinceId = q.get('since_id') || q.get('min_id');
|
|
1842
|
+
if (sinceId) cut(sinceId, true);
|
|
1843
|
+
// min_id asks for the ones IMMEDIATELY newer, i.e. the oldest end of
|
|
1844
|
+
// what is newer; since_id asks for the newest. Only the window differs.
|
|
1845
|
+
const page = q.get('min_id') && !q.get('since_id')
|
|
1846
|
+
? items.slice(Math.max(0, items.length - limit))
|
|
1847
|
+
: items.slice(0, limit);
|
|
1848
|
+
|
|
1849
|
+
// A client pages by following these rather than by guessing ids.
|
|
1850
|
+
if (page.length) {
|
|
1851
|
+
const base = `${this.scheme || (req.socket?.encrypted ? 'https' : 'http')}://${req.headers.host}${pathname}`;
|
|
1852
|
+
const link = (params) => {
|
|
1853
|
+
const u = new URL(base);
|
|
1854
|
+
for (const [k, v] of q) if (k !== 'max_id' && k !== 'since_id' && k !== 'min_id') u.searchParams.append(k, v);
|
|
1855
|
+
for (const [k, v] of Object.entries(params)) u.searchParams.set(k, v);
|
|
1856
|
+
return u.href;
|
|
1857
|
+
};
|
|
1858
|
+
return send(200, page.map((n) => this.notification(n)), {
|
|
1859
|
+
link: `<${link({ max_id: page[page.length - 1].id })}>; rel="next", `
|
|
1860
|
+
+ `<${link({ min_id: page[0].id })}>; rel="prev"`,
|
|
1861
|
+
});
|
|
1529
1862
|
}
|
|
1530
|
-
return send(200,
|
|
1863
|
+
return send(200, []);
|
|
1531
1864
|
}
|
|
1532
1865
|
|
|
1533
1866
|
if (pathname === '/api/v1/markers') {
|
|
@@ -1606,6 +1939,10 @@ export class MastoApi {
|
|
|
1606
1939
|
if (pathname === '/api/v1/push/subscription') {
|
|
1607
1940
|
const token = (/^Bearer (.+)$/.exec(req.headers.authorization || '') || [])[1];
|
|
1608
1941
|
if (!token) return send(401, { error: 'The access token is invalid' });
|
|
1942
|
+
// A client that ignores the missing `vapid` and subscribes anyway must
|
|
1943
|
+
// not be told it worked — a stored subscription nothing ever pushes to is
|
|
1944
|
+
// the same silent nothing the toggle was.
|
|
1945
|
+
if (!this.webPush) return send(422, { error: 'this instance does not send web push' });
|
|
1609
1946
|
if (req.method === 'GET') {
|
|
1610
1947
|
const sub = this.push.get(token);
|
|
1611
1948
|
return sub ? send(200, this.push.json(token, sub)) : send(404, { error: 'Record not found' });
|
|
@@ -1732,7 +2069,7 @@ export class MastoApi {
|
|
|
1732
2069
|
const slug = new Date().toISOString().slice(0, 10) + '-' + crypto.randomBytes(4).toString('hex') + '.' + ext;
|
|
1733
2070
|
const mediaUrl = this.urls.media + slug;
|
|
1734
2071
|
await this.agent.publisher.ensureMediaContainer();
|
|
1735
|
-
await this.agent.remote
|
|
2072
|
+
await podMedia.write(this.agent.remote, mediaUrl, file.data, mediaType);
|
|
1736
2073
|
const entry = { url: mediaUrl, mediaType, description: fields.description || '' };
|
|
1737
2074
|
const id = this.store.idFor(mediaUrl);
|
|
1738
2075
|
this.store.setMedia(id, entry);
|