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.
Files changed (319) hide show
  1. package/dist/claims.js +1 -1
  2. package/dist/handler.d.ts +1 -1
  3. package/dist/handler.js +35 -2
  4. package/dist/handler.jsonld +1 -1
  5. package/lib/admin.mjs +3 -3
  6. package/lib/c2s.mjs +27 -2
  7. package/lib/deliver.mjs +6 -0
  8. package/lib/embed.mjs +1 -0
  9. package/lib/export-collections.mjs +1 -1
  10. package/lib/front-core.mjs +272 -49
  11. package/lib/gateway-core.mjs +3 -7
  12. package/lib/httpsig.mjs +33 -2
  13. package/lib/import.mjs +1 -1
  14. package/lib/intake.mjs +357 -118
  15. package/lib/links.mjs +6 -32
  16. package/lib/mastoapi.mjs +449 -38
  17. package/lib/migrate.mjs +4 -18
  18. package/lib/pod/README.md +61 -0
  19. package/lib/pod/actor.mjs +96 -0
  20. package/lib/pod/collection.mjs +77 -0
  21. package/lib/pod/containers.mjs +136 -0
  22. package/lib/pod/discovery.mjs +46 -0
  23. package/lib/pod/featured.mjs +18 -0
  24. package/lib/pod/followers.mjs +24 -0
  25. package/lib/pod/following.mjs +10 -0
  26. package/lib/pod/http.mjs +74 -0
  27. package/lib/pod/inbox.mjs +144 -0
  28. package/lib/pod/links.mjs +35 -0
  29. package/lib/pod/media.mjs +27 -0
  30. package/lib/pod/notes.mjs +80 -0
  31. package/lib/pod/notifications.mjs +89 -0
  32. package/lib/pod/outbox.mjs +18 -0
  33. package/lib/pod/package.json +19 -0
  34. package/lib/pod/policy.mjs +58 -0
  35. package/lib/pod/private.mjs +19 -0
  36. package/lib/pod/root.mjs +63 -0
  37. package/lib/pod/state.mjs +39 -0
  38. package/lib/pod/transport.mjs +477 -0
  39. package/lib/pod/urls.mjs +71 -0
  40. package/lib/polls.mjs +105 -0
  41. package/lib/publisher.mjs +357 -188
  42. package/lib/remote.mjs +30 -395
  43. package/lib/social.mjs +20 -9
  44. package/lib/storage.mjs +2 -2
  45. package/lib/store.mjs +8 -1
  46. package/lib/tagfeed.mjs +19 -2
  47. package/lib/wire.mjs +87 -67
  48. package/package.json +1 -1
  49. package/run-agent.mjs +20 -39
  50. package/web/admin/admin.js +141 -74
  51. package/web/admin/bar.css +9 -13
  52. package/web/admin/bar.js +2 -2
  53. package/web/admin/client/client.js +1 -1
  54. package/web/admin/client/index.html +5 -6
  55. package/web/admin/index.html +53 -65
  56. package/web/admin/setup/index.html +2 -1
  57. package/web/admin/setup/setup.js +1 -1
  58. package/web/admin/tokens.css +56 -0
  59. package/web/app/README.md +77 -0
  60. package/web/app/admin-facade.mjs +549 -0
  61. package/web/app/agent.mjs +490 -0
  62. package/web/app/atproto-browser.mjs +86 -0
  63. package/web/app/boot.mjs +340 -0
  64. package/web/app/deliver-relay.mjs +72 -0
  65. package/web/app/dist/boot.js +34067 -0
  66. package/web/app/dist/boot.js.map +7 -0
  67. package/web/app/dist/sw.js +53745 -0
  68. package/web/app/dist/sw.js.map +7 -0
  69. package/web/app/fediacct-browser.mjs +146 -0
  70. package/web/app/idb-kv.mjs +35 -0
  71. package/web/app/index.html +203 -0
  72. package/web/app/index.html~ +152 -0
  73. package/web/app/keys-browser.mjs +46 -0
  74. package/web/app/keystore.mjs +99 -0
  75. package/web/app/oidc-session.mjs +189 -0
  76. package/web/app/pod-auth.mjs +248 -0
  77. package/web/app/pod-remote.mjs +84 -0
  78. package/web/app/shims/_globals.mjs +2 -0
  79. package/web/app/shims/fedify-sig.mjs +77 -0
  80. package/web/app/shims/node-crypto.mjs +113 -0
  81. package/web/app/shims/node-fs.mjs +17 -0
  82. package/web/app/shims/node-path.mjs +19 -0
  83. package/web/app/shims/node-url.mjs +7 -0
  84. package/web/app/shims/prelude.js +58 -0
  85. package/web/app/shims/safefetch.mjs +58 -0
  86. package/web/app/shims/web-push.mjs +14 -0
  87. package/web/app/signup.mjs +248 -0
  88. package/web/app/site/_headers +9 -0
  89. package/web/app/site/_redirects +21 -0
  90. package/web/app/site/admin/admin.js +1181 -0
  91. package/web/app/site/admin/bar.css +60 -0
  92. package/web/app/site/admin/bar.js +61 -0
  93. package/web/app/site/admin/client/client.js +66 -0
  94. package/web/app/site/admin/client/index.html +43 -0
  95. package/web/app/site/admin/index.html +556 -0
  96. package/web/app/site/admin/setup/index.html +191 -0
  97. package/web/app/site/admin/setup/setup.js +308 -0
  98. package/web/app/site/admin/tokens.css +56 -0
  99. package/web/app/site/admin/window.css +55 -0
  100. package/web/app/site/admin/window.js +136 -0
  101. package/web/app/site/app/404.html +40 -0
  102. package/web/app/site/app/_headers +2 -0
  103. package/web/app/site/app/apple-touch-icon.png +0 -0
  104. package/web/app/site/app/assets/ICONS-DIPJeU0C.js +2 -0
  105. package/web/app/site/app/assets/ICONS-DIPJeU0C.js.map +1 -0
  106. package/web/app/site/app/assets/ScheduledAtField-D-Abfo4J.js +183 -0
  107. package/web/app/site/app/assets/ScheduledAtField-D-Abfo4J.js.map +1 -0
  108. package/web/app/site/app/assets/Temml-CCjoacWt.woff2 +0 -0
  109. package/web/app/site/app/assets/__vite-browser-external-C7iut881.js +2 -0
  110. package/web/app/site/app/assets/__vite-browser-external-C7iut881.js.map +1 -0
  111. package/web/app/site/app/assets/boosts-carousel-BiOaNdMT.jpg +0 -0
  112. package/web/app/site/app/assets/catch-up-O5q5C75Z.png +0 -0
  113. package/web/app/site/app/assets/chunk-aKtaBQYM.js +1 -0
  114. package/web/app/site/app/assets/compose-CFEx7wb4.js +2 -0
  115. package/web/app/site/app/assets/compose-CFEx7wb4.js.map +1 -0
  116. package/web/app/site/app/assets/compose-ZJN5xSb4.js +35 -0
  117. package/web/app/site/app/assets/compose-ZJN5xSb4.js.map +1 -0
  118. package/web/app/site/app/assets/debug.module-YFQgwnIr.js +52 -0
  119. package/web/app/site/app/assets/debug.module-YFQgwnIr.js.map +1 -0
  120. package/web/app/site/app/assets/dom-p1YcJ5Rw.js +2 -0
  121. package/web/app/site/app/assets/dom-p1YcJ5Rw.js.map +1 -0
  122. package/web/app/site/app/assets/home-mobile-dark@2x-Dc-J8-SM.png +0 -0
  123. package/web/app/site/app/assets/home-mobile-light@2x-Bd3fod8m.png +0 -0
  124. package/web/app/site/app/assets/home-tablet-dark@2x-BgcNI8le.png +0 -0
  125. package/web/app/site/app/assets/home-tablet-light@2x-BW2RV9T6.png +0 -0
  126. package/web/app/site/app/assets/icons/add-circle-line-WD0mwORD.js +1 -0
  127. package/web/app/site/app/assets/icons/alert-line-CpFUMf9s.js +1 -0
  128. package/web/app/site/app/assets/icons/android-2-line-Bzxnrd3S.js +1 -0
  129. package/web/app/site/app/assets/icons/announcement-line-5nwaJFJm.js +1 -0
  130. package/web/app/site/app/assets/icons/arrow-down-circle-line-a7Id9-Iv.js +1 -0
  131. package/web/app/site/app/assets/icons/arrow-down-line-B8uzXzq6.js +1 -0
  132. package/web/app/site/app/assets/icons/arrow-left-line-CGrGT9C-.js +1 -0
  133. package/web/app/site/app/assets/icons/arrow-right-line-CE-HERfX.js +1 -0
  134. package/web/app/site/app/assets/icons/arrow-to-up-line-CvdQjmcy.js +1 -0
  135. package/web/app/site/app/assets/icons/arrow-up-circle-line-DEparSNF.js +1 -0
  136. package/web/app/site/app/assets/icons/arrow-up-line-C7yBeugc.js +1 -0
  137. package/web/app/site/app/assets/icons/arrows-right-line-BtbXmJGP.js +1 -0
  138. package/web/app/site/app/assets/icons/at-line-CF66Bznk.js +1 -0
  139. package/web/app/site/app/assets/icons/attachment-line-BY8It-l9.js +1 -0
  140. package/web/app/site/app/assets/icons/blockquote-line-BS6pU_hm.js +1 -0
  141. package/web/app/site/app/assets/icons/board-line-C0UWXyA6.js +1 -0
  142. package/web/app/site/app/assets/icons/bookmark-line-1CtX5JLF.js +1 -0
  143. package/web/app/site/app/assets/icons/building-5-line-Dd59MzbO.js +1 -0
  144. package/web/app/site/app/assets/icons/bus-2-line-6_dvA0GG.js +1 -0
  145. package/web/app/site/app/assets/icons/calendar-day-line-Crpl_fmu.js +1 -0
  146. package/web/app/site/app/assets/icons/calendar-month-line-vommts2C.js +1 -0
  147. package/web/app/site/app/assets/icons/calendar-time-add-line-BU_DTlQk.js +1 -0
  148. package/web/app/site/app/assets/icons/camera-line-Cv69MEei.js +1 -0
  149. package/web/app/site/app/assets/icons/celebrate-line-B8DvIoi0.js +1 -0
  150. package/web/app/site/app/assets/icons/chart-bar-line-FK-QOmtF.js +1 -0
  151. package/web/app/site/app/assets/icons/chart-line-line-BGcJAFWw.js +1 -0
  152. package/web/app/site/app/assets/icons/chat-3-line-YFK2-hwz.js +1 -0
  153. package/web/app/site/app/assets/icons/check-circle-line-DsAxFr5-.js +1 -0
  154. package/web/app/site/app/assets/icons/clipboard-line-BBp364gJ.js +1 -0
  155. package/web/app/site/app/assets/icons/close-circle-line-CICdiuGz.js +1 -0
  156. package/web/app/site/app/assets/icons/close-line-DYrFWcjv.js +1 -0
  157. package/web/app/site/app/assets/icons/cloud-line-tiQAbah8.js +1 -0
  158. package/web/app/site/app/assets/icons/code-line-fAl8iiV3.js +1 -0
  159. package/web/app/site/app/assets/icons/comment-2-line-CR3g7WeB.js +1 -0
  160. package/web/app/site/app/assets/icons/copy-2-line-CLW69yF5.js +1 -0
  161. package/web/app/site/app/assets/icons/delete-2-line-B8xmbr4n.js +1 -0
  162. package/web/app/site/app/assets/icons/document-3-line-DBGNhAg2.js +1 -0
  163. package/web/app/site/app/assets/icons/document-line-C7mb9sb7.js +1 -0
  164. package/web/app/site/app/assets/icons/down-line-BGqSSThd.js +1 -0
  165. package/web/app/site/app/assets/icons/edit-4-line-DcgGaEhx.js +1 -0
  166. package/web/app/site/app/assets/icons/emoji-2-line-D8YgnM-y.js +1 -0
  167. package/web/app/site/app/assets/icons/exit-line-Dc07BS-x.js +1 -0
  168. package/web/app/site/app/assets/icons/external-link-line-BvpSkmjO.js +1 -0
  169. package/web/app/site/app/assets/icons/eye-2-line-CiWXx-f_.js +1 -0
  170. package/web/app/site/app/assets/icons/eye-close-line-BgTaSJ4P.js +1 -0
  171. package/web/app/site/app/assets/icons/filter-2-line-B_mR2usy.js +1 -0
  172. package/web/app/site/app/assets/icons/filter-line-C_nm-3cU.js +1 -0
  173. package/web/app/site/app/assets/icons/flag-1-line-C2HB05I0.js +1 -0
  174. package/web/app/site/app/assets/icons/forbid-circle-line-D3LTT9yd.js +1 -0
  175. package/web/app/site/app/assets/icons/formula-line-DLrt2d6j.js +1 -0
  176. package/web/app/site/app/assets/icons/grid-line-CgUmEikf.js +1 -0
  177. package/web/app/site/app/assets/icons/group-line-C5fnX0wU.js +1 -0
  178. package/web/app/site/app/assets/icons/hand-finger-2-line-BGgxbuL9.js +1 -0
  179. package/web/app/site/app/assets/icons/hashtag-line-CyTEEXBb.js +1 -0
  180. package/web/app/site/app/assets/icons/heart-crack-line-BZddbO2C.js +1 -0
  181. package/web/app/site/app/assets/icons/heart-line-CJ7K47J4.js +1 -0
  182. package/web/app/site/app/assets/icons/history-2-line-kWWvFzqv.js +1 -0
  183. package/web/app/site/app/assets/icons/history-line-bGQiwbTH.js +1 -0
  184. package/web/app/site/app/assets/icons/home-3-line-fRU1zeLG.js +1 -0
  185. package/web/app/site/app/assets/icons/information-line-iNk6-sOY.js +1 -0
  186. package/web/app/site/app/assets/icons/keyboard-line-DsCyuQNz.js +1 -0
  187. package/web/app/site/app/assets/icons/layout-4-line-CZkOX52Z.js +1 -0
  188. package/web/app/site/app/assets/icons/layout-5-line-B_znt5wO.js +1 -0
  189. package/web/app/site/app/assets/icons/left-line-eUw0tya4.js +1 -0
  190. package/web/app/site/app/assets/icons/lightning-line-DaKoDdix.js +1 -0
  191. package/web/app/site/app/assets/icons/link-2-line-DCqLmMA7.js +1 -0
  192. package/web/app/site/app/assets/icons/list-check-line-BAxosBh9.js +1 -0
  193. package/web/app/site/app/assets/icons/lock-line-CPoaXeUW.js +1 -0
  194. package/web/app/site/app/assets/icons/mail-line-B0P8aa1L.js +1 -0
  195. package/web/app/site/app/assets/icons/moon-line-CEqNAToP.js +1 -0
  196. package/web/app/site/app/assets/icons/more-1-fill-Dw2e5xab.js +1 -0
  197. package/web/app/site/app/assets/icons/more-3-line-BIiVjYTz.js +1 -0
  198. package/web/app/site/app/assets/icons/notification-line-BZZq2Gtu.js +1 -0
  199. package/web/app/site/app/assets/icons/pencil-line-CKh8-A1v.js +1 -0
  200. package/web/app/site/app/assets/icons/photo-album-line-Bnfg2u40.js +1 -0
  201. package/web/app/site/app/assets/icons/pin-line-DpTolyhs.js +1 -0
  202. package/web/app/site/app/assets/icons/play-fill-B0-G_8lX.js +1 -0
  203. package/web/app/site/app/assets/icons/qrcode-2-line-B6SPCaHn.js +1 -0
  204. package/web/app/site/app/assets/icons/quill-pen-line-B6LUrnCP.js +1 -0
  205. package/web/app/site/app/assets/icons/quote-left-fill-hQZ1cj_6.js +1 -0
  206. package/web/app/site/app/assets/icons/radar-line-CKyroTth.js +1 -0
  207. package/web/app/site/app/assets/icons/react-line-CoIKhhtK.js +1 -0
  208. package/web/app/site/app/assets/icons/refresh-2-line-D0mTF97S.js +1 -0
  209. package/web/app/site/app/assets/icons/right-line-B3KWJaqc.js +1 -0
  210. package/web/app/site/app/assets/icons/rocket-line-4KZl537D.js +1 -0
  211. package/web/app/site/app/assets/icons/round-fill-C69NTrZ0.js +1 -0
  212. package/web/app/site/app/assets/icons/round-line-B9k_1uo-.js +1 -0
  213. package/web/app/site/app/assets/icons/route-line-B28ReHcE.js +1 -0
  214. package/web/app/site/app/assets/icons/rows-4-line-CrBniagM.js +1 -0
  215. package/web/app/site/app/assets/icons/scan-line-DSf74YdP.js +1 -0
  216. package/web/app/site/app/assets/icons/search-2-line-B6geIU1z.js +1 -0
  217. package/web/app/site/app/assets/icons/settings-3-line-azJwK47w.js +1 -0
  218. package/web/app/site/app/assets/icons/settings-6-line-C_5zc5YR.js +1 -0
  219. package/web/app/site/app/assets/icons/share-2-line-D5MxWjrm.js +1 -0
  220. package/web/app/site/app/assets/icons/share-forward-line-Cp61fZzZ.js +1 -0
  221. package/web/app/site/app/assets/icons/sparkles-2-line-DYYyXTaW.js +1 -0
  222. package/web/app/site/app/assets/icons/sparkles-line-C2hY1s6I.js +1 -0
  223. package/web/app/site/app/assets/icons/time-line-BF3eet1v.js +1 -0
  224. package/web/app/site/app/assets/icons/transfer-4-line-oisfvTBw.js +1 -0
  225. package/web/app/site/app/assets/icons/translate-line-BFWol2Ce.js +1 -0
  226. package/web/app/site/app/assets/icons/unlock-line-X_x5vwv3.js +1 -0
  227. package/web/app/site/app/assets/icons/upload-3-line-DgzwUJeW.js +1 -0
  228. package/web/app/site/app/assets/icons/user-4-line-BVSynZCp.js +1 -0
  229. package/web/app/site/app/assets/icons/user-add-2-line-BlGS5Q8q.js +1 -0
  230. package/web/app/site/app/assets/icons/user-add-line-BbXdHG4z.js +1 -0
  231. package/web/app/site/app/assets/icons/user-edit-line-BFAHOV0X.js +1 -0
  232. package/web/app/site/app/assets/icons/user-follow-line-BXux0wSd.js +1 -0
  233. package/web/app/site/app/assets/icons/user-star-line-DdYlpHLU.js +1 -0
  234. package/web/app/site/app/assets/icons/user-warning-line-Cj43j0_E.js +1 -0
  235. package/web/app/site/app/assets/icons/user-x-line-DIDgNlPH.js +1 -0
  236. package/web/app/site/app/assets/icons/volume-line-Bs390KQt.js +1 -0
  237. package/web/app/site/app/assets/icons/volume-mute-line-DzlzIqFZ.js +1 -0
  238. package/web/app/site/app/assets/icons/walk-line-yNXfB-md.js +1 -0
  239. package/web/app/site/app/assets/icons/world-2-line-CenkGIwj.js +1 -0
  240. package/web/app/site/app/assets/icons/zoom-in-line-CaENUphf.js +1 -0
  241. package/web/app/site/app/assets/icons/zoom-out-line-BDNk0EWo.js +1 -0
  242. package/web/app/site/app/assets/instances-BNPptnn-.json +333 -0
  243. package/web/app/site/app/assets/locales/ar-SA-CfheRbCu.js +1 -0
  244. package/web/app/site/app/assets/locales/ca-ES-DHRW86Tz.js +1 -0
  245. package/web/app/site/app/assets/locales/cs-CZ-CKoqEuA1.js +1 -0
  246. package/web/app/site/app/assets/locales/de-DE-ByRPMRFy.js +1 -0
  247. package/web/app/site/app/assets/locales/eo-UY-CPC7wLCS.js +1 -0
  248. package/web/app/site/app/assets/locales/es-ES-dJa0Fd9t.js +1 -0
  249. package/web/app/site/app/assets/locales/eu-ES-C3djlPR2.js +1 -0
  250. package/web/app/site/app/assets/locales/fa-IR-DTT490Ck.js +1 -0
  251. package/web/app/site/app/assets/locales/fi-FI-B23MCPuz.js +1 -0
  252. package/web/app/site/app/assets/locales/fr-FR-BcaQsO5C.js +1 -0
  253. package/web/app/site/app/assets/locales/gl-ES-BR-CVKBN.js +1 -0
  254. package/web/app/site/app/assets/locales/he-IL-CfheRbCu.js +1 -0
  255. package/web/app/site/app/assets/locales/hu-HU-CfheRbCu.js +1 -0
  256. package/web/app/site/app/assets/locales/it-IT-C1136UFV.js +1 -0
  257. package/web/app/site/app/assets/locales/ja-JP-CRZmRAhD.js +1 -0
  258. package/web/app/site/app/assets/locales/kab-Cz8xH_TT.js +1 -0
  259. package/web/app/site/app/assets/locales/ko-KR-CG9UiY4o.js +1 -0
  260. package/web/app/site/app/assets/locales/lt-LT-CJcI6sHZ.js +1 -0
  261. package/web/app/site/app/assets/locales/nb-NO-CfheRbCu.js +1 -0
  262. package/web/app/site/app/assets/locales/nl-NL-DxN4Yc1t.js +1 -0
  263. package/web/app/site/app/assets/locales/oc-FR-CfheRbCu.js +1 -0
  264. package/web/app/site/app/assets/locales/pl-PL-DkwGXf6P.js +1 -0
  265. package/web/app/site/app/assets/locales/pseudo-LOCALE-CQ4XxkQ0.js +1 -0
  266. package/web/app/site/app/assets/locales/pt-BR-nGL0HdFS.js +1 -0
  267. package/web/app/site/app/assets/locales/pt-PT-Cp17aj2z.js +1 -0
  268. package/web/app/site/app/assets/locales/ru-RU-0Y8cIoaK.js +1 -0
  269. package/web/app/site/app/assets/locales/th-TH-CfheRbCu.js +1 -0
  270. package/web/app/site/app/assets/locales/tok-CfheRbCu.js +1 -0
  271. package/web/app/site/app/assets/locales/tr-TR-CfheRbCu.js +1 -0
  272. package/web/app/site/app/assets/locales/uk-UA-C5E3dFBf.js +1 -0
  273. package/web/app/site/app/assets/locales/zh-CN-BHJi7_et.js +1 -0
  274. package/web/app/site/app/assets/locales/zh-TW-CfheRbCu.js +1 -0
  275. package/web/app/site/app/assets/main-BdqNbG-a.js +26 -0
  276. package/web/app/site/app/assets/main-BdqNbG-a.js.map +1 -0
  277. package/web/app/site/app/assets/mock-home-DGhR__He.js +2 -0
  278. package/web/app/site/app/assets/mock-home-DGhR__He.js.map +1 -0
  279. package/web/app/site/app/assets/multi-column-ROck0NVt.jpg +0 -0
  280. package/web/app/site/app/assets/multi-hashtag-timeline-DH5INVHi.jpg +0 -0
  281. package/web/app/site/app/assets/nested-comments-thread-hv59kZLc.jpg +0 -0
  282. package/web/app/site/app/assets/polyfill-force-H8sUkLGp.js +2 -0
  283. package/web/app/site/app/assets/polyfill-force-H8sUkLGp.js.map +1 -0
  284. package/web/app/site/app/assets/pwa-viewport-ogKbF51K.js +20 -0
  285. package/web/app/site/app/assets/pwa-viewport-ogKbF51K.js.map +1 -0
  286. package/web/app/site/app/assets/style-B1iLPTPR.css +2 -0
  287. package/web/app/site/app/assets/temml-DuyIQMei.js +97 -0
  288. package/web/app/site/app/assets/temml-DuyIQMei.js.map +1 -0
  289. package/web/app/site/app/assets/tinyld.light.node-DtbBhLYC.js +2 -0
  290. package/web/app/site/app/assets/tinyld.light.node-DtbBhLYC.js.map +1 -0
  291. package/web/app/site/app/assets/year-in-posts-B2lywqdx.png +0 -0
  292. package/web/app/site/app/assets/year-in-posts-DJv_star.js +3 -0
  293. package/web/app/site/app/assets/year-in-posts-DJv_star.js.map +1 -0
  294. package/web/app/site/app/compose/index.html +20 -0
  295. package/web/app/site/app/favicon.ico +0 -0
  296. package/web/app/site/app/index.html +73 -0
  297. package/web/app/site/app/logo-192.png +0 -0
  298. package/web/app/site/app/logo-512.png +0 -0
  299. package/web/app/site/app/logo-badge-72.png +0 -0
  300. package/web/app/site/app/logo-maskable-512.png +0 -0
  301. package/web/app/site/app/logo-monochrome-512.png +0 -0
  302. package/web/app/site/app/logo-monochrome-maskable-512.png +0 -0
  303. package/web/app/site/app/manifest.webmanifest +1 -0
  304. package/web/app/site/app/og-image-2.jpg +0 -0
  305. package/web/app/site/app/og-image.png +0 -0
  306. package/web/app/site/app/robots.txt +2 -0
  307. package/web/app/site/app/version.json +1 -0
  308. package/web/app/site/boot.js +34067 -0
  309. package/web/app/site/index.html +203 -0
  310. package/web/app/site/sw.js +53745 -0
  311. package/web/app/sw-src.mjs +205 -0
  312. package/web/front/#new-account.html# +43 -0
  313. package/web/front/admin.html +1 -81
  314. package/web/front/admin.js +85 -0
  315. package/web/front/new-account.html +1 -9
  316. package/web/front/new-account.js +13 -0
  317. package/web/front/run.html +1 -92
  318. package/web/front/run.js +107 -0
  319. 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
- '/api/v1/followed_tags': [],
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
- mintToken() {
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', [...kept, { token: t, createdAt: now }].slice(-20));
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
- this.clientDocs.set(clientId, { at: Date.now(), client });
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
- authed(req) {
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
- return !!m && this.tokens().includes(m[1]);
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
- : /^image\/gif/.test(a.mediaType) ? 'gifv' : 'image';
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: body.scope || 'read write follow push', created_at: Math.floor(Date.now() / 1000),
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: this.streamingUrl(req) },
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
- urls: { streaming_api: this.streamingUrl(req) },
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: this.streamingUrl(req) },
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
- if (!this.authed(req)) return send(401, { error: 'The access token is invalid' });
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.put(url, f.data, f.contentType);
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 served; the client applies them to what it shows.
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
- const maxId = url.searchParams.get('max_id');
1467
- if (maxId) {
1468
- const i = items.findIndex(n => n.id === maxId);
1469
- if (i >= 0) items = items.slice(i + 1);
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, items.slice(0, limit).map(n => this.notification(n)));
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.put(mediaUrl, file.data, mediaType);
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: { max_options: 0 },
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
- resolve(Object.fromEntries(new URLSearchParams(data)));
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);