fedipod-server 0.21.0 → 0.28.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 (332) hide show
  1. package/README.md +5 -4
  2. package/dist/claims.d.ts +3 -1
  3. package/dist/claims.js +20 -7
  4. package/dist/components/context.jsonld +6 -0
  5. package/dist/handler.d.ts +29 -1
  6. package/dist/handler.js +89 -4
  7. package/dist/handler.jsonld +36 -1
  8. package/lib/client/c2s.mjs +100 -3
  9. package/lib/client/masto/accounts.mjs +44 -4
  10. package/lib/client/masto/body.mjs +90 -3
  11. package/lib/client/masto/index.mjs +5 -1
  12. package/lib/client/masto/instance.mjs +11 -3
  13. package/lib/client/masto/media.mjs +3 -57
  14. package/lib/client/masto/oauth.mjs +66 -9
  15. package/lib/client/masto/render.mjs +62 -5
  16. package/lib/client/masto/statuses.mjs +25 -1
  17. package/lib/client/masto/timelines.mjs +128 -6
  18. package/lib/connections/tagfeed.mjs +42 -5
  19. package/lib/core/as2.mjs +36 -5
  20. package/lib/core/contexts/index.mjs +14 -0
  21. package/lib/core/contexts/litepub-0.1.json +60 -0
  22. package/lib/core/contexts/map.json +3 -1
  23. package/lib/core/contexts/quotes.json +18 -0
  24. package/lib/core/graphview.mjs +41 -3
  25. package/lib/core/intake/activities.mjs +136 -2
  26. package/lib/core/intake/activity.mjs +79 -1
  27. package/lib/core/intake/group.mjs +37 -3
  28. package/lib/core/intake/index.mjs +32 -2
  29. package/lib/core/intake/notes.mjs +60 -3
  30. package/lib/core/lease.mjs +12 -2
  31. package/lib/core/profile-page.mjs +104 -0
  32. package/lib/core/publisher/collections.mjs +6 -1
  33. package/lib/core/publisher/index.mjs +35 -17
  34. package/lib/core/publisher/notes.mjs +104 -3
  35. package/lib/core/shapes/activitystreams.ttl +28 -0
  36. package/lib/core/shapes/index.mjs +26 -1
  37. package/lib/core/wire-quotes.mjs +71 -0
  38. package/lib/core/wire.mjs +59 -103
  39. package/lib/device/admin/routes/lifecycle.mjs +9 -0
  40. package/lib/device/admin/static.mjs +4 -1
  41. package/lib/device/admin/surface.mjs +26 -2
  42. package/lib/device/certs.mjs +18 -2
  43. package/lib/device/cli/commands/setup.mjs +6 -2
  44. package/lib/device/cli/commands/state.mjs +3 -1
  45. package/lib/device/gateway-move.mjs +58 -0
  46. package/lib/device/migrate.mjs +3 -1
  47. package/lib/device/setup.mjs +67 -7
  48. package/lib/gateway/front-core.mjs +307 -36
  49. package/lib/gateway/gateway-core.mjs +15 -6
  50. package/lib/gateway/notices.mjs +84 -0
  51. package/lib/gateway/quiet.mjs +184 -0
  52. package/lib/pod/root.mjs +6 -3
  53. package/lib/pod/state.mjs +8 -7
  54. package/lib/pod/transport.mjs +5 -1
  55. package/lib/session/README.md +123 -0
  56. package/lib/session/demo.html +94 -0
  57. package/lib/session/fedi-account.mjs +346 -0
  58. package/lib/session/fedi-login.mjs +123 -0
  59. package/lib/session/oidc-session.mjs +233 -0
  60. package/lib/session/package.json +41 -0
  61. package/package.json +1 -1
  62. package/run-agent.mjs +28 -0
  63. package/vendor/gate.cjs +6 -3
  64. package/web/admin/bar.css +66 -0
  65. package/web/admin/bar.js +38 -0
  66. package/web/admin/client/index.html +31 -3
  67. package/web/admin/client-pick.js +79 -0
  68. package/web/admin/gateway.js +22 -0
  69. package/web/admin/index.html +54 -13
  70. package/web/admin/notices-bar.js +90 -0
  71. package/web/admin/oauth-signin.mjs +37 -0
  72. package/web/admin/record.js +13 -1
  73. package/web/admin/setup/index.html +31 -3
  74. package/web/admin/tokens.css +15 -3
  75. package/web/admin/upkeep.js +4 -5
  76. package/web/app/README.md +14 -10
  77. package/web/app/admin-facade.mjs +51 -12
  78. package/web/app/agent.mjs +157 -19
  79. package/web/app/authorize_interaction.html +56 -0
  80. package/web/app/authorize_interaction.js +69 -0
  81. package/web/app/boot.mjs +233 -95
  82. package/web/app/dist/boot.js +477 -569
  83. package/web/app/dist/boot.js.map +4 -4
  84. package/web/app/dist/sw.js +1861 -532
  85. package/web/app/dist/sw.js.map +4 -4
  86. package/web/app/gateway-move.mjs +60 -0
  87. package/web/app/index.html +40 -49
  88. package/web/app/keys-browser.mjs +12 -15
  89. package/web/app/keystore.mjs +19 -36
  90. package/web/app/oidc-session.mjs +5 -188
  91. package/web/app/signup.mjs +172 -132
  92. package/web/app/sw-src.mjs +1 -1
  93. package/web/app/update.js +66 -0
  94. package/web/front/admin.html +5 -3
  95. package/web/front/new-account.html +2 -2
  96. package/web/front/notices.html +69 -0
  97. package/web/front/notices.js +107 -0
  98. package/web/front/run.html +15 -8
  99. package/web/front/run.js +124 -36
  100. package/web/icons/forum.svg +11 -0
  101. package/web/icons/gateway.svg +14 -0
  102. package/web/icons/local.svg +11 -0
  103. package/web/app/index.html~ +0 -152
  104. package/web/app/site/_headers +0 -9
  105. package/web/app/site/_redirects +0 -22
  106. package/web/app/site/admin/actors.js +0 -145
  107. package/web/app/site/admin/bar.css +0 -60
  108. package/web/app/site/admin/bar.js +0 -61
  109. package/web/app/site/admin/client/client.js +0 -66
  110. package/web/app/site/admin/client/index.html +0 -43
  111. package/web/app/site/admin/common.js +0 -23
  112. package/web/app/site/admin/connections.js +0 -112
  113. package/web/app/site/admin/gateway.js +0 -111
  114. package/web/app/site/admin/group.js +0 -258
  115. package/web/app/site/admin/index.html +0 -564
  116. package/web/app/site/admin/record.js +0 -378
  117. package/web/app/site/admin/setup/index.html +0 -215
  118. package/web/app/site/admin/setup/setup.js +0 -326
  119. package/web/app/site/admin/tokens.css +0 -56
  120. package/web/app/site/admin/upkeep.js +0 -178
  121. package/web/app/site/admin/window.css +0 -55
  122. package/web/app/site/admin/window.js +0 -136
  123. package/web/app/site/app/404.html +0 -40
  124. package/web/app/site/app/_headers +0 -2
  125. package/web/app/site/app/apple-touch-icon.png +0 -0
  126. package/web/app/site/app/assets/ICONS-DIPJeU0C.js +0 -2
  127. package/web/app/site/app/assets/ICONS-DIPJeU0C.js.map +0 -1
  128. package/web/app/site/app/assets/ScheduledAtField-D-Abfo4J.js +0 -183
  129. package/web/app/site/app/assets/ScheduledAtField-D-Abfo4J.js.map +0 -1
  130. package/web/app/site/app/assets/Temml-CCjoacWt.woff2 +0 -0
  131. package/web/app/site/app/assets/__vite-browser-external-C7iut881.js +0 -2
  132. package/web/app/site/app/assets/__vite-browser-external-C7iut881.js.map +0 -1
  133. package/web/app/site/app/assets/boosts-carousel-BiOaNdMT.jpg +0 -0
  134. package/web/app/site/app/assets/catch-up-O5q5C75Z.png +0 -0
  135. package/web/app/site/app/assets/chunk-aKtaBQYM.js +0 -1
  136. package/web/app/site/app/assets/compose-CFEx7wb4.js +0 -2
  137. package/web/app/site/app/assets/compose-CFEx7wb4.js.map +0 -1
  138. package/web/app/site/app/assets/compose-ZJN5xSb4.js +0 -35
  139. package/web/app/site/app/assets/compose-ZJN5xSb4.js.map +0 -1
  140. package/web/app/site/app/assets/debug.module-YFQgwnIr.js +0 -52
  141. package/web/app/site/app/assets/debug.module-YFQgwnIr.js.map +0 -1
  142. package/web/app/site/app/assets/dom-p1YcJ5Rw.js +0 -2
  143. package/web/app/site/app/assets/dom-p1YcJ5Rw.js.map +0 -1
  144. package/web/app/site/app/assets/home-mobile-dark@2x-Dc-J8-SM.png +0 -0
  145. package/web/app/site/app/assets/home-mobile-light@2x-Bd3fod8m.png +0 -0
  146. package/web/app/site/app/assets/home-tablet-dark@2x-BgcNI8le.png +0 -0
  147. package/web/app/site/app/assets/home-tablet-light@2x-BW2RV9T6.png +0 -0
  148. package/web/app/site/app/assets/icons/add-circle-line-WD0mwORD.js +0 -1
  149. package/web/app/site/app/assets/icons/alert-line-CpFUMf9s.js +0 -1
  150. package/web/app/site/app/assets/icons/android-2-line-Bzxnrd3S.js +0 -1
  151. package/web/app/site/app/assets/icons/announcement-line-5nwaJFJm.js +0 -1
  152. package/web/app/site/app/assets/icons/arrow-down-circle-line-a7Id9-Iv.js +0 -1
  153. package/web/app/site/app/assets/icons/arrow-down-line-B8uzXzq6.js +0 -1
  154. package/web/app/site/app/assets/icons/arrow-left-line-CGrGT9C-.js +0 -1
  155. package/web/app/site/app/assets/icons/arrow-right-line-CE-HERfX.js +0 -1
  156. package/web/app/site/app/assets/icons/arrow-to-up-line-CvdQjmcy.js +0 -1
  157. package/web/app/site/app/assets/icons/arrow-up-circle-line-DEparSNF.js +0 -1
  158. package/web/app/site/app/assets/icons/arrow-up-line-C7yBeugc.js +0 -1
  159. package/web/app/site/app/assets/icons/arrows-right-line-BtbXmJGP.js +0 -1
  160. package/web/app/site/app/assets/icons/at-line-CF66Bznk.js +0 -1
  161. package/web/app/site/app/assets/icons/attachment-line-BY8It-l9.js +0 -1
  162. package/web/app/site/app/assets/icons/blockquote-line-BS6pU_hm.js +0 -1
  163. package/web/app/site/app/assets/icons/board-line-C0UWXyA6.js +0 -1
  164. package/web/app/site/app/assets/icons/bookmark-line-1CtX5JLF.js +0 -1
  165. package/web/app/site/app/assets/icons/building-5-line-Dd59MzbO.js +0 -1
  166. package/web/app/site/app/assets/icons/bus-2-line-6_dvA0GG.js +0 -1
  167. package/web/app/site/app/assets/icons/calendar-day-line-Crpl_fmu.js +0 -1
  168. package/web/app/site/app/assets/icons/calendar-month-line-vommts2C.js +0 -1
  169. package/web/app/site/app/assets/icons/calendar-time-add-line-BU_DTlQk.js +0 -1
  170. package/web/app/site/app/assets/icons/camera-line-Cv69MEei.js +0 -1
  171. package/web/app/site/app/assets/icons/celebrate-line-B8DvIoi0.js +0 -1
  172. package/web/app/site/app/assets/icons/chart-bar-line-FK-QOmtF.js +0 -1
  173. package/web/app/site/app/assets/icons/chart-line-line-BGcJAFWw.js +0 -1
  174. package/web/app/site/app/assets/icons/chat-3-line-YFK2-hwz.js +0 -1
  175. package/web/app/site/app/assets/icons/check-circle-line-DsAxFr5-.js +0 -1
  176. package/web/app/site/app/assets/icons/clipboard-line-BBp364gJ.js +0 -1
  177. package/web/app/site/app/assets/icons/close-circle-line-CICdiuGz.js +0 -1
  178. package/web/app/site/app/assets/icons/close-line-DYrFWcjv.js +0 -1
  179. package/web/app/site/app/assets/icons/cloud-line-tiQAbah8.js +0 -1
  180. package/web/app/site/app/assets/icons/code-line-fAl8iiV3.js +0 -1
  181. package/web/app/site/app/assets/icons/comment-2-line-CR3g7WeB.js +0 -1
  182. package/web/app/site/app/assets/icons/copy-2-line-CLW69yF5.js +0 -1
  183. package/web/app/site/app/assets/icons/delete-2-line-B8xmbr4n.js +0 -1
  184. package/web/app/site/app/assets/icons/document-3-line-DBGNhAg2.js +0 -1
  185. package/web/app/site/app/assets/icons/document-line-C7mb9sb7.js +0 -1
  186. package/web/app/site/app/assets/icons/down-line-BGqSSThd.js +0 -1
  187. package/web/app/site/app/assets/icons/edit-4-line-DcgGaEhx.js +0 -1
  188. package/web/app/site/app/assets/icons/emoji-2-line-D8YgnM-y.js +0 -1
  189. package/web/app/site/app/assets/icons/exit-line-Dc07BS-x.js +0 -1
  190. package/web/app/site/app/assets/icons/external-link-line-BvpSkmjO.js +0 -1
  191. package/web/app/site/app/assets/icons/eye-2-line-CiWXx-f_.js +0 -1
  192. package/web/app/site/app/assets/icons/eye-close-line-BgTaSJ4P.js +0 -1
  193. package/web/app/site/app/assets/icons/filter-2-line-B_mR2usy.js +0 -1
  194. package/web/app/site/app/assets/icons/filter-line-C_nm-3cU.js +0 -1
  195. package/web/app/site/app/assets/icons/flag-1-line-C2HB05I0.js +0 -1
  196. package/web/app/site/app/assets/icons/forbid-circle-line-D3LTT9yd.js +0 -1
  197. package/web/app/site/app/assets/icons/formula-line-DLrt2d6j.js +0 -1
  198. package/web/app/site/app/assets/icons/grid-line-CgUmEikf.js +0 -1
  199. package/web/app/site/app/assets/icons/group-line-C5fnX0wU.js +0 -1
  200. package/web/app/site/app/assets/icons/hand-finger-2-line-BGgxbuL9.js +0 -1
  201. package/web/app/site/app/assets/icons/hashtag-line-CyTEEXBb.js +0 -1
  202. package/web/app/site/app/assets/icons/heart-crack-line-BZddbO2C.js +0 -1
  203. package/web/app/site/app/assets/icons/heart-line-CJ7K47J4.js +0 -1
  204. package/web/app/site/app/assets/icons/history-2-line-kWWvFzqv.js +0 -1
  205. package/web/app/site/app/assets/icons/history-line-bGQiwbTH.js +0 -1
  206. package/web/app/site/app/assets/icons/home-3-line-fRU1zeLG.js +0 -1
  207. package/web/app/site/app/assets/icons/information-line-iNk6-sOY.js +0 -1
  208. package/web/app/site/app/assets/icons/keyboard-line-DsCyuQNz.js +0 -1
  209. package/web/app/site/app/assets/icons/layout-4-line-CZkOX52Z.js +0 -1
  210. package/web/app/site/app/assets/icons/layout-5-line-B_znt5wO.js +0 -1
  211. package/web/app/site/app/assets/icons/left-line-eUw0tya4.js +0 -1
  212. package/web/app/site/app/assets/icons/lightning-line-DaKoDdix.js +0 -1
  213. package/web/app/site/app/assets/icons/link-2-line-DCqLmMA7.js +0 -1
  214. package/web/app/site/app/assets/icons/list-check-line-BAxosBh9.js +0 -1
  215. package/web/app/site/app/assets/icons/lock-line-CPoaXeUW.js +0 -1
  216. package/web/app/site/app/assets/icons/mail-line-B0P8aa1L.js +0 -1
  217. package/web/app/site/app/assets/icons/moon-line-CEqNAToP.js +0 -1
  218. package/web/app/site/app/assets/icons/more-1-fill-Dw2e5xab.js +0 -1
  219. package/web/app/site/app/assets/icons/more-3-line-BIiVjYTz.js +0 -1
  220. package/web/app/site/app/assets/icons/notification-line-BZZq2Gtu.js +0 -1
  221. package/web/app/site/app/assets/icons/pencil-line-CKh8-A1v.js +0 -1
  222. package/web/app/site/app/assets/icons/photo-album-line-Bnfg2u40.js +0 -1
  223. package/web/app/site/app/assets/icons/pin-line-DpTolyhs.js +0 -1
  224. package/web/app/site/app/assets/icons/play-fill-B0-G_8lX.js +0 -1
  225. package/web/app/site/app/assets/icons/qrcode-2-line-B6SPCaHn.js +0 -1
  226. package/web/app/site/app/assets/icons/quill-pen-line-B6LUrnCP.js +0 -1
  227. package/web/app/site/app/assets/icons/quote-left-fill-hQZ1cj_6.js +0 -1
  228. package/web/app/site/app/assets/icons/radar-line-CKyroTth.js +0 -1
  229. package/web/app/site/app/assets/icons/react-line-CoIKhhtK.js +0 -1
  230. package/web/app/site/app/assets/icons/refresh-2-line-D0mTF97S.js +0 -1
  231. package/web/app/site/app/assets/icons/right-line-B3KWJaqc.js +0 -1
  232. package/web/app/site/app/assets/icons/rocket-line-4KZl537D.js +0 -1
  233. package/web/app/site/app/assets/icons/round-fill-C69NTrZ0.js +0 -1
  234. package/web/app/site/app/assets/icons/round-line-B9k_1uo-.js +0 -1
  235. package/web/app/site/app/assets/icons/route-line-B28ReHcE.js +0 -1
  236. package/web/app/site/app/assets/icons/rows-4-line-CrBniagM.js +0 -1
  237. package/web/app/site/app/assets/icons/scan-line-DSf74YdP.js +0 -1
  238. package/web/app/site/app/assets/icons/search-2-line-B6geIU1z.js +0 -1
  239. package/web/app/site/app/assets/icons/settings-3-line-azJwK47w.js +0 -1
  240. package/web/app/site/app/assets/icons/settings-6-line-C_5zc5YR.js +0 -1
  241. package/web/app/site/app/assets/icons/share-2-line-D5MxWjrm.js +0 -1
  242. package/web/app/site/app/assets/icons/share-forward-line-Cp61fZzZ.js +0 -1
  243. package/web/app/site/app/assets/icons/sparkles-2-line-DYYyXTaW.js +0 -1
  244. package/web/app/site/app/assets/icons/sparkles-line-C2hY1s6I.js +0 -1
  245. package/web/app/site/app/assets/icons/time-line-BF3eet1v.js +0 -1
  246. package/web/app/site/app/assets/icons/transfer-4-line-oisfvTBw.js +0 -1
  247. package/web/app/site/app/assets/icons/translate-line-BFWol2Ce.js +0 -1
  248. package/web/app/site/app/assets/icons/unlock-line-X_x5vwv3.js +0 -1
  249. package/web/app/site/app/assets/icons/upload-3-line-DgzwUJeW.js +0 -1
  250. package/web/app/site/app/assets/icons/user-4-line-BVSynZCp.js +0 -1
  251. package/web/app/site/app/assets/icons/user-add-2-line-BlGS5Q8q.js +0 -1
  252. package/web/app/site/app/assets/icons/user-add-line-BbXdHG4z.js +0 -1
  253. package/web/app/site/app/assets/icons/user-edit-line-BFAHOV0X.js +0 -1
  254. package/web/app/site/app/assets/icons/user-follow-line-BXux0wSd.js +0 -1
  255. package/web/app/site/app/assets/icons/user-star-line-DdYlpHLU.js +0 -1
  256. package/web/app/site/app/assets/icons/user-warning-line-Cj43j0_E.js +0 -1
  257. package/web/app/site/app/assets/icons/user-x-line-DIDgNlPH.js +0 -1
  258. package/web/app/site/app/assets/icons/volume-line-Bs390KQt.js +0 -1
  259. package/web/app/site/app/assets/icons/volume-mute-line-DzlzIqFZ.js +0 -1
  260. package/web/app/site/app/assets/icons/walk-line-yNXfB-md.js +0 -1
  261. package/web/app/site/app/assets/icons/world-2-line-CenkGIwj.js +0 -1
  262. package/web/app/site/app/assets/icons/zoom-in-line-CaENUphf.js +0 -1
  263. package/web/app/site/app/assets/icons/zoom-out-line-BDNk0EWo.js +0 -1
  264. package/web/app/site/app/assets/instances-BNPptnn-.json +0 -333
  265. package/web/app/site/app/assets/locales/ar-SA-CfheRbCu.js +0 -1
  266. package/web/app/site/app/assets/locales/ca-ES-DHRW86Tz.js +0 -1
  267. package/web/app/site/app/assets/locales/cs-CZ-CKoqEuA1.js +0 -1
  268. package/web/app/site/app/assets/locales/de-DE-ByRPMRFy.js +0 -1
  269. package/web/app/site/app/assets/locales/eo-UY-CPC7wLCS.js +0 -1
  270. package/web/app/site/app/assets/locales/es-ES-dJa0Fd9t.js +0 -1
  271. package/web/app/site/app/assets/locales/eu-ES-C3djlPR2.js +0 -1
  272. package/web/app/site/app/assets/locales/fa-IR-DTT490Ck.js +0 -1
  273. package/web/app/site/app/assets/locales/fi-FI-B23MCPuz.js +0 -1
  274. package/web/app/site/app/assets/locales/fr-FR-BcaQsO5C.js +0 -1
  275. package/web/app/site/app/assets/locales/gl-ES-BR-CVKBN.js +0 -1
  276. package/web/app/site/app/assets/locales/he-IL-CfheRbCu.js +0 -1
  277. package/web/app/site/app/assets/locales/hu-HU-CfheRbCu.js +0 -1
  278. package/web/app/site/app/assets/locales/it-IT-C1136UFV.js +0 -1
  279. package/web/app/site/app/assets/locales/ja-JP-CRZmRAhD.js +0 -1
  280. package/web/app/site/app/assets/locales/kab-Cz8xH_TT.js +0 -1
  281. package/web/app/site/app/assets/locales/ko-KR-CG9UiY4o.js +0 -1
  282. package/web/app/site/app/assets/locales/lt-LT-CJcI6sHZ.js +0 -1
  283. package/web/app/site/app/assets/locales/nb-NO-CfheRbCu.js +0 -1
  284. package/web/app/site/app/assets/locales/nl-NL-DxN4Yc1t.js +0 -1
  285. package/web/app/site/app/assets/locales/oc-FR-CfheRbCu.js +0 -1
  286. package/web/app/site/app/assets/locales/pl-PL-DkwGXf6P.js +0 -1
  287. package/web/app/site/app/assets/locales/pseudo-LOCALE-CQ4XxkQ0.js +0 -1
  288. package/web/app/site/app/assets/locales/pt-BR-nGL0HdFS.js +0 -1
  289. package/web/app/site/app/assets/locales/pt-PT-Cp17aj2z.js +0 -1
  290. package/web/app/site/app/assets/locales/ru-RU-0Y8cIoaK.js +0 -1
  291. package/web/app/site/app/assets/locales/th-TH-CfheRbCu.js +0 -1
  292. package/web/app/site/app/assets/locales/tok-CfheRbCu.js +0 -1
  293. package/web/app/site/app/assets/locales/tr-TR-CfheRbCu.js +0 -1
  294. package/web/app/site/app/assets/locales/uk-UA-C5E3dFBf.js +0 -1
  295. package/web/app/site/app/assets/locales/zh-CN-BHJi7_et.js +0 -1
  296. package/web/app/site/app/assets/locales/zh-TW-CfheRbCu.js +0 -1
  297. package/web/app/site/app/assets/main-BdqNbG-a.js +0 -26
  298. package/web/app/site/app/assets/main-BdqNbG-a.js.map +0 -1
  299. package/web/app/site/app/assets/mock-home-DGhR__He.js +0 -2
  300. package/web/app/site/app/assets/mock-home-DGhR__He.js.map +0 -1
  301. package/web/app/site/app/assets/multi-column-ROck0NVt.jpg +0 -0
  302. package/web/app/site/app/assets/multi-hashtag-timeline-DH5INVHi.jpg +0 -0
  303. package/web/app/site/app/assets/nested-comments-thread-hv59kZLc.jpg +0 -0
  304. package/web/app/site/app/assets/polyfill-force-H8sUkLGp.js +0 -2
  305. package/web/app/site/app/assets/polyfill-force-H8sUkLGp.js.map +0 -1
  306. package/web/app/site/app/assets/pwa-viewport-ogKbF51K.js +0 -20
  307. package/web/app/site/app/assets/pwa-viewport-ogKbF51K.js.map +0 -1
  308. package/web/app/site/app/assets/style-B1iLPTPR.css +0 -2
  309. package/web/app/site/app/assets/temml-DuyIQMei.js +0 -97
  310. package/web/app/site/app/assets/temml-DuyIQMei.js.map +0 -1
  311. package/web/app/site/app/assets/tinyld.light.node-DtbBhLYC.js +0 -2
  312. package/web/app/site/app/assets/tinyld.light.node-DtbBhLYC.js.map +0 -1
  313. package/web/app/site/app/assets/year-in-posts-B2lywqdx.png +0 -0
  314. package/web/app/site/app/assets/year-in-posts-DJv_star.js +0 -3
  315. package/web/app/site/app/assets/year-in-posts-DJv_star.js.map +0 -1
  316. package/web/app/site/app/compose/index.html +0 -20
  317. package/web/app/site/app/favicon.ico +0 -0
  318. package/web/app/site/app/index.html +0 -73
  319. package/web/app/site/app/logo-192.png +0 -0
  320. package/web/app/site/app/logo-512.png +0 -0
  321. package/web/app/site/app/logo-badge-72.png +0 -0
  322. package/web/app/site/app/logo-maskable-512.png +0 -0
  323. package/web/app/site/app/logo-monochrome-512.png +0 -0
  324. package/web/app/site/app/logo-monochrome-maskable-512.png +0 -0
  325. package/web/app/site/app/manifest.webmanifest +0 -1
  326. package/web/app/site/app/og-image-2.jpg +0 -0
  327. package/web/app/site/app/og-image.png +0 -0
  328. package/web/app/site/app/robots.txt +0 -2
  329. package/web/app/site/app/version.json +0 -1
  330. package/web/app/site/boot.js +0 -34277
  331. package/web/app/site/index.html +0 -251
  332. package/web/app/site/sw.js +0 -71331
@@ -1,6 +1,6 @@
1
- // body.mjs — reading what a client sent: JSON or form-encoded bodies, the
2
- // poll a compose request carries, and the text of a post back out of its
3
- // HTML.
1
+ // body.mjs — reading what a client sent: JSON, form-encoded or multipart
2
+ // bodies, the poll a compose request carries, and the text of a post back out
3
+ // of its HTML.
4
4
 
5
5
 
6
6
 
@@ -51,6 +51,12 @@ export function readBody(req) {
51
51
  const ct = String(req.headers['content-type'] || '');
52
52
  try {
53
53
  if (ct.includes('application/json')) return resolve(data ? JSON.parse(data) : {});
54
+ // A multipart body is not a query string: read as one it becomes a
55
+ // single nonsense key, so every field a client sent that way arrived
56
+ // undefined. Sengi registers its OAuth client with a FormData, which
57
+ // is multipart, so /api/v1/apps saw no redirect_uris and handed back
58
+ // the out-of-band URN, leaving the client nowhere to come back to.
59
+ if (ct.includes('multipart/form-data')) return resolve(textFields(data, ct));
54
60
  // A form-encoded list is the same key repeated, spelled with a
55
61
  // trailing `[]`. Reading it as a plain object kept only the last one,
56
62
  // so a client sending its poll or its media that way lost all but the
@@ -67,3 +73,84 @@ export function readBody(req) {
67
73
  req.on('error', reject);
68
74
  });
69
75
  }
76
+
77
+ /**
78
+ * The text fields of a multipart body that arrived as text. readBody
79
+ * accumulates the body as a string, so this reads the fields out of that
80
+ * string rather than out of bytes — which is all a client sending a plain
81
+ * form this way has in it. A body with a FILE in it is not this reader's:
82
+ * a text round trip corrupts binary, and the upload endpoints take their
83
+ * bytes from readMultipart below.
84
+ */
85
+ function textFields(data, contentType) {
86
+ const m = /boundary=(?:"([^"]+)"|([^;]+))/.exec(contentType);
87
+ if (!m) return {};
88
+ const out = {};
89
+ for (const part of data.split('--' + (m[1] || m[2]).trim())) {
90
+ const sep = part.indexOf('\r\n\r\n');
91
+ if (sep < 0) continue;
92
+ const head = part.slice(0, sep);
93
+ const name = /name="([^"]*)"/.exec(head)?.[1];
94
+ // A file part is skipped rather than kept as mangled text.
95
+ if (!name || /filename="/.test(head)) continue;
96
+ const value = part.slice(sep + 4).replace(/\r\n$/, '');
97
+ if (name.endsWith('[]')) (out[name] ||= []).push(value);
98
+ else out[name] = value;
99
+ }
100
+ return out;
101
+ }
102
+
103
+ // Minimal multipart/form-data reader for media uploads: string fields plus
104
+ // at most one file part (Mastodon's media endpoints send exactly one).
105
+ export function readMultipart(req, limit = 12e6) {
106
+ return new Promise((resolve, reject) => {
107
+ const chunks = [];
108
+ let n = 0;
109
+ req.on('data', c => {
110
+ n += c.length;
111
+ if (n > limit) { reject(new Error('upload too large')); req.destroy(); return; }
112
+ chunks.push(c);
113
+ });
114
+ req.on('error', reject);
115
+ req.on('end', () => {
116
+ try {
117
+ const m = /boundary=(?:"([^"]+)"|([^;]+))/.exec(String(req.headers['content-type'] || ''));
118
+ if (!m) return resolve({ fields: {}, file: null });
119
+ const buf = Buffer.concat(chunks);
120
+ const boundary = Buffer.from('--' + (m[1] || m[2]).trim());
121
+ const fields = {};
122
+ // `file` is the last one seen, which is what the single-file media
123
+ // upload wants. `files` keys them by field name, because the profile
124
+ // editor submits an avatar and a header in one request.
125
+ const files = {};
126
+ let file = null;
127
+ let i = buf.indexOf(boundary);
128
+ while (i >= 0) {
129
+ const start = i + boundary.length;
130
+ if (buf.slice(start, start + 2).toString() === '--') break;
131
+ const next = buf.indexOf(boundary, start);
132
+ if (next < 0) break;
133
+ const part = buf.slice(start + 2, next - 2); // strip the CRLFs framing the part
134
+ const sep = part.indexOf('\r\n\r\n');
135
+ if (sep >= 0) {
136
+ const head = part.slice(0, sep).toString();
137
+ const body = part.slice(sep + 4);
138
+ const name = /name="([^"]*)"/.exec(head)?.[1];
139
+ const filename = /filename="([^"]*)"/.exec(head)?.[1];
140
+ if (filename !== undefined) {
141
+ file = {
142
+ filename,
143
+ contentType: /content-type:\s*([^\r\n]+)/i.exec(head)?.[1]?.trim() || 'application/octet-stream',
144
+ data: body,
145
+ };
146
+ if (name) files[name] = file;
147
+ } else if (name) fields[name] = body.toString();
148
+ }
149
+ i = next;
150
+ }
151
+ resolve({ fields, file, files });
152
+ } catch (e) { reject(e); }
153
+ });
154
+ });
155
+ }
156
+
@@ -31,7 +31,7 @@ export { pollParams } from './body.mjs';
31
31
  export { attachmentType, extensionFor } from './media.mjs';
32
32
 
33
33
  export class MastoApi {
34
- constructor({ agent, log = console.log, allowed = null, scheme = null, embedded = false,
34
+ constructor({ agent, log = console.log, allowed = null, scheme = null, embedded = false, throughDoor = null,
35
35
  mount = '', streaming = true, webPush = true, scheduling = true }) {
36
36
  this.agent = agent;
37
37
  // The path this identity's surface answers under, when it shares its origin
@@ -45,6 +45,9 @@ export class MastoApi {
45
45
  this.embedded = embedded;
46
46
  this.log = log;
47
47
  this.allowed = allowed; // authorities a redirect_uri may name
48
+ // Whether a request already passed the owner's door (its key or cookie):
49
+ // an owner who opened the management page is the owner at the sign-in too.
50
+ this.throughDoor = throughDoor;
48
51
  // The scheme this identity is reached on, when the socket cannot say —
49
52
  // a server behind a TLS proxy terminates cleartext and still is https.
50
53
  this.scheme = scheme;
@@ -183,6 +186,7 @@ export class MastoApi {
183
186
  acctAction(...a) { return render.acctAction(this, ...a); }
184
187
  acctReply(...a) { return render.acctReply(this, ...a); }
185
188
  status(...a) { return render.status(this, ...a); }
189
+ quoteApproval(...a) { return render.quoteApproval(this, ...a); }
186
190
  filtersFor(...a) { return render.filtersFor(this, ...a); }
187
191
  lookup(...a) { return render.lookup(this, ...a); }
188
192
  statusOrBoost(...a) { return render.statusOrBoost(this, ...a); }
@@ -14,7 +14,7 @@ const STUBS = new Map(Object.entries({
14
14
  '/api/v1/custom_emojis': [],
15
15
  '/api/v1/announcements': [],
16
16
  '/api/v1/instance/peers': [],
17
- '/api/v1/trends/tags': [], '/api/v1/trends/links': [],
17
+ '/api/v1/trends/links': [],
18
18
  '/api/v2/suggestions': [],
19
19
  '/api/v1/preferences': {},
20
20
  })); // followed_tags is served live from the tag feed, not stubbed
@@ -49,9 +49,9 @@ export function instanceTitle(api) {
49
49
  // A Mastodon Tag object. The client reads `following` in its Followed
50
50
  // Hashtags view and toggles it with the follow/unfollow endpoints. No usage
51
51
  // history — a single-actor instance has no firehose stats to report.
52
- export function tagObject(api, name, following, req) {
52
+ export function tagObject(api, name, following, req, history = []) {
53
53
  const host = req?.headers?.host || api.host;
54
- return { name, url: `https://${host}/tags/${name}`, history: [], following: !!following };
54
+ return { name, url: `https://${host}/tags/${name}`, history, following: !!following };
55
55
  }
56
56
 
57
57
  export function instanceBlurb(api) {
@@ -59,6 +59,12 @@ export function instanceBlurb(api) {
59
59
  return `Solid pod ActivityPub ${kind}`;
60
60
  }
61
61
 
62
+ // The Mastodon API version this facade answers to. Clients read it for what
63
+ // the server can do beyond the version string: grouped notifications from 2,
64
+ // a filter's blur action from 5, quote posts from 7. Everything up to 7 is
65
+ // served here; nothing past it is claimed.
66
+ const API_VERSIONS = { mastodon: 7 };
67
+
62
68
  export async function handle(api, ctx) {
63
69
  const { req, res, pathname, url, send } = ctx; // eslint-disable-line no-unused-vars
64
70
 
@@ -68,6 +74,7 @@ export async function handle(api, ctx) {
68
74
  return send(200, {
69
75
  uri: api.host, title: api.instanceTitle(), short_description: api.instanceBlurb(),
70
76
  description: api.instanceBlurb(), email: '', version: '4.2.0 (compatible; fedipod)',
77
+ api_versions: API_VERSIONS,
71
78
  urls: su ? { streaming_api: su } : {},
72
79
  stats: { user_count: 1, status_count: api.store.countStatuses(), domain_count: 1 },
73
80
  languages: ['en'], registrations: false, approval_required: false, invites_enabled: false,
@@ -79,6 +86,7 @@ export async function handle(api, ctx) {
79
86
  const su = api.streamingUrl(req);
80
87
  return send(200, {
81
88
  domain: api.host, title: api.instanceTitle(), version: '4.2.0 (compatible; fedipod)',
89
+ api_versions: API_VERSIONS,
82
90
  source_url: 'https://github.com/jeff-zucker/FediPod', description: api.instanceBlurb(),
83
91
  usage: { users: { active_month: 1 } },
84
92
  thumbnail: { url: TRANSPARENT_PNG },
@@ -1,10 +1,10 @@
1
1
  // media.mjs — an upload on its way to the pod's media container: what an
2
- // attachment is allowed to be, the multipart reader, and the two media
3
- // endpoints.
2
+ // attachment is allowed to be and the two media endpoints. The multipart
3
+ // reader moved to body.mjs, which is where a client's body is read.
4
4
 
5
5
  import crypto from 'node:crypto';
6
6
  import * as podMedia from '../../pod/media.mjs';
7
- import { readBody } from './body.mjs';
7
+ import { readBody, readMultipart } from './body.mjs';
8
8
 
9
9
  // What an attachment is allowed to BE. Anything else is stored as bytes, which
10
10
  // a browser downloads rather than runs.
@@ -34,60 +34,6 @@ export function extensionFor(mediaType, filename = '') {
34
34
  return given && (given === sub || sub.startsWith(given) || given.startsWith(sub)) ? given : (sub || 'bin');
35
35
  }
36
36
 
37
- // Minimal multipart/form-data reader for media uploads: string fields plus
38
- // at most one file part (Mastodon's media endpoints send exactly one).
39
- export function readMultipart(req, limit = 12e6) {
40
- return new Promise((resolve, reject) => {
41
- const chunks = [];
42
- let n = 0;
43
- req.on('data', c => {
44
- n += c.length;
45
- if (n > limit) { reject(new Error('upload too large')); req.destroy(); return; }
46
- chunks.push(c);
47
- });
48
- req.on('error', reject);
49
- req.on('end', () => {
50
- try {
51
- const m = /boundary=(?:"([^"]+)"|([^;]+))/.exec(String(req.headers['content-type'] || ''));
52
- if (!m) return resolve({ fields: {}, file: null });
53
- const buf = Buffer.concat(chunks);
54
- const boundary = Buffer.from('--' + (m[1] || m[2]).trim());
55
- const fields = {};
56
- // `file` is the last one seen, which is what the single-file media
57
- // upload wants. `files` keys them by field name, because the profile
58
- // editor submits an avatar and a header in one request.
59
- const files = {};
60
- let file = null;
61
- let i = buf.indexOf(boundary);
62
- while (i >= 0) {
63
- const start = i + boundary.length;
64
- if (buf.slice(start, start + 2).toString() === '--') break;
65
- const next = buf.indexOf(boundary, start);
66
- if (next < 0) break;
67
- const part = buf.slice(start + 2, next - 2); // strip the CRLFs framing the part
68
- const sep = part.indexOf('\r\n\r\n');
69
- if (sep >= 0) {
70
- const head = part.slice(0, sep).toString();
71
- const body = part.slice(sep + 4);
72
- const name = /name="([^"]*)"/.exec(head)?.[1];
73
- const filename = /filename="([^"]*)"/.exec(head)?.[1];
74
- if (filename !== undefined) {
75
- file = {
76
- filename,
77
- contentType: /content-type:\s*([^\r\n]+)/i.exec(head)?.[1]?.trim() || 'application/octet-stream',
78
- data: body,
79
- };
80
- if (name) files[name] = file;
81
- } else if (name) fields[name] = body.toString();
82
- }
83
- i = next;
84
- }
85
- resolve({ fields, file, files });
86
- } catch (e) { reject(e); }
87
- });
88
- });
89
- }
90
-
91
37
  export async function handle(api, ctx) {
92
38
  const { req, res, pathname, url, send } = ctx; // eslint-disable-line no-unused-vars
93
39
 
@@ -42,6 +42,42 @@ const escapeHtml = (s) => String(s).replace(/[&<>"']/g, c =>
42
42
  const parseRedirects = (v) => (Array.isArray(v) ? v : String(v || '').split(/\s+/))
43
43
  .map(s => s.trim()).filter(Boolean);
44
44
 
45
+ // The sign-in page for an account whose owner signs in at their pod: no
46
+ // password of ours exists, and none is asked for. The page carries the
47
+ // client's request in its address, signs the owner in at the pod with the
48
+ // fediverse-account library, and proves that sign-in back to /oauth/authorize.
49
+ function sendPodSigninPage(res, client, webId, mount = '') {
50
+ let asking = '';
51
+ if (client && (client.name || client.redirect)) {
52
+ let where = '';
53
+ try { where = client.redirect ? new URL(client.redirect).host : ''; } catch { /* oob or blank */ }
54
+ const who = client.name ? escapeHtml(client.name) : (where ? escapeHtml(where) : 'A client');
55
+ asking = `<p><strong>${who}</strong> is asking to use your account`
56
+ + `${where ? `, and will be sent back to <code>${escapeHtml(where)}</code>` : ''}.</p>`;
57
+ }
58
+ res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-cache' });
59
+ res.end(`<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
60
+ <title>Sign in with your pod</title>
61
+ <style>:root{color-scheme:light dark;--heading:#1a4f8a;--btn:#3d6b35;--line:#9a9a9a}
62
+ body{font:20px Arial,Helvetica,sans-serif;max-width:28rem;margin:12vh auto;padding:0 1rem}
63
+ h1{color:var(--heading);font-size:1.5rem}
64
+ code{word-break:break-all;background:rgba(120,120,120,.12);padding:.05em .35em;border-radius:4px}
65
+ button{font:inherit;padding:.6rem 1.1rem;background:var(--btn);color:#fff;border:none;border-radius:9px;cursor:pointer}
66
+ button[hidden]{display:none}
67
+ #signin-status{min-height:1.5em;padding:.6rem .8rem;border:1px solid var(--line);border-radius:8px;margin-top:1rem}
68
+ @media (prefers-color-scheme:dark){:root{--heading:#7fb3e8;--btn:#5a8a4f}}</style></head><body>
69
+ <main>
70
+ <h1>Sign in with your pod</h1>
71
+ ${asking}
72
+ <p>This account belongs to <a id="webid" href="${escapeHtml(webId)}">${escapeHtml(webId)}</a>. Sign in at that pod to allow it.</p>
73
+ <p><button type="button" id="signin" hidden>Sign in with your pod</button></p>
74
+ <p id="signin-status" role="status" aria-live="polite"></p>
75
+ </main>
76
+ <script type="module" src="${escapeHtml(mount)}/oauth/session/signin.mjs"></script>
77
+ </body></html>`);
78
+ return true;
79
+ }
80
+
45
81
  function sendLoginForm(res, params, error = '', client = null, status = null, headers = {}) {
46
82
  const hidden = [...params.entries()].filter(([k]) => k !== 'password')
47
83
  .map(([k, v]) => `<input type="hidden" name="${escapeHtml(k)}" value="${escapeHtml(v)}">`).join('\n');
@@ -378,6 +414,21 @@ export async function handle(api, ctx) {
378
414
  let body = null;
379
415
  if (req.method === 'POST') { body = await readBody(req); params = new URLSearchParams(body); }
380
416
  const redirect = params.get('redirect_uri') || '';
417
+ // The owner's pod sign-in, proved: a Solid-OIDC token with its DPoP proof,
418
+ // from the WebID this account belongs to. It stands in for the password
419
+ // everywhere below.
420
+ let proved = false;
421
+ if (req.method === 'POST' && req.headers.authorization && req.headers.dpop && api.podAuth) {
422
+ const proof = await api.podAuth(req, pathname);
423
+ if (!proof.ok) return send(proof.status || 401, { error: proof.error });
424
+ proved = true;
425
+ }
426
+ const ownerWebId = api.ownerWebId?.() || null;
427
+ // The way back from the pod's login: no client named, only the pod's
428
+ // code. The page finishes the sign-in and returns to the client's request.
429
+ if (req.method === 'GET' && !params.get('client_id') && params.get('code') && params.get('state') && ownerWebId) {
430
+ return sendPodSigninPage(res, null, ownerWebId, api.mount || '');
431
+ }
381
432
  const app = api.findApp(params.get('client_id') || '');
382
433
  // A client that published its own metadata document needs no
383
434
  // registration here: the document is its name and says where it may be
@@ -419,14 +470,16 @@ export async function handle(api, ctx) {
419
470
  // port is the owner" is a statement about the machine rather than
420
471
  // about a web page. Anything pointing elsewhere has to be approved by
421
472
  // somebody who knows the password.
422
- if (external && !api.store.getConfig()?.uiPassword && !api.redirectAllowed(redirect)) {
473
+ const client = { name: app?.name || doc?.name || null, redirect, scope: params.get('scope') || 'read' };
474
+ if (external && !proved && !api.store.getConfig()?.uiPassword && !api.redirectAllowed(redirect)) {
475
+ // With a pod to sign in at, the owner approves the client there.
476
+ if (req.method === 'GET' && ownerWebId) return sendPodSigninPage(res, client, ownerWebId, api.mount || '');
423
477
  api.log(`authorize refused: no UI password, and "${redirect}" is not an address of this agent`);
424
478
  return send(403, {
425
479
  error: 'this client asks to be sent somewhere other than this agent, and no password is '
426
480
  + 'set to approve that with. Run `fedipod passwd` and try again.',
427
481
  });
428
482
  }
429
- const client = { name: app?.name || doc?.name || null, redirect, scope: params.get('scope') || 'read' };
430
483
  if (app) {
431
484
  if (!app.redirectUris.includes(redirect)) {
432
485
  api.log(`authorize refused: redirect_uri "${redirect}" not registered for ${app.clientId}`);
@@ -448,7 +501,7 @@ export async function handle(api, ctx) {
448
501
  api.log(`authorize refused: redirect_uri "${redirect}" is not this agent`);
449
502
  return send(400, { error: 'redirect_uri must be an address of this agent' });
450
503
  }
451
- if (req.method === 'POST') {
504
+ if (req.method === 'POST' && !proved) {
452
505
  if (api.rateLimited()) {
453
506
  api.log('authorize rate limited');
454
507
  // 429, not 401: a client that reads this as a wrong password will
@@ -460,11 +513,13 @@ export async function handle(api, ctx) {
460
513
  if (!pw || !checkPassword(pw, body.password || '')) {
461
514
  return sendLoginForm(res, params, 'wrong password — try again', client);
462
515
  }
463
- } else if (pw) {
516
+ } else if (pw && !proved) {
464
517
  // The login/approve screen names what is asking before the owner types
465
518
  // the password — a cross-site client cannot forge past that.
466
519
  return sendLoginForm(res, params, '', client);
467
- } else if (api.allowed && !api.allowed.isLocalRequest(req)) {
520
+ } else if (!proved && api.allowed && !api.allowed.isLocalRequest(req) && !api.throughDoor?.(req)) {
521
+ // With a pod to sign in at, the owner does that instead of a password.
522
+ if (req.method === 'GET' && ownerWebId) return sendPodSigninPage(res, client, ownerWebId, api.mount || '');
468
523
  // No password set, and this request did not come from this machine.
469
524
  //
470
525
  // The instant-authorize path is honest theatre on loopback: whoever can
@@ -477,10 +532,9 @@ export async function handle(api, ctx) {
477
532
  // dispatched before that check ever runs, so it needs its own.
478
533
  api.log(`authorize refused: no UI password, and "${req.headers.host}" is not this machine`);
479
534
  return send(403, {
480
- error: 'this agent answers on an address outside this machine and has no password set — '
481
- + (api.embedded
482
- ? 'POST {"password":"…"} to the owner door\'s /config with its door secret before logging in'
483
- : 'run `fedipod passwd` before logging in over that address'),
535
+ error: api.embedded
536
+ ? 'Open your account\'s management page first, then sign in from there.'
537
+ : 'Signing in from another machine needs a password. On the machine that runs your account, run: fedipod passwd',
484
538
  });
485
539
  }
486
540
  // External clients get a bound code; the local flow keeps code==token.
@@ -493,6 +547,9 @@ export async function handle(api, ctx) {
493
547
  const target = new URL(redirect);
494
548
  target.searchParams.set('code', code);
495
549
  if (params.get('state')) target.searchParams.set('state', params.get('state'));
550
+ // A proved sign-in came from the page's own request, which cannot follow
551
+ // a redirect to a client's address; the page is told where to go.
552
+ if (proved) return send(200, { redirect: target.href });
496
553
  res.writeHead(302, { location: target.href });
497
554
  res.end();
498
555
  return true;
@@ -9,9 +9,25 @@ import { sanitizeHtml, followsNeedApproval, publicHandle } from '../../core/wire
9
9
  import { profileUrl, postUrl } from '../../connections/bskyfeed.mjs';
10
10
  import { htmlToText } from './body.mjs';
11
11
 
12
- // 1x1 transparent PNG — placeholder avatar/header for accounts without icons.
12
+ // 1x1 transparent PNG — placeholder header, and the instance thumbnail. A
13
+ // banner nobody set should take up no room and show nothing; an avatar is a
14
+ // different matter (below).
13
15
  export const TRANSPARENT_PNG = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==';
14
16
 
17
+ // The avatar for an account with no icon of its own: a head and shoulders on a
18
+ // slate square, 64x64. It used to be the transparent pixel above, which is
19
+ // correct JSON and invisible on screen — in Sengi's left bar the account button
20
+ // (the one control that opens the account) rendered as an empty square, so
21
+ // clicking it looked like nothing happening.
22
+ //
23
+ // A PNG rather than the SVG this was first written as, and not by preference:
24
+ // Angular sanitizes every `<img src>` binding and trusts raster data URIs only,
25
+ // because an SVG can carry script. An `svg+xml` one comes back out of it as
26
+ // `unsafe:data:image/svg+xml` and draws nothing. Sengi is Angular.
27
+ //
28
+ // Redraw it with `node claude/scratch/make-default-avatar.mjs`.
29
+ export const DEFAULT_AVATAR = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAABd0lEQVR42u3ZoW4CQRDG8Xk6NBLbVCJ5gBpMTQ2iBlFR29RXNsFhz1JHXV9hydfkEkoobW5n2F34i0kIHMfN73ZvdwYbTabpmsMAAAAAAAAAAAAAAAAAAACiY3wzS/ePz+n1bZW6zTZ9fH59h17rPX2mYy4OQEktnl5+JP1b6Bgdey6IcIDb2Ty9r7s/Ez8MfUffbRpACfznrp8aDdEIFjnsh9z5YyMhcjqEAWge5ybfh87VFIDuWM7QPzYVokZBCICWM6/k+9A5mwHQmu4NoHM2A+A5/PenQTMA3sn3AQBTgIcgyyAbIbbCFEOUwzREaInRFKUtDgAAFQLcPSxDCqH9Z4V+o0oAXVhU4ofhiWCtJe+NYKV3e6V3idkAHvv9nDqhKIBnxVeqUrRaav5SvQKrqetTols0GGDdbaoB0LWcFUBP31qS72PoimCtD//caWC1tL1Ltc2ttbXfe08wCKC25HP+OgMAAAAAAAAAAAAAAAAArhNgB1PE/x/JOiuNAAAAAElFTkSuQmCC';
30
+
15
31
  const selfIcon = (api, self, cached) =>
16
32
  (self ? api.store.getConfig()?.icon : null) || cached.icon;
17
33
 
@@ -49,8 +65,8 @@ export function account(api, actorUrl, { selfAcct } = {}) {
49
65
  created_at: (self && api.store.getConfig()?.createdAt) || '2026-01-01T00:00:00.000Z',
50
66
  note: (self ? api.store.getConfig()?.summary : cached.summary) || '',
51
67
  url: actorUrl, uri: actorUrl,
52
- avatar: selfIcon(api, self, cached) || TRANSPARENT_PNG,
53
- avatar_static: selfIcon(api, self, cached) || TRANSPARENT_PNG,
68
+ avatar: selfIcon(api, self, cached) || DEFAULT_AVATAR,
69
+ avatar_static: selfIcon(api, self, cached) || DEFAULT_AVATAR,
54
70
  header: (self ? api.store.getConfig()?.image : null) || TRANSPARENT_PNG,
55
71
  header_static: (self ? api.store.getConfig()?.image : null) || TRANSPARENT_PNG,
56
72
  // A remote actor's counts are whatever its own collections said when we
@@ -217,7 +233,37 @@ export async function acctReply(api, send, body, parent, visibility) {
217
233
  } catch (e) { return send(422, { error: e.message }); }
218
234
  }
219
235
 
220
- export function status(api, s, { all } = {}) {
236
+ // The post a status quotes, and how the quote stands: `pending` until the
237
+ // quoted author allows it, `accepted` once they have, `rejected` if they
238
+ // refused. The quoted post is shown whenever it is held — a reader wants to
239
+ // see what was quoted, whatever the state says. One level deep: a quote
240
+ // inside a quote is named by id, not rendered, so the shape stays finite.
241
+ export function quoteJson(api, s, { all, depth = 0 } = {}) {
242
+ if (!s.quote) return null;
243
+ const state = s.quoteState || 'pending';
244
+ const quoted = state !== 'rejected' && (all || api.store.getStatuses()).find(x => x.noteId === s.quote);
245
+ if (depth > 0) return { state, quoted_status_id: quoted ? api.store.idFor(quoted.noteId) : null };
246
+ return { state, quoted_status: quoted ? api.status(quoted, { all, depth: depth + 1 }) : null };
247
+ }
248
+
249
+ // Who may quote a post, in the client's words, and what that means for this
250
+ // account. Our own posts: anyone, when the world can read it; nobody
251
+ // otherwise. Another's: whatever policy the post carried — `unknown` when it
252
+ // carried none, which is what a client hides the quote button on.
253
+ export function quoteApproval(api, s) {
254
+ if (s.actor === api.urls?.actor) {
255
+ const open = !s.visibility || s.visibility === 'public' || s.visibility === 'unlisted';
256
+ return { automatic: open ? ['public'] : [], manual: [], current_user: open ? 'automatic' : 'denied' };
257
+ }
258
+ const p = s.quotePolicy;
259
+ if (!p) return { automatic: [], manual: [], current_user: 'unknown' };
260
+ const follows = () => api.store.getContacts().following.some(f => f.actor === s.actor && f.accepted);
261
+ const may = (list) => list.includes('public') || (list.includes('followers') && follows());
262
+ const current = may(p.automatic) ? 'automatic' : may(p.manual) ? 'manual' : 'denied';
263
+ return { automatic: p.automatic, manual: p.manual, current_user: current };
264
+ }
265
+
266
+ export function status(api, s, { all, depth = 0 } = {}) {
221
267
  const replies = (all || api.store.getStatuses()).filter(x => x.inReplyTo === s.noteId).length;
222
268
  return {
223
269
  id: api.store.idFor(s.noteId),
@@ -258,6 +304,8 @@ export function status(api, s, { all } = {}) {
258
304
  shortcode: e.shortcode, url: e.url, static_url: e.url, visible_in_picker: false,
259
305
  })),
260
306
  card: null, poll: s.poll ? api.pollJson(s) : null,
307
+ quote: quoteJson(api, s, { all, depth }),
308
+ quote_approval: quoteApproval(api, s),
261
309
  // What a filter matched, if any. Mastodon's clients read this and do the
262
310
  // hiding or warning; they do NOT match keywords themselves — Phanpy does
263
311
  // not — so filters that were stored and served but never applied were a
@@ -346,6 +394,7 @@ export function pushNotify(api, n) {
346
394
  mention: 'mentioned you', favourite: 'favourited your post',
347
395
  reblog: 'boosted your post', follow: 'followed you',
348
396
  'follow-request': 'asked to follow you', move: 'moved account',
397
+ reaction: `reacted ${n.emoji || ''} to your post`, quote: 'quoted your post',
349
398
  };
350
399
  const s = n.noteId && api.store.getStatuses().find(x => x.noteId === n.noteId);
351
400
  return api.push.notify(n, {
@@ -419,10 +468,18 @@ export function relationship(api, actorUrl) {
419
468
  // and a client that does not know the type shows "Unknown notification type"
420
469
  // — which is what every Mastodon client did with a follow request here. The
421
470
  // stored spelling is left alone so existing state keeps reading.
422
- export function notificationType(api, t) { return t === 'follow-request' ? 'follow_request' : t; }
471
+ // Mastodon's spellings for what is stored here. A reaction is what Pleroma's
472
+ // API calls an emoji reaction, and what clients render as one.
473
+ export function notificationType(api, t) {
474
+ return t === 'follow-request' ? 'follow_request' : t === 'reaction' ? 'emoji_reaction' : t;
475
+ }
423
476
 
424
477
  export function notification(api, n) {
425
478
  const out = { id: n.id, type: api.notificationType(n.type), created_at: n.at, account: api.account(n.actor) };
479
+ if (n.type === 'reaction') {
480
+ out.emoji = n.emoji;
481
+ if (n.url) out.emoji_url = n.url;
482
+ }
426
483
  if (n.noteId) {
427
484
  const s = api.store.getStatuses().find(x => x.noteId === n.noteId);
428
485
  if (s) out.status = api.status(s);
@@ -42,10 +42,28 @@ export async function handle(api, ctx) {
42
42
  const media = api.store.getMedia();
43
43
  const attachments = mediaIds.map(id => media[id] && { id, ...media[id] }).filter(Boolean);
44
44
 
45
+ // A quote: the post named is one this account holds, and its author has
46
+ // said it may be quoted. The author's answer decides what the reader sees;
47
+ // the reasons a quote cannot even be asked for are the client's to show.
48
+ let quote = null;
49
+ if (body.quoted_status_id) {
50
+ const quotedUrl = api.store.urlFor(String(body.quoted_status_id));
51
+ const quoted = quotedUrl && api.store.getStatuses().find(x => x.noteId === quotedUrl);
52
+ if (!quoted) return send(404, { error: 'Record not found' });
53
+ if (['bsky', 'acct'].includes(quoted.kind)) return send(422, { error: 'that post cannot be quoted from this account' });
54
+ if (visibility === 'private' || visibility === 'direct') return send(422, { error: 'a quote post is public or unlisted' });
55
+ const may = api.quoteApproval(quoted).current_user;
56
+ if (may === 'denied') return send(422, { error: 'the author of that post does not allow quotes' });
57
+ if (may === 'unknown') return send(422, { error: 'the author of that post has not said whether it may be quoted' });
58
+ if (body.scheduled_at) return send(422, { error: 'a quote cannot be scheduled' });
59
+ quote = { id: quoted.noteId, actor: quoted.actor };
60
+ }
61
+
45
62
  const asking = pollParams(body);
46
63
  if (asking) {
47
64
  // Mastodon's own rules, stated here rather than discovered inside the
48
65
  // publisher, so a client gets the reason back on the request it made.
66
+ if (quote) return send(422, { error: 'a poll cannot quote a post' });
49
67
  if (attachments.length) return send(422, { error: 'a poll cannot carry media' });
50
68
  if (body.scheduled_at) return send(422, { error: 'a poll cannot be scheduled' });
51
69
  if (asking.options.length < 2) return send(422, { error: 'a poll needs at least two options' });
@@ -99,7 +117,7 @@ export async function handle(api, ctx) {
99
117
  let note;
100
118
  try {
101
119
  note = await api.agent.publisher.publishNote(body.status,
102
- { inReplyTo, attachments, visibility, spoilerText, sensitive });
120
+ { inReplyTo, attachments, visibility, spoilerText, sensitive, quote });
103
121
  } catch (e) {
104
122
  // A direct message nobody could be found for: the sender's mistake to
105
123
  // fix, said in the client, not a fault of the server.
@@ -110,6 +128,12 @@ export async function handle(api, ctx) {
110
128
  return send(200, api.status(s));
111
129
  }
112
130
 
131
+ // Who may quote a post is the same for every post here — anyone, when the
132
+ // world can read it — and is not set per post.
133
+ if (/^\/api\/v1\/statuses\/[a-f0-9]+\/interaction_policy$/.test(pathname) && req.method === 'PUT') {
134
+ return send(422, { error: 'the quote policy is the same for every post here: anyone may quote a public or unlisted post, nobody a followers-only or direct one' });
135
+ }
136
+
113
137
  // The composer reads the raw text back before an edit.
114
138
  const mSource = /^\/api\/v1\/statuses\/([a-f0-9]+)\/source$/.exec(pathname);
115
139
  if (mSource && req.method === 'GET') {