fedipod-server 0.9.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/c2s.mjs +27 -2
- package/lib/deliver.mjs +6 -0
- package/lib/embed.mjs +1 -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 +357 -118
- package/lib/links.mjs +6 -32
- package/lib/mastoapi.mjs +449 -38
- 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/polls.mjs +105 -0
- package/lib/publisher.mjs +357 -188
- package/lib/remote.mjs +30 -395
- package/lib/social.mjs +20 -9
- package/lib/storage.mjs +2 -2
- package/lib/store.mjs +8 -1
- package/lib/tagfeed.mjs +19 -2
- package/lib/wire.mjs +87 -67
- package/package.json +1 -1
- package/run-agent.mjs +20 -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,12 +11,18 @@
|
|
|
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';
|
|
18
20
|
import { Push } from './webpush.mjs';
|
|
19
21
|
import { safeFetch, readCapped } from './safefetch.mjs';
|
|
22
|
+
import {
|
|
23
|
+
MAX_OPTIONS as POLL_MAX_OPTIONS, MAX_OPTION_CHARS as POLL_MAX_OPTION_CHARS,
|
|
24
|
+
MIN_SECONDS as POLL_MIN_SECONDS, MAX_SECONDS as POLL_MAX_SECONDS,
|
|
25
|
+
} from './polls.mjs';
|
|
20
26
|
|
|
21
27
|
// What an attachment is allowed to BE. Anything else is stored as bytes, which
|
|
22
28
|
// a browser downloads rather than runs.
|
|
@@ -27,6 +33,26 @@ const ATTACHMENT_KINDS = new Set(['image', 'video', 'audio']);
|
|
|
27
33
|
const NEVER = new Set(['image/svg+xml', 'image/svg']);
|
|
28
34
|
const OPAQUE = 'application/octet-stream';
|
|
29
35
|
|
|
36
|
+
/**
|
|
37
|
+
* A poll out of a compose request, or null when there is none. A JSON client
|
|
38
|
+
* sends a `poll` object; a form-encoded one spells the same thing out in
|
|
39
|
+
* Rails's bracket notation, which is the shape the option list arrives in.
|
|
40
|
+
*/
|
|
41
|
+
export function pollParams(body) {
|
|
42
|
+
const nested = body?.poll && typeof body.poll === 'object' ? body.poll : null;
|
|
43
|
+
const options = [].concat(nested?.options ?? body?.['poll[options][]'] ?? [])
|
|
44
|
+
.map(o => String(o ?? '').trim()).filter(Boolean);
|
|
45
|
+
const rawExpiry = nested?.expires_in ?? body?.['poll[expires_in]'];
|
|
46
|
+
const rawMultiple = nested?.multiple ?? body?.['poll[multiple]'];
|
|
47
|
+
if (!options.length && rawExpiry === undefined) return null;
|
|
48
|
+
return {
|
|
49
|
+
options,
|
|
50
|
+
expiresIn: rawExpiry === undefined || rawExpiry === null || rawExpiry === ''
|
|
51
|
+
? null : Number(rawExpiry),
|
|
52
|
+
multiple: rawMultiple === true || rawMultiple === 'true' || rawMultiple === '1',
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
30
56
|
export function attachmentType(claimed) {
|
|
31
57
|
const t = String(claimed || '').split(';')[0].trim().toLowerCase();
|
|
32
58
|
if (!/^[a-z0-9.+-]+\/[a-z0-9.+-]+$/.test(t) || NEVER.has(t)) return OPAQUE;
|
|
@@ -50,13 +76,11 @@ const STUBS = new Map(Object.entries({
|
|
|
50
76
|
'/api/v1/filters': [],
|
|
51
77
|
'/api/v1/custom_emojis': [],
|
|
52
78
|
'/api/v1/announcements': [],
|
|
53
|
-
'/api/v1/follow_requests': [],
|
|
54
79
|
'/api/v1/instance/peers': [],
|
|
55
80
|
'/api/v1/trends/tags': [], '/api/v1/trends/links': [],
|
|
56
81
|
'/api/v2/suggestions': [],
|
|
57
82
|
'/api/v1/preferences': {},
|
|
58
|
-
|
|
59
|
-
}));
|
|
83
|
+
})); // followed_tags is served live from the tag feed, not stubbed
|
|
60
84
|
|
|
61
85
|
const TOKEN_TTL_MS = 90 * 24 * 60 * 60 * 1000; // tokens age out after 90 days
|
|
62
86
|
const AUTHZ_WINDOW_MS = 60_000;
|
|
@@ -64,10 +88,14 @@ const AUTHZ_MAX_ATTEMPTS = 5;
|
|
|
64
88
|
const CODE_TTL_MS = 5 * 60_000; // an authorization code is short-lived
|
|
65
89
|
const MAX_APPS = 200; // registered third-party clients, capped
|
|
66
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
|
|
67
94
|
const CLIENT_DOC_MAX = 64 * 1024; // it names a client; it is not a payload
|
|
68
95
|
|
|
69
96
|
export class MastoApi {
|
|
70
|
-
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 }) {
|
|
71
99
|
this.agent = agent;
|
|
72
100
|
// A server-hosted identity has no CLI of its own, so the advice this gives
|
|
73
101
|
// when it refuses has to name the route that identity really has.
|
|
@@ -77,6 +105,29 @@ export class MastoApi {
|
|
|
77
105
|
// The scheme this identity is reached on, when the socket cannot say —
|
|
78
106
|
// a server behind a TLS proxy terminates cleartext and still is https.
|
|
79
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;
|
|
80
131
|
this.authzAttempts = []; // password-attempt timestamps
|
|
81
132
|
}
|
|
82
133
|
|
|
@@ -97,6 +148,7 @@ export class MastoApi {
|
|
|
97
148
|
// that leaves it empty is not merely unhelpful — clients read it without a
|
|
98
149
|
// guard and fall over, and every one of them loses live updates.
|
|
99
150
|
streamingUrl(req) {
|
|
151
|
+
if (!this.streaming) return null; // fetch-only facade: no WebSocket, no wss://localhost prompt
|
|
100
152
|
const host = req?.headers?.host || `localhost:${this.port || ''}`;
|
|
101
153
|
const secure = this.scheme
|
|
102
154
|
? this.scheme.startsWith('https')
|
|
@@ -116,14 +168,54 @@ export class MastoApi {
|
|
|
116
168
|
const now = Date.now();
|
|
117
169
|
return this.tokenRecords().filter(r => now - (r.createdAt || 0) < TOKEN_TTL_MS).map(r => r.token);
|
|
118
170
|
}
|
|
119
|
-
|
|
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) {
|
|
120
176
|
const t = crypto.randomBytes(24).toString('hex');
|
|
121
177
|
const now = Date.now();
|
|
122
178
|
const kept = this.tokenRecords().filter(r => now - (r.createdAt || 0) < TOKEN_TTL_MS);
|
|
123
|
-
this.store.write('masto-tokens.json',
|
|
179
|
+
this.store.write('masto-tokens.json',
|
|
180
|
+
[...kept, { token: t, createdAt: now, ...(scope ? { scope } : {}) }].slice(-20));
|
|
124
181
|
return t;
|
|
125
182
|
}
|
|
126
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
|
+
|
|
127
219
|
// Registered OAuth apps. A third-party (browser) client registers here, and
|
|
128
220
|
// the authorization code it later receives is bound to the client_id and the
|
|
129
221
|
// redirect_uri it registered — so the code reaches only where that client
|
|
@@ -170,31 +262,55 @@ export class MastoApi {
|
|
|
170
262
|
if (!/^https:\/\//iu.test(String(clientId || ''))) return null; // cleartext is refused
|
|
171
263
|
this.clientDocs = this.clientDocs || new Map();
|
|
172
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.
|
|
173
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; };
|
|
174
291
|
let doc;
|
|
175
292
|
try {
|
|
176
293
|
const res = await safeFetch(clientId, { headers: { accept: 'application/json' } });
|
|
177
|
-
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); }
|
|
178
295
|
doc = JSON.parse(await readCapped(res, CLIENT_DOC_MAX));
|
|
179
296
|
} catch (e) {
|
|
180
297
|
this.log(`client document ${clientId} could not be read: ${e.message}`);
|
|
181
|
-
return null;
|
|
298
|
+
return remember(null);
|
|
182
299
|
}
|
|
183
300
|
// It must claim to be itself: a document naming some other id would let
|
|
184
301
|
// one client borrow another's name.
|
|
185
302
|
if (doc?.client_id !== clientId) {
|
|
186
303
|
this.log(`client document ${clientId} names ${doc?.client_id ?? 'nothing'} — refused`);
|
|
187
|
-
return null;
|
|
304
|
+
return remember(null);
|
|
188
305
|
}
|
|
189
306
|
const redirectUris = [].concat(doc.redirect_uris || []).filter((u) => typeof u === 'string');
|
|
190
|
-
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); }
|
|
191
308
|
const client = {
|
|
192
309
|
clientId, redirectUris,
|
|
193
310
|
name: String(doc.client_name || clientId).slice(0, 200),
|
|
194
311
|
scopes: 'read write follow',
|
|
195
312
|
};
|
|
196
|
-
|
|
197
|
-
return client;
|
|
313
|
+
return remember(client);
|
|
198
314
|
}
|
|
199
315
|
|
|
200
316
|
/**
|
|
@@ -270,10 +386,15 @@ export class MastoApi {
|
|
|
270
386
|
if (rec) this.store.write('oauth-codes.json', all.filter(c => c.code !== code)); // single-use
|
|
271
387
|
return rec || null;
|
|
272
388
|
}
|
|
273
|
-
|
|
389
|
+
// The live record for the bearer on this request, or null.
|
|
390
|
+
tokenOf(req) {
|
|
274
391
|
const m = /^Bearer (.+)$/.exec(req.headers.authorization || '');
|
|
275
|
-
|
|
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;
|
|
276
396
|
}
|
|
397
|
+
authed(req) { return !!this.tokenOf(req); }
|
|
277
398
|
|
|
278
399
|
// A redirect_uri must name an authority this agent answers on — otherwise
|
|
279
400
|
// a visited page could navigate to /oauth/authorize and have the freshly
|
|
@@ -304,6 +425,14 @@ export class MastoApi {
|
|
|
304
425
|
return cfg?.handle ? `@${cfg.handle}@${this.host}` : 'FediPod';
|
|
305
426
|
}
|
|
306
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
|
+
|
|
307
436
|
instanceBlurb() {
|
|
308
437
|
const kind = this.store.getConfig()?.kind === 'group' ? 'group' : 'actor';
|
|
309
438
|
return `Solid pod ActivityPub ${kind}`;
|
|
@@ -551,9 +680,52 @@ export class MastoApi {
|
|
|
551
680
|
shortcode: e.shortcode, url: e.url, static_url: e.url, visible_in_picker: false,
|
|
552
681
|
})),
|
|
553
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),
|
|
554
688
|
};
|
|
555
689
|
}
|
|
556
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
|
+
|
|
557
729
|
// A timeline row's id may name the CARRY rather than the post — statusOrBoost
|
|
558
730
|
// mints `via:<noteId>` for the envelope. A client that asks about a row it
|
|
559
731
|
// was served has to get an answer: a 404 there makes it drop the row, which
|
|
@@ -628,7 +800,7 @@ export class MastoApi {
|
|
|
628
800
|
expires_at: s.poll.expiresAt || null,
|
|
629
801
|
expired: !!s.poll.closed || (!!s.poll.expiresAt && Date.parse(s.poll.expiresAt) < Date.now()),
|
|
630
802
|
multiple: !!s.poll.multiple,
|
|
631
|
-
votes_count: votes, voters_count: null,
|
|
803
|
+
votes_count: votes, voters_count: s.poll.votersCount ?? null,
|
|
632
804
|
options: opts.map(o => ({ title: o.title, votes_count: o.votes || 0 })),
|
|
633
805
|
voted: !!s.poll.voted, own_votes: s.poll.ownVotes || [],
|
|
634
806
|
emojis: [],
|
|
@@ -638,7 +810,11 @@ export class MastoApi {
|
|
|
638
810
|
mediaJson(a) {
|
|
639
811
|
const kind = /^video\//.test(a.mediaType) ? 'video'
|
|
640
812
|
: /^audio\//.test(a.mediaType) ? 'audio'
|
|
641
|
-
|
|
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';
|
|
642
818
|
return {
|
|
643
819
|
id: a.id || this.store.idFor(a.url),
|
|
644
820
|
type: kind, url: a.url, preview_url: a.url, remote_url: null,
|
|
@@ -661,8 +837,14 @@ export class MastoApi {
|
|
|
661
837
|
};
|
|
662
838
|
}
|
|
663
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
|
+
|
|
664
846
|
notification(n) {
|
|
665
|
-
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) };
|
|
666
848
|
if (n.noteId) {
|
|
667
849
|
const s = this.store.getStatuses().find(x => x.noteId === n.noteId);
|
|
668
850
|
if (s) out.status = this.status(s);
|
|
@@ -723,7 +905,7 @@ export class MastoApi {
|
|
|
723
905
|
id: app.clientId, name: app.name, website: app.website,
|
|
724
906
|
client_id: app.clientId, client_secret: app.clientSecret,
|
|
725
907
|
redirect_uri: redirectUris.join(' ') || 'urn:ietf:wg:oauth:2.0:oob',
|
|
726
|
-
vapid_key: this.push.publicKey(),
|
|
908
|
+
...(this.webPush ? { vapid_key: this.push.publicKey() } : {}),
|
|
727
909
|
});
|
|
728
910
|
}
|
|
729
911
|
if (pathname === '/oauth/authorize' && (req.method === 'GET' || req.method === 'POST')) {
|
|
@@ -744,6 +926,42 @@ export class MastoApi {
|
|
|
744
926
|
// code-is-the-token flow is only for the built-in client, which never
|
|
745
927
|
// registers.
|
|
746
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
|
+
}
|
|
747
965
|
const client = { name: app?.name || doc?.name || null, redirect, scope: params.get('scope') || 'read' };
|
|
748
966
|
if (app) {
|
|
749
967
|
if (!app.redirectUris.includes(redirect)) {
|
|
@@ -806,7 +1024,7 @@ export class MastoApi {
|
|
|
806
1024
|
? this.mintCode({ clientId: (app || doc).clientId, redirectUri: redirect, scope: client.scope,
|
|
807
1025
|
challenge: params.get('code_challenge') || null,
|
|
808
1026
|
challengeMethod: params.get('code_challenge_method') || null })
|
|
809
|
-
: this.mintToken();
|
|
1027
|
+
: this.mintToken(client.scope);
|
|
810
1028
|
if (!redirect || redirect === 'urn:ietf:wg:oauth:2.0:oob') return send(200, { code });
|
|
811
1029
|
const target = new URL(redirect);
|
|
812
1030
|
target.searchParams.set('code', code);
|
|
@@ -834,7 +1052,7 @@ export class MastoApi {
|
|
|
834
1052
|
this.log('token refused: the verifier does not answer the challenge this code was made with');
|
|
835
1053
|
return send(400, { error: 'invalid_grant' });
|
|
836
1054
|
}
|
|
837
|
-
return send(200, { access_token: this.mintToken(), token_type: 'Bearer',
|
|
1055
|
+
return send(200, { access_token: this.mintToken(rec.scope || 'read'), token_type: 'Bearer',
|
|
838
1056
|
scope: rec.scope || 'read', created_at: Math.floor(Date.now() / 1000),
|
|
839
1057
|
...(this.urls?.actor ? { activitypub_actor_id: this.urls.actor } : {}) });
|
|
840
1058
|
}
|
|
@@ -854,7 +1072,7 @@ export class MastoApi {
|
|
|
854
1072
|
this.log('token refused: the verifier does not answer the challenge this code was made with');
|
|
855
1073
|
return send(400, { error: 'invalid_grant' });
|
|
856
1074
|
}
|
|
857
|
-
return send(200, { access_token: this.mintToken(), token_type: 'Bearer',
|
|
1075
|
+
return send(200, { access_token: this.mintToken(rec.scope || 'read'), token_type: 'Bearer',
|
|
858
1076
|
scope: rec.scope || 'read', created_at: Math.floor(Date.now() / 1000),
|
|
859
1077
|
...(this.urls?.actor ? { activitypub_actor_id: this.urls.actor } : {}) });
|
|
860
1078
|
}
|
|
@@ -874,7 +1092,7 @@ export class MastoApi {
|
|
|
874
1092
|
this.log('token refused: this code was made with a challenge and the verifier does not answer it');
|
|
875
1093
|
return send(400, { error: 'invalid_grant' });
|
|
876
1094
|
}
|
|
877
|
-
return send(200, { access_token: this.mintToken(), token_type: 'Bearer',
|
|
1095
|
+
return send(200, { access_token: this.mintToken(rec.scope || 'read'), token_type: 'Bearer',
|
|
878
1096
|
scope: rec.scope || 'read', created_at: Math.floor(Date.now() / 1000),
|
|
879
1097
|
// Which actor the token acts for. A Mastodon client ignores it; an
|
|
880
1098
|
// ActivityPub API client needs it, and asking for it separately
|
|
@@ -890,8 +1108,13 @@ export class MastoApi {
|
|
|
890
1108
|
this.log('token refused: code is not a live authorization');
|
|
891
1109
|
return send(400, { error: 'invalid_grant' });
|
|
892
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';
|
|
893
1116
|
return send(200, { access_token: body.code, token_type: 'Bearer',
|
|
894
|
-
scope:
|
|
1117
|
+
scope: granted, created_at: Math.floor(Date.now() / 1000),
|
|
895
1118
|
...(this.urls?.actor ? { activitypub_actor_id: this.urls.actor } : {}) });
|
|
896
1119
|
}
|
|
897
1120
|
if (pathname === '/oauth/revoke' && req.method === 'POST') {
|
|
@@ -912,10 +1135,11 @@ export class MastoApi {
|
|
|
912
1135
|
|
|
913
1136
|
// --- instance (public) ---
|
|
914
1137
|
if (pathname === '/api/v1/instance') {
|
|
1138
|
+
const su = this.streamingUrl(req);
|
|
915
1139
|
return send(200, {
|
|
916
1140
|
uri: this.host, title: this.instanceTitle(), short_description: this.instanceBlurb(),
|
|
917
1141
|
description: this.instanceBlurb(), email: '', version: '4.2.0 (compatible; fedipod)',
|
|
918
|
-
urls: { streaming_api:
|
|
1142
|
+
urls: su ? { streaming_api: su } : {},
|
|
919
1143
|
stats: { user_count: 1, status_count: this.store.countStatuses(), domain_count: 1 },
|
|
920
1144
|
languages: ['en'], registrations: false, approval_required: false, invites_enabled: false,
|
|
921
1145
|
configuration: instanceConfig(),
|
|
@@ -923,6 +1147,7 @@ export class MastoApi {
|
|
|
923
1147
|
});
|
|
924
1148
|
}
|
|
925
1149
|
if (pathname === '/api/v2/instance') {
|
|
1150
|
+
const su = this.streamingUrl(req);
|
|
926
1151
|
return send(200, {
|
|
927
1152
|
domain: this.host, title: this.instanceTitle(), version: '4.2.0 (compatible; fedipod)',
|
|
928
1153
|
source_url: 'https://github.com/jeff-zucker/FediPod', description: this.instanceBlurb(),
|
|
@@ -931,11 +1156,12 @@ export class MastoApi {
|
|
|
931
1156
|
languages: ['en'],
|
|
932
1157
|
// Both spellings: v2 clients read configuration.urls.streaming, older
|
|
933
1158
|
// ones the top-level urls.streaming_api, and some fall back blindly.
|
|
934
|
-
|
|
1159
|
+
// Omitted entirely when there is no streaming, so the client polls.
|
|
1160
|
+
urls: su ? { streaming_api: su } : {},
|
|
935
1161
|
configuration: {
|
|
936
1162
|
...instanceConfig(),
|
|
937
|
-
urls: { streaming:
|
|
938
|
-
vapid: { public_key: this.push.publicKey() },
|
|
1163
|
+
...(su ? { urls: { streaming: su } } : {}),
|
|
1164
|
+
...(this.webPush ? { vapid: { public_key: this.push.publicKey() } } : {}),
|
|
939
1165
|
},
|
|
940
1166
|
registrations: { enabled: false, approval_required: false, message: null },
|
|
941
1167
|
contact: { email: '', account: null }, rules: [],
|
|
@@ -946,7 +1172,16 @@ export class MastoApi {
|
|
|
946
1172
|
if (stub !== undefined && req.method === 'GET') return send(200, stub);
|
|
947
1173
|
|
|
948
1174
|
// --- everything below needs a bearer token + a configured agent ---
|
|
949
|
-
|
|
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
|
+
}
|
|
950
1185
|
if (!this.agent.configured()) return send(503, { error: 'agent not configured' });
|
|
951
1186
|
// A viewer-mode agent (another agent holds the drain lease) may not act —
|
|
952
1187
|
// but a user acting HERE outranks the idle active agent elsewhere, so a
|
|
@@ -989,7 +1224,7 @@ export class MastoApi {
|
|
|
989
1224
|
const slug = new Date().toISOString().slice(0, 10) + '-' + crypto.randomBytes(4).toString('hex') + '.' + ext;
|
|
990
1225
|
const url = this.urls.media + slug;
|
|
991
1226
|
await this.agent.publisher.ensureMediaContainer();
|
|
992
|
-
await this.agent.remote
|
|
1227
|
+
await podMedia.write(this.agent.remote, url, f.data, f.contentType);
|
|
993
1228
|
return url;
|
|
994
1229
|
};
|
|
995
1230
|
|
|
@@ -1054,6 +1289,41 @@ export class MastoApi {
|
|
|
1054
1289
|
return send(200, page.map(s => this.statusOrBoost(s, { all })), headers);
|
|
1055
1290
|
}
|
|
1056
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
|
+
|
|
1057
1327
|
if (pathname === '/api/v1/statuses' && req.method === 'POST') {
|
|
1058
1328
|
const body = await readBody(req);
|
|
1059
1329
|
if (!body.status) return send(422, { error: 'status text required' });
|
|
@@ -1080,7 +1350,48 @@ export class MastoApi {
|
|
|
1080
1350
|
const mediaIds = [].concat(body.media_ids || body['media_ids[]'] || []).filter(Boolean);
|
|
1081
1351
|
const media = this.store.getMedia();
|
|
1082
1352
|
const attachments = mediaIds.map(id => media[id] && { id, ...media[id] }).filter(Boolean);
|
|
1353
|
+
|
|
1354
|
+
const asking = pollParams(body);
|
|
1355
|
+
if (asking) {
|
|
1356
|
+
// Mastodon's own rules, stated here rather than discovered inside the
|
|
1357
|
+
// publisher, so a client gets the reason back on the request it made.
|
|
1358
|
+
if (attachments.length) return send(422, { error: 'a poll cannot carry media' });
|
|
1359
|
+
if (body.scheduled_at) return send(422, { error: 'a poll cannot be scheduled' });
|
|
1360
|
+
if (asking.options.length < 2) return send(422, { error: 'a poll needs at least two options' });
|
|
1361
|
+
if (asking.options.length > POLL_MAX_OPTIONS) {
|
|
1362
|
+
return send(422, { error: `a poll takes at most ${POLL_MAX_OPTIONS} options` });
|
|
1363
|
+
}
|
|
1364
|
+
if (asking.options.some(o => o.length > POLL_MAX_OPTION_CHARS)) {
|
|
1365
|
+
return send(422, { error: `a poll option is at most ${POLL_MAX_OPTION_CHARS} characters` });
|
|
1366
|
+
}
|
|
1367
|
+
if (new Set(asking.options).size !== asking.options.length) {
|
|
1368
|
+
return send(422, { error: 'a poll\u2019s options must differ from one another' });
|
|
1369
|
+
}
|
|
1370
|
+
const seconds = asking.expiresIn ?? POLL_MAX_SECONDS;
|
|
1371
|
+
if (!Number.isFinite(seconds) || seconds < POLL_MIN_SECONDS || seconds > POLL_MAX_SECONDS) {
|
|
1372
|
+
return send(422, {
|
|
1373
|
+
error: `a poll runs between ${POLL_MIN_SECONDS} and ${POLL_MAX_SECONDS} seconds`,
|
|
1374
|
+
});
|
|
1375
|
+
}
|
|
1376
|
+
try {
|
|
1377
|
+
const q = await this.agent.publisher.publishQuestion(body.status, {
|
|
1378
|
+
options: asking.options, multiple: asking.multiple,
|
|
1379
|
+
expiresAt: new Date(Date.now() + seconds * 1000).toISOString(),
|
|
1380
|
+
inReplyTo, visibility, spoilerText,
|
|
1381
|
+
});
|
|
1382
|
+
return send(200, this.status(this.store.getStatuses().find(x => x.noteId === q.id)));
|
|
1383
|
+
} catch (e) {
|
|
1384
|
+
return send(422, { error: e.message });
|
|
1385
|
+
}
|
|
1386
|
+
}
|
|
1387
|
+
|
|
1083
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
|
+
}
|
|
1084
1395
|
const at = Date.parse(body.scheduled_at);
|
|
1085
1396
|
if (!Number.isFinite(at) || at < Date.now() + 60_000) {
|
|
1086
1397
|
return send(422, { error: 'scheduled_at must be at least a minute from now' });
|
|
@@ -1310,7 +1621,9 @@ export class MastoApi {
|
|
|
1310
1621
|
return send(200, page.map(s => this.statusOrBoost(s, { all })), headers);
|
|
1311
1622
|
}
|
|
1312
1623
|
|
|
1313
|
-
// 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.
|
|
1314
1627
|
const filterJson = (f) => ({
|
|
1315
1628
|
id: f.id, title: f.title, context: f.context || ['home'],
|
|
1316
1629
|
expires_at: f.expiresAt || null, filter_action: f.action || 'warn',
|
|
@@ -1460,15 +1773,94 @@ export class MastoApi {
|
|
|
1460
1773
|
return send(200, this.status(updated || s));
|
|
1461
1774
|
}
|
|
1462
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
|
+
|
|
1463
1805
|
if (pathname === '/api/v1/notifications') {
|
|
1464
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.
|
|
1465
1809
|
let items = this.store.getNotifications();
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
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
|
+
});
|
|
1470
1862
|
}
|
|
1471
|
-
return send(200,
|
|
1863
|
+
return send(200, []);
|
|
1472
1864
|
}
|
|
1473
1865
|
|
|
1474
1866
|
if (pathname === '/api/v1/markers') {
|
|
@@ -1547,6 +1939,10 @@ export class MastoApi {
|
|
|
1547
1939
|
if (pathname === '/api/v1/push/subscription') {
|
|
1548
1940
|
const token = (/^Bearer (.+)$/.exec(req.headers.authorization || '') || [])[1];
|
|
1549
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' });
|
|
1550
1946
|
if (req.method === 'GET') {
|
|
1551
1947
|
const sub = this.push.get(token);
|
|
1552
1948
|
return sub ? send(200, this.push.json(token, sub)) : send(404, { error: 'Record not found' });
|
|
@@ -1673,7 +2069,7 @@ export class MastoApi {
|
|
|
1673
2069
|
const slug = new Date().toISOString().slice(0, 10) + '-' + crypto.randomBytes(4).toString('hex') + '.' + ext;
|
|
1674
2070
|
const mediaUrl = this.urls.media + slug;
|
|
1675
2071
|
await this.agent.publisher.ensureMediaContainer();
|
|
1676
|
-
await this.agent.remote
|
|
2072
|
+
await podMedia.write(this.agent.remote, mediaUrl, file.data, mediaType);
|
|
1677
2073
|
const entry = { url: mediaUrl, mediaType, description: fields.description || '' };
|
|
1678
2074
|
const id = this.store.idFor(mediaUrl);
|
|
1679
2075
|
this.store.setMedia(id, entry);
|
|
@@ -1707,7 +2103,12 @@ function instanceConfig() {
|
|
|
1707
2103
|
image_size_limit: 10 * 1024 * 1024, video_size_limit: 40 * 1024 * 1024,
|
|
1708
2104
|
image_matrix_limit: 16777216, video_matrix_limit: 2304000,
|
|
1709
2105
|
},
|
|
1710
|
-
polls: {
|
|
2106
|
+
polls: {
|
|
2107
|
+
max_options: POLL_MAX_OPTIONS,
|
|
2108
|
+
max_characters_per_option: POLL_MAX_OPTION_CHARS,
|
|
2109
|
+
min_expiration: POLL_MIN_SECONDS,
|
|
2110
|
+
max_expiration: POLL_MAX_SECONDS,
|
|
2111
|
+
},
|
|
1711
2112
|
accounts: { max_featured_tags: 0 },
|
|
1712
2113
|
};
|
|
1713
2114
|
}
|
|
@@ -1865,7 +2266,17 @@ function readBody(req) {
|
|
|
1865
2266
|
const ct = String(req.headers['content-type'] || '');
|
|
1866
2267
|
try {
|
|
1867
2268
|
if (ct.includes('application/json')) return resolve(data ? JSON.parse(data) : {});
|
|
1868
|
-
|
|
2269
|
+
// A form-encoded list is the same key repeated, spelled with a
|
|
2270
|
+
// trailing `[]`. Reading it as a plain object kept only the last one,
|
|
2271
|
+
// so a client sending its poll or its media that way lost all but the
|
|
2272
|
+
// final value. Only the `[]` keys become lists: everything else keeps
|
|
2273
|
+
// the single value the rest of this file reads.
|
|
2274
|
+
const form = new URLSearchParams(data);
|
|
2275
|
+
const out = {};
|
|
2276
|
+
for (const key of new Set(form.keys())) {
|
|
2277
|
+
out[key] = key.endsWith('[]') ? form.getAll(key) : form.get(key);
|
|
2278
|
+
}
|
|
2279
|
+
resolve(out);
|
|
1869
2280
|
} catch (e) { reject(e); }
|
|
1870
2281
|
});
|
|
1871
2282
|
req.on('error', reject);
|