fedipod 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (276) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +123 -0
  3. package/architecture.svg +89 -0
  4. package/bin/fedipod.mjs +2303 -0
  5. package/cli.md +243 -0
  6. package/groups.md +85 -0
  7. package/gui.md +99 -0
  8. package/lib/account.mjs +87 -0
  9. package/lib/admin.mjs +1606 -0
  10. package/lib/atproto.mjs +310 -0
  11. package/lib/bskyfeed.mjs +166 -0
  12. package/lib/bskygroup.mjs +162 -0
  13. package/lib/c2s.mjs +305 -0
  14. package/lib/certs.mjs +265 -0
  15. package/lib/deliver.mjs +249 -0
  16. package/lib/directory.mjs +90 -0
  17. package/lib/embed.mjs +214 -0
  18. package/lib/export-collections.mjs +161 -0
  19. package/lib/front-core.mjs +322 -0
  20. package/lib/gateway-core.mjs +115 -0
  21. package/lib/guard.mjs +213 -0
  22. package/lib/home.mjs +161 -0
  23. package/lib/httpsig.mjs +109 -0
  24. package/lib/import.mjs +357 -0
  25. package/lib/intake.mjs +1710 -0
  26. package/lib/keys.mjs +139 -0
  27. package/lib/lease.mjs +190 -0
  28. package/lib/localapi.mjs +87 -0
  29. package/lib/mastoapi.mjs +1588 -0
  30. package/lib/migrate.mjs +127 -0
  31. package/lib/oidc-auth.mjs +53 -0
  32. package/lib/podrdf.mjs +132 -0
  33. package/lib/ports.mjs +27 -0
  34. package/lib/publisher.mjs +1003 -0
  35. package/lib/remote.mjs +303 -0
  36. package/lib/safefetch.mjs +235 -0
  37. package/lib/setup.mjs +262 -0
  38. package/lib/social.mjs +505 -0
  39. package/lib/storage.mjs +170 -0
  40. package/lib/store.mjs +470 -0
  41. package/lib/streaming.mjs +154 -0
  42. package/lib/tagfeed.mjs +158 -0
  43. package/lib/ua.mjs +29 -0
  44. package/lib/update.mjs +91 -0
  45. package/lib/webpush.mjs +98 -0
  46. package/lib/wire.mjs +762 -0
  47. package/package.json +60 -0
  48. package/phanpy/dist/404.html +40 -0
  49. package/phanpy/dist/_headers +2 -0
  50. package/phanpy/dist/apple-touch-icon.png +0 -0
  51. package/phanpy/dist/assets/ICONS-DIPJeU0C.js +2 -0
  52. package/phanpy/dist/assets/ICONS-DIPJeU0C.js.map +1 -0
  53. package/phanpy/dist/assets/ScheduledAtField-D-Abfo4J.js +183 -0
  54. package/phanpy/dist/assets/ScheduledAtField-D-Abfo4J.js.map +1 -0
  55. package/phanpy/dist/assets/Temml-CCjoacWt.woff2 +0 -0
  56. package/phanpy/dist/assets/__vite-browser-external-C7iut881.js +2 -0
  57. package/phanpy/dist/assets/__vite-browser-external-C7iut881.js.map +1 -0
  58. package/phanpy/dist/assets/boosts-carousel-BiOaNdMT.jpg +0 -0
  59. package/phanpy/dist/assets/catch-up-O5q5C75Z.png +0 -0
  60. package/phanpy/dist/assets/chunk-aKtaBQYM.js +1 -0
  61. package/phanpy/dist/assets/compose-CFEx7wb4.js +2 -0
  62. package/phanpy/dist/assets/compose-CFEx7wb4.js.map +1 -0
  63. package/phanpy/dist/assets/compose-ZJN5xSb4.js +35 -0
  64. package/phanpy/dist/assets/compose-ZJN5xSb4.js.map +1 -0
  65. package/phanpy/dist/assets/debug.module-YFQgwnIr.js +52 -0
  66. package/phanpy/dist/assets/debug.module-YFQgwnIr.js.map +1 -0
  67. package/phanpy/dist/assets/dom-p1YcJ5Rw.js +2 -0
  68. package/phanpy/dist/assets/dom-p1YcJ5Rw.js.map +1 -0
  69. package/phanpy/dist/assets/home-mobile-dark@2x-Dc-J8-SM.png +0 -0
  70. package/phanpy/dist/assets/home-mobile-light@2x-Bd3fod8m.png +0 -0
  71. package/phanpy/dist/assets/home-tablet-dark@2x-BgcNI8le.png +0 -0
  72. package/phanpy/dist/assets/home-tablet-light@2x-BW2RV9T6.png +0 -0
  73. package/phanpy/dist/assets/icons/add-circle-line-WD0mwORD.js +1 -0
  74. package/phanpy/dist/assets/icons/alert-line-CpFUMf9s.js +1 -0
  75. package/phanpy/dist/assets/icons/android-2-line-Bzxnrd3S.js +1 -0
  76. package/phanpy/dist/assets/icons/announcement-line-5nwaJFJm.js +1 -0
  77. package/phanpy/dist/assets/icons/arrow-down-circle-line-a7Id9-Iv.js +1 -0
  78. package/phanpy/dist/assets/icons/arrow-down-line-B8uzXzq6.js +1 -0
  79. package/phanpy/dist/assets/icons/arrow-left-line-CGrGT9C-.js +1 -0
  80. package/phanpy/dist/assets/icons/arrow-right-line-CE-HERfX.js +1 -0
  81. package/phanpy/dist/assets/icons/arrow-to-up-line-CvdQjmcy.js +1 -0
  82. package/phanpy/dist/assets/icons/arrow-up-circle-line-DEparSNF.js +1 -0
  83. package/phanpy/dist/assets/icons/arrow-up-line-C7yBeugc.js +1 -0
  84. package/phanpy/dist/assets/icons/arrows-right-line-BtbXmJGP.js +1 -0
  85. package/phanpy/dist/assets/icons/at-line-CF66Bznk.js +1 -0
  86. package/phanpy/dist/assets/icons/attachment-line-BY8It-l9.js +1 -0
  87. package/phanpy/dist/assets/icons/blockquote-line-BS6pU_hm.js +1 -0
  88. package/phanpy/dist/assets/icons/board-line-C0UWXyA6.js +1 -0
  89. package/phanpy/dist/assets/icons/bookmark-line-1CtX5JLF.js +1 -0
  90. package/phanpy/dist/assets/icons/building-5-line-Dd59MzbO.js +1 -0
  91. package/phanpy/dist/assets/icons/bus-2-line-6_dvA0GG.js +1 -0
  92. package/phanpy/dist/assets/icons/calendar-day-line-Crpl_fmu.js +1 -0
  93. package/phanpy/dist/assets/icons/calendar-month-line-vommts2C.js +1 -0
  94. package/phanpy/dist/assets/icons/calendar-time-add-line-BU_DTlQk.js +1 -0
  95. package/phanpy/dist/assets/icons/camera-line-Cv69MEei.js +1 -0
  96. package/phanpy/dist/assets/icons/celebrate-line-B8DvIoi0.js +1 -0
  97. package/phanpy/dist/assets/icons/chart-bar-line-FK-QOmtF.js +1 -0
  98. package/phanpy/dist/assets/icons/chart-line-line-BGcJAFWw.js +1 -0
  99. package/phanpy/dist/assets/icons/chat-3-line-YFK2-hwz.js +1 -0
  100. package/phanpy/dist/assets/icons/check-circle-line-DsAxFr5-.js +1 -0
  101. package/phanpy/dist/assets/icons/clipboard-line-BBp364gJ.js +1 -0
  102. package/phanpy/dist/assets/icons/close-circle-line-CICdiuGz.js +1 -0
  103. package/phanpy/dist/assets/icons/close-line-DYrFWcjv.js +1 -0
  104. package/phanpy/dist/assets/icons/cloud-line-tiQAbah8.js +1 -0
  105. package/phanpy/dist/assets/icons/code-line-fAl8iiV3.js +1 -0
  106. package/phanpy/dist/assets/icons/comment-2-line-CR3g7WeB.js +1 -0
  107. package/phanpy/dist/assets/icons/copy-2-line-CLW69yF5.js +1 -0
  108. package/phanpy/dist/assets/icons/delete-2-line-B8xmbr4n.js +1 -0
  109. package/phanpy/dist/assets/icons/document-3-line-DBGNhAg2.js +1 -0
  110. package/phanpy/dist/assets/icons/document-line-C7mb9sb7.js +1 -0
  111. package/phanpy/dist/assets/icons/down-line-BGqSSThd.js +1 -0
  112. package/phanpy/dist/assets/icons/edit-4-line-DcgGaEhx.js +1 -0
  113. package/phanpy/dist/assets/icons/emoji-2-line-D8YgnM-y.js +1 -0
  114. package/phanpy/dist/assets/icons/exit-line-Dc07BS-x.js +1 -0
  115. package/phanpy/dist/assets/icons/external-link-line-BvpSkmjO.js +1 -0
  116. package/phanpy/dist/assets/icons/eye-2-line-CiWXx-f_.js +1 -0
  117. package/phanpy/dist/assets/icons/eye-close-line-BgTaSJ4P.js +1 -0
  118. package/phanpy/dist/assets/icons/filter-2-line-B_mR2usy.js +1 -0
  119. package/phanpy/dist/assets/icons/filter-line-C_nm-3cU.js +1 -0
  120. package/phanpy/dist/assets/icons/flag-1-line-C2HB05I0.js +1 -0
  121. package/phanpy/dist/assets/icons/forbid-circle-line-D3LTT9yd.js +1 -0
  122. package/phanpy/dist/assets/icons/formula-line-DLrt2d6j.js +1 -0
  123. package/phanpy/dist/assets/icons/grid-line-CgUmEikf.js +1 -0
  124. package/phanpy/dist/assets/icons/group-line-C5fnX0wU.js +1 -0
  125. package/phanpy/dist/assets/icons/hand-finger-2-line-BGgxbuL9.js +1 -0
  126. package/phanpy/dist/assets/icons/hashtag-line-CyTEEXBb.js +1 -0
  127. package/phanpy/dist/assets/icons/heart-crack-line-BZddbO2C.js +1 -0
  128. package/phanpy/dist/assets/icons/heart-line-CJ7K47J4.js +1 -0
  129. package/phanpy/dist/assets/icons/history-2-line-kWWvFzqv.js +1 -0
  130. package/phanpy/dist/assets/icons/history-line-bGQiwbTH.js +1 -0
  131. package/phanpy/dist/assets/icons/home-3-line-fRU1zeLG.js +1 -0
  132. package/phanpy/dist/assets/icons/information-line-iNk6-sOY.js +1 -0
  133. package/phanpy/dist/assets/icons/keyboard-line-DsCyuQNz.js +1 -0
  134. package/phanpy/dist/assets/icons/layout-4-line-CZkOX52Z.js +1 -0
  135. package/phanpy/dist/assets/icons/layout-5-line-B_znt5wO.js +1 -0
  136. package/phanpy/dist/assets/icons/left-line-eUw0tya4.js +1 -0
  137. package/phanpy/dist/assets/icons/lightning-line-DaKoDdix.js +1 -0
  138. package/phanpy/dist/assets/icons/link-2-line-DCqLmMA7.js +1 -0
  139. package/phanpy/dist/assets/icons/list-check-line-BAxosBh9.js +1 -0
  140. package/phanpy/dist/assets/icons/lock-line-CPoaXeUW.js +1 -0
  141. package/phanpy/dist/assets/icons/mail-line-B0P8aa1L.js +1 -0
  142. package/phanpy/dist/assets/icons/moon-line-CEqNAToP.js +1 -0
  143. package/phanpy/dist/assets/icons/more-1-fill-Dw2e5xab.js +1 -0
  144. package/phanpy/dist/assets/icons/more-3-line-BIiVjYTz.js +1 -0
  145. package/phanpy/dist/assets/icons/notification-line-BZZq2Gtu.js +1 -0
  146. package/phanpy/dist/assets/icons/pencil-line-CKh8-A1v.js +1 -0
  147. package/phanpy/dist/assets/icons/photo-album-line-Bnfg2u40.js +1 -0
  148. package/phanpy/dist/assets/icons/pin-line-DpTolyhs.js +1 -0
  149. package/phanpy/dist/assets/icons/play-fill-B0-G_8lX.js +1 -0
  150. package/phanpy/dist/assets/icons/qrcode-2-line-B6SPCaHn.js +1 -0
  151. package/phanpy/dist/assets/icons/quill-pen-line-B6LUrnCP.js +1 -0
  152. package/phanpy/dist/assets/icons/quote-left-fill-hQZ1cj_6.js +1 -0
  153. package/phanpy/dist/assets/icons/radar-line-CKyroTth.js +1 -0
  154. package/phanpy/dist/assets/icons/react-line-CoIKhhtK.js +1 -0
  155. package/phanpy/dist/assets/icons/refresh-2-line-D0mTF97S.js +1 -0
  156. package/phanpy/dist/assets/icons/right-line-B3KWJaqc.js +1 -0
  157. package/phanpy/dist/assets/icons/rocket-line-4KZl537D.js +1 -0
  158. package/phanpy/dist/assets/icons/round-fill-C69NTrZ0.js +1 -0
  159. package/phanpy/dist/assets/icons/round-line-B9k_1uo-.js +1 -0
  160. package/phanpy/dist/assets/icons/route-line-B28ReHcE.js +1 -0
  161. package/phanpy/dist/assets/icons/rows-4-line-CrBniagM.js +1 -0
  162. package/phanpy/dist/assets/icons/scan-line-DSf74YdP.js +1 -0
  163. package/phanpy/dist/assets/icons/search-2-line-B6geIU1z.js +1 -0
  164. package/phanpy/dist/assets/icons/settings-3-line-azJwK47w.js +1 -0
  165. package/phanpy/dist/assets/icons/settings-6-line-C_5zc5YR.js +1 -0
  166. package/phanpy/dist/assets/icons/share-2-line-D5MxWjrm.js +1 -0
  167. package/phanpy/dist/assets/icons/share-forward-line-Cp61fZzZ.js +1 -0
  168. package/phanpy/dist/assets/icons/sparkles-2-line-DYYyXTaW.js +1 -0
  169. package/phanpy/dist/assets/icons/sparkles-line-C2hY1s6I.js +1 -0
  170. package/phanpy/dist/assets/icons/time-line-BF3eet1v.js +1 -0
  171. package/phanpy/dist/assets/icons/transfer-4-line-oisfvTBw.js +1 -0
  172. package/phanpy/dist/assets/icons/translate-line-BFWol2Ce.js +1 -0
  173. package/phanpy/dist/assets/icons/unlock-line-X_x5vwv3.js +1 -0
  174. package/phanpy/dist/assets/icons/upload-3-line-DgzwUJeW.js +1 -0
  175. package/phanpy/dist/assets/icons/user-4-line-BVSynZCp.js +1 -0
  176. package/phanpy/dist/assets/icons/user-add-2-line-BlGS5Q8q.js +1 -0
  177. package/phanpy/dist/assets/icons/user-add-line-BbXdHG4z.js +1 -0
  178. package/phanpy/dist/assets/icons/user-edit-line-BFAHOV0X.js +1 -0
  179. package/phanpy/dist/assets/icons/user-follow-line-BXux0wSd.js +1 -0
  180. package/phanpy/dist/assets/icons/user-star-line-DdYlpHLU.js +1 -0
  181. package/phanpy/dist/assets/icons/user-warning-line-Cj43j0_E.js +1 -0
  182. package/phanpy/dist/assets/icons/user-x-line-DIDgNlPH.js +1 -0
  183. package/phanpy/dist/assets/icons/volume-line-Bs390KQt.js +1 -0
  184. package/phanpy/dist/assets/icons/volume-mute-line-DzlzIqFZ.js +1 -0
  185. package/phanpy/dist/assets/icons/walk-line-yNXfB-md.js +1 -0
  186. package/phanpy/dist/assets/icons/world-2-line-CenkGIwj.js +1 -0
  187. package/phanpy/dist/assets/icons/zoom-in-line-CaENUphf.js +1 -0
  188. package/phanpy/dist/assets/icons/zoom-out-line-BDNk0EWo.js +1 -0
  189. package/phanpy/dist/assets/instances-BNPptnn-.json +333 -0
  190. package/phanpy/dist/assets/locales/ar-SA-CfheRbCu.js +1 -0
  191. package/phanpy/dist/assets/locales/ca-ES-DHRW86Tz.js +1 -0
  192. package/phanpy/dist/assets/locales/cs-CZ-CKoqEuA1.js +1 -0
  193. package/phanpy/dist/assets/locales/de-DE-ByRPMRFy.js +1 -0
  194. package/phanpy/dist/assets/locales/eo-UY-CPC7wLCS.js +1 -0
  195. package/phanpy/dist/assets/locales/es-ES-dJa0Fd9t.js +1 -0
  196. package/phanpy/dist/assets/locales/eu-ES-C3djlPR2.js +1 -0
  197. package/phanpy/dist/assets/locales/fa-IR-DTT490Ck.js +1 -0
  198. package/phanpy/dist/assets/locales/fi-FI-B23MCPuz.js +1 -0
  199. package/phanpy/dist/assets/locales/fr-FR-BcaQsO5C.js +1 -0
  200. package/phanpy/dist/assets/locales/gl-ES-BR-CVKBN.js +1 -0
  201. package/phanpy/dist/assets/locales/he-IL-CfheRbCu.js +1 -0
  202. package/phanpy/dist/assets/locales/hu-HU-CfheRbCu.js +1 -0
  203. package/phanpy/dist/assets/locales/it-IT-C1136UFV.js +1 -0
  204. package/phanpy/dist/assets/locales/ja-JP-CRZmRAhD.js +1 -0
  205. package/phanpy/dist/assets/locales/kab-Cz8xH_TT.js +1 -0
  206. package/phanpy/dist/assets/locales/ko-KR-CG9UiY4o.js +1 -0
  207. package/phanpy/dist/assets/locales/lt-LT-CJcI6sHZ.js +1 -0
  208. package/phanpy/dist/assets/locales/nb-NO-CfheRbCu.js +1 -0
  209. package/phanpy/dist/assets/locales/nl-NL-DxN4Yc1t.js +1 -0
  210. package/phanpy/dist/assets/locales/oc-FR-CfheRbCu.js +1 -0
  211. package/phanpy/dist/assets/locales/pl-PL-DkwGXf6P.js +1 -0
  212. package/phanpy/dist/assets/locales/pseudo-LOCALE-CQ4XxkQ0.js +1 -0
  213. package/phanpy/dist/assets/locales/pt-BR-nGL0HdFS.js +1 -0
  214. package/phanpy/dist/assets/locales/pt-PT-Cp17aj2z.js +1 -0
  215. package/phanpy/dist/assets/locales/ru-RU-0Y8cIoaK.js +1 -0
  216. package/phanpy/dist/assets/locales/th-TH-CfheRbCu.js +1 -0
  217. package/phanpy/dist/assets/locales/tok-CfheRbCu.js +1 -0
  218. package/phanpy/dist/assets/locales/tr-TR-CfheRbCu.js +1 -0
  219. package/phanpy/dist/assets/locales/uk-UA-C5E3dFBf.js +1 -0
  220. package/phanpy/dist/assets/locales/zh-CN-BHJi7_et.js +1 -0
  221. package/phanpy/dist/assets/locales/zh-TW-CfheRbCu.js +1 -0
  222. package/phanpy/dist/assets/main-BdqNbG-a.js +26 -0
  223. package/phanpy/dist/assets/main-BdqNbG-a.js.map +1 -0
  224. package/phanpy/dist/assets/mock-home-DGhR__He.js +2 -0
  225. package/phanpy/dist/assets/mock-home-DGhR__He.js.map +1 -0
  226. package/phanpy/dist/assets/multi-column-ROck0NVt.jpg +0 -0
  227. package/phanpy/dist/assets/multi-hashtag-timeline-DH5INVHi.jpg +0 -0
  228. package/phanpy/dist/assets/nested-comments-thread-hv59kZLc.jpg +0 -0
  229. package/phanpy/dist/assets/polyfill-force-H8sUkLGp.js +2 -0
  230. package/phanpy/dist/assets/polyfill-force-H8sUkLGp.js.map +1 -0
  231. package/phanpy/dist/assets/pwa-viewport-ogKbF51K.js +20 -0
  232. package/phanpy/dist/assets/pwa-viewport-ogKbF51K.js.map +1 -0
  233. package/phanpy/dist/assets/style-B1iLPTPR.css +2 -0
  234. package/phanpy/dist/assets/temml-DuyIQMei.js +97 -0
  235. package/phanpy/dist/assets/temml-DuyIQMei.js.map +1 -0
  236. package/phanpy/dist/assets/tinyld.light.node-DtbBhLYC.js +2 -0
  237. package/phanpy/dist/assets/tinyld.light.node-DtbBhLYC.js.map +1 -0
  238. package/phanpy/dist/assets/year-in-posts-B2lywqdx.png +0 -0
  239. package/phanpy/dist/assets/year-in-posts-DJv_star.js +3 -0
  240. package/phanpy/dist/assets/year-in-posts-DJv_star.js.map +1 -0
  241. package/phanpy/dist/compose/index.html +20 -0
  242. package/phanpy/dist/favicon.ico +0 -0
  243. package/phanpy/dist/index.html +73 -0
  244. package/phanpy/dist/logo-192.png +0 -0
  245. package/phanpy/dist/logo-512.png +0 -0
  246. package/phanpy/dist/logo-badge-72.png +0 -0
  247. package/phanpy/dist/logo-maskable-512.png +0 -0
  248. package/phanpy/dist/logo-monochrome-512.png +0 -0
  249. package/phanpy/dist/logo-monochrome-maskable-512.png +0 -0
  250. package/phanpy/dist/manifest.webmanifest +1 -0
  251. package/phanpy/dist/og-image-2.jpg +0 -0
  252. package/phanpy/dist/og-image.png +0 -0
  253. package/phanpy/dist/robots.txt +2 -0
  254. package/phanpy/dist/sw.js +25 -0
  255. package/phanpy/dist/version.json +1 -0
  256. package/run-agent.mjs +805 -0
  257. package/scripts/build-dist.mjs +37 -0
  258. package/scripts/verify-ui.mjs +60 -0
  259. package/ui/README.md +23 -0
  260. package/vendor/gate.cjs +132 -0
  261. package/vendor/idp-grant.cjs +394 -0
  262. package/vendor/jfetch.cjs +16 -0
  263. package/web/admin/admin.js +958 -0
  264. package/web/admin/bar.css +64 -0
  265. package/web/admin/bar.js +61 -0
  266. package/web/admin/client/client.js +66 -0
  267. package/web/admin/client/index.html +42 -0
  268. package/web/admin/index.html +493 -0
  269. package/web/admin/setup/index.html +172 -0
  270. package/web/admin/setup/setup.js +268 -0
  271. package/web/admin/window.css +55 -0
  272. package/web/admin/window.js +136 -0
  273. package/web/front/install.sh +69 -0
  274. package/web/front/new-account.html +424 -0
  275. package/web/front/run.html +138 -0
  276. package/web/front/solid-client-authn.bundle.js +2 -0
package/lib/import.mjs ADDED
@@ -0,0 +1,357 @@
1
+ // import.mjs — bring a non-FediPod account's exports in: the CSV files a
2
+ // Mastodon-family server hands its leaver (follows, blocks, mutes, lists,
3
+ // domain blocks; Misskey's exports are the same shapes). Parsing is here;
4
+ // application runs in a paced worker owned by the agent, because most rows
5
+ // cost network requests — a handle has to be resolved to its actor before
6
+ // anything can be followed, blocked or muted — and a migration-sized list
7
+ // applied at full speed is a flood aimed at every server it names.
8
+ //
9
+ // The state document is shared with stage() and clear(), which run from the
10
+ // admin route while a row is in flight — so nothing here holds a copy of it
11
+ // across a network await. Every mutation is read-modify-write against a fresh
12
+ // read, and a row that finished travelling is committed only if its record is
13
+ // still there and still pending.
14
+
15
+ import crypto from 'node:crypto';
16
+ import { lookupWebFinger, selfLink, confirmDelegation, followActor, unfollowActor } from './social.mjs';
17
+
18
+ export const IMPORT_STATE_DOC = 'import-state.json';
19
+ export const IMPORT_KINDS = ['follow', 'block', 'mute', 'list', 'domain'];
20
+
21
+ const TICK_MS = 400; // one network row per tick at most
22
+ const FOLLOW_GAP_MS = 1000; // follows also deliver, so they go slower
23
+ const PUBLISH_EVERY = 25; // follows between collection republishes
24
+ const MAX_ROWS = 25_000; // staged-row cap: the doc is rewritten per row
25
+
26
+ // A real CSV parse — quoted fields, doubled quotes, CRLF — because a list
27
+ // title may contain a comma and a display name may contain anything.
28
+ export function parseCsv(text) {
29
+ const rows = [];
30
+ let row = [], field = '', q = false;
31
+ const endField = () => { row.push(field); field = ''; };
32
+ const endRow = () => { endField(); if (row.some(f => f !== '')) rows.push(row); row = []; };
33
+ for (let i = 0; i < text.length; i++) {
34
+ const c = text[i];
35
+ if (q) {
36
+ if (c === '"') { if (text[i + 1] === '"') { field += '"'; i++; } else q = false; }
37
+ else field += c;
38
+ } else if (c === '"') q = true;
39
+ else if (c === ',') endField();
40
+ else if (c === '\n' || c === '\r') { if (c === '\r' && text[i + 1] === '\n') i++; endRow(); }
41
+ else field += c;
42
+ }
43
+ endRow();
44
+ return rows;
45
+ }
46
+
47
+ const HANDLE = /^[^@\s]+@[^@\s]+$/;
48
+ const DOMAIN = /^[a-z0-9-]+(\.[a-z0-9-]+)+$/i;
49
+ const cleanHandle = (s) => String(s || '').trim().replace(/^@/, '');
50
+
51
+ // CSV text → normalized row values for one kind. Header and comment rows are
52
+ // recognized only where they can exist — a LIST title is free text and may
53
+ // legitimately start with '#' or say anything. Invalid rows come back counted,
54
+ // with the first few quoted so a mis-picked file is obvious.
55
+ export function normalizeImport(kind, text) {
56
+ if (!IMPORT_KINDS.includes(kind)) throw new Error(`unknown import kind: ${kind}`);
57
+ const rows = parseCsv(String(text || ''));
58
+ const values = [];
59
+ const invalid = [];
60
+ for (const cols of rows) {
61
+ const first = String(cols[0] || '').trim();
62
+ if (!first) continue;
63
+ if (kind !== 'list' && /^#/.test(first)) continue; // comment
64
+ if (kind !== 'list' && kind !== 'domain' && /account address/i.test(first)) continue; // header
65
+ if (kind === 'domain') {
66
+ const d = first.toLowerCase();
67
+ if (DOMAIN.test(d)) values.push(d); else invalid.push(first);
68
+ } else if (kind === 'list') {
69
+ const handle = cleanHandle(cols[1]);
70
+ if (first && HANDLE.test(handle)) values.push({ value: handle, list: first });
71
+ else invalid.push(cols.join(','));
72
+ } else {
73
+ const handle = cleanHandle(first);
74
+ if (HANDLE.test(handle)) values.push(handle); else invalid.push(first);
75
+ }
76
+ }
77
+ return { values, invalid };
78
+ }
79
+
80
+ // The paced applier. One instance per agent, armed only while the agent is
81
+ // active — startActive/requestTakeover resume it, demote stops it — and each
82
+ // tick re-checks the lease and the pod's backoff before touching anything.
83
+ export class ImportWorker {
84
+ constructor({ agent, log }) {
85
+ this.agent = agent;
86
+ this.log = log || (() => {});
87
+ this.timer = null;
88
+ this.busy = false;
89
+ this.lastFollowAt = 0;
90
+ }
91
+
92
+ state() { return this.agent.store.read(IMPORT_STATE_DOC, null); }
93
+
94
+ // Add parsed rows to the run. Rows already staged, already applied, or
95
+ // already following are counted out rather than re-queued, so re-running
96
+ // the same file is a no-op. The cap keeps the per-row rewrite bounded.
97
+ stage(kind, values) {
98
+ const agent = this.agent;
99
+ const st = this.state() || { startedAt: new Date().toISOString(), rows: [] };
100
+ delete st.completedAt; // a new batch reopens the run
101
+ const key = (r) => [r.kind, r.list || '', r.value].join(' ');
102
+ const have = new Set(st.rows.map(key));
103
+ const following = new Set(agent.store.getContacts().following.map(f => f.handle).filter(Boolean));
104
+ const domains = new Set(agent.store.getBlocklist().domains);
105
+ let staged = 0, duplicate = 0, already = 0, refused = 0;
106
+ for (const v of values) {
107
+ const row = typeof v === 'string' ? { kind, value: v } : { kind, ...v };
108
+ row.status = 'pending';
109
+ if (have.has(key(row))) { duplicate++; continue; }
110
+ if (kind === 'follow' && following.has(row.value)) { already++; continue; }
111
+ if (kind === 'domain' && domains.has(row.value)) { already++; continue; }
112
+ if (st.rows.length >= MAX_ROWS) { refused++; continue; }
113
+ have.add(key(row));
114
+ st.rows.push(row);
115
+ staged++;
116
+ }
117
+ agent.store.write(IMPORT_STATE_DOC, st);
118
+ if (staged) this.start();
119
+ return { staged, duplicate, already, refused, total: st.rows.length };
120
+ }
121
+
122
+ progress() {
123
+ const st = this.state();
124
+ const prior = st?.doneCount || 0;
125
+ if (!st?.rows?.length && !prior) return { rows: 0, pending: 0, done: 0, failed: 0, running: !!this.timer };
126
+ const byKind = {};
127
+ let pending = 0, done = prior, failed = 0;
128
+ for (const r of st.rows) {
129
+ const k = (byKind[r.kind] ||= { pending: 0, done: 0, failed: 0 });
130
+ k[r.status]++;
131
+ if (r.status === 'pending') pending++;
132
+ else if (r.status === 'done') done++;
133
+ else failed++;
134
+ }
135
+ return {
136
+ rows: st.rows.length + prior, pending, done, failed, byKind,
137
+ running: !!this.timer, startedAt: st.startedAt, completedAt: st.completedAt || null,
138
+ failures: st.rows.filter(r => r.status === 'failed').slice(0, 100)
139
+ .map(r => ({ kind: r.kind, value: r.value, ...(r.list ? { list: r.list } : {}), reason: r.reason })),
140
+ };
141
+ }
142
+
143
+ clear() {
144
+ this.stop();
145
+ this.agent.store.write(IMPORT_STATE_DOC, { rows: [] });
146
+ }
147
+
148
+ // Called from startActive and after a takeover: pick a stranded run back
149
+ // up. Any unclosed run arms the timer — rows still pending, or a final
150
+ // publish that never landed.
151
+ resume() {
152
+ const st = this.state();
153
+ if (!st?.rows?.length || st.completedAt) return;
154
+ this.start();
155
+ }
156
+
157
+ start() {
158
+ if (this.timer) return;
159
+ this.timer = setInterval(() => this.tick().catch(e => this.log(`import tick: ${e.message}`)), TICK_MS);
160
+ this.timer.unref();
161
+ }
162
+
163
+ stop() {
164
+ clearInterval(this.timer);
165
+ this.timer = null;
166
+ }
167
+
168
+ async tick() {
169
+ if (this.busy) return;
170
+ this.busy = true;
171
+ try {
172
+ const agent = this.agent;
173
+ const st = this.state();
174
+ if (!st?.rows) { this.stop(); return; }
175
+ // A parked or moved actor runs no import: parking mass-unfollows and a
176
+ // move hands the account on — the worker must not pull against either.
177
+ const cfg = agent.store.getConfig?.() || {};
178
+ if (cfg?.quiescedAt || cfg?.movedTo) { this.stop(); return; }
179
+ const next = st.rows.find(r => r.status === 'pending');
180
+ if (!next) {
181
+ if (!st.completedAt) await this.finish();
182
+ else this.stop();
183
+ return;
184
+ }
185
+ if (agent.viewer) return; // demoted mid-run
186
+ if (agent.lease && !agent.lease.stillHeld()) return; // another device is acting
187
+ if (agent.remote?.pausedUntil > Date.now()) return; // the pod asked for quiet
188
+ if (next.kind === 'follow' && Date.now() - this.lastFollowAt < FOLLOW_GAP_MS) return;
189
+ if (next.kind === 'domain') {
190
+ // Free rows — no resolution, no delivery — the whole batch lands in
191
+ // one tick. No awaits between the read and the write, so no race.
192
+ const b = agent.store.getBlocklist();
193
+ const fresh = this.state();
194
+ if (!fresh?.rows) return;
195
+ for (const r of fresh.rows) {
196
+ if (r.status !== 'pending' || r.kind !== 'domain') continue;
197
+ if (!b.domains.includes(r.value)) b.domains.push(r.value);
198
+ r.status = 'done';
199
+ }
200
+ agent.store.setBlocklist(b);
201
+ agent.store.write(IMPORT_STATE_DOC, fresh);
202
+ return;
203
+ }
204
+ // The network happens here, touching no state document; the verdict is
205
+ // committed against a FRESH read afterwards, so rows staged or cleared
206
+ // while this one travelled are never overwritten.
207
+ const outcome = await this.applyRow(next);
208
+ if (outcome) this.commitRow(next, outcome);
209
+ if (outcome?.followed && (this.state()?.followsSincePublish || 0) >= PUBLISH_EVERY) {
210
+ await agent.publisher.publishCollections({ following: true, pending: true });
211
+ const after = this.state();
212
+ if (after) { after.followsSincePublish = 0; agent.store.write(IMPORT_STATE_DOC, after); }
213
+ }
214
+ } finally { this.busy = false; }
215
+ }
216
+
217
+ // Record one travelled row's verdict. Fresh read; the row is matched by
218
+ // content — if clear() dropped it or a duplicate check superseded it while
219
+ // it was in flight, there is nothing to record and nothing is written.
220
+ commitRow(row, outcome) {
221
+ const st = this.state();
222
+ const match = st?.rows?.find(r => r.status === 'pending' && r.kind === row.kind
223
+ && r.value === row.value && (r.list || '') === (row.list || ''));
224
+ if (!match) return;
225
+ match.status = outcome.status;
226
+ if (outcome.reason) match.reason = outcome.reason;
227
+ if (outcome.followed) st.followsSincePublish = (st.followsSincePublish || 0) + 1;
228
+ if (outcome.blocked) st.blocksApplied = true;
229
+ this.agent.store.write(IMPORT_STATE_DOC, st);
230
+ }
231
+
232
+ // handle → { id, inbox } — the lean resolve: WebFinger, one actor fetch,
233
+ // the delegation round-trip, and nothing else. A handle we already follow
234
+ // answers from contacts without touching the network, and the blocklist is
235
+ // honored the same way the interactive resolve honors it.
236
+ async resolve(clean, { forBlock = false } = {}) {
237
+ const agent = this.agent;
238
+ const host = clean.slice(clean.lastIndexOf('@') + 1);
239
+ if (agent.store.isBlocked(`https://${host}/`)) throw new Error('domain is blocked');
240
+ const known = agent.store.getContacts().following.find(f => f.handle === clean && f.actor);
241
+ if (known) return { id: known.actor, inbox: known.inbox };
242
+ const jrd = await lookupWebFinger('acct:' + clean);
243
+ const href = selfLink(jrd)?.href;
244
+ if (!href) throw new Error('webfinger found no actor');
245
+ const doc = await agent.intake.fetchAP(href);
246
+ if (!doc?.id) throw new Error('actor document unusable');
247
+ if (!forBlock && agent.store.isBlocked(doc.id)) throw new Error('actor is blocked');
248
+ await confirmDelegation(clean.slice(clean.lastIndexOf('@') + 1), doc);
249
+ return doc;
250
+ }
251
+
252
+ // One row's whole journey, returned as a verdict — never written here.
253
+ // null means "no verdict": the pod's backoff interrupted it, and the row
254
+ // stays pending for after the pause.
255
+ async applyRow(row) {
256
+ const agent = this.agent;
257
+ const done = (extra = {}) => ({ status: 'done', ...extra });
258
+ try {
259
+ if (row.kind === 'follow') {
260
+ this.lastFollowAt = Date.now();
261
+ if (agent.store.getContacts().following.some(f => f.handle === row.value)) {
262
+ return done({ reason: 'already following' });
263
+ }
264
+ const doc = await this.resolve(row.value);
265
+ if (agent.store.getContacts().following.some(f => f.actor === doc.id)) {
266
+ return done({ reason: 'already following' });
267
+ }
268
+ // followActor records the contact BEFORE delivering, which is what
269
+ // lets the Accept that comes back seconds later find its record.
270
+ await followActor(agent, doc.id, { publish: false, doc });
271
+ const contacts = agent.store.getContacts();
272
+ const rec = contacts.following.find(f => f.actor === doc.id);
273
+ if (rec && !rec.handle) { rec.handle = row.value; agent.store.setContacts(contacts); }
274
+ return done({ followed: true });
275
+ }
276
+ if (row.kind === 'block') {
277
+ const host = row.value.slice(row.value.lastIndexOf('@') + 1);
278
+ const b = agent.store.getBlocklist();
279
+ if (b.domains.some(d => host === d || host.endsWith('.' + d))) {
280
+ return done({ reason: 'domain already blocked' });
281
+ }
282
+ const doc = await this.resolve(row.value, { forBlock: true });
283
+ const b2 = agent.store.getBlocklist();
284
+ if (b2.actors.includes(doc.id)) return done({ reason: 'already blocked' });
285
+ b2.actors.push(doc.id);
286
+ agent.store.setBlocklist(b2);
287
+ if (agent.store.getContacts().following.some(f => f.actor === doc.id)) {
288
+ await unfollowActor(agent, doc.id).catch(() => {});
289
+ }
290
+ return done({ blocked: true });
291
+ }
292
+ if (row.kind === 'mute') {
293
+ const doc = await this.resolve(row.value);
294
+ const m = agent.store.getMuted();
295
+ if (m.actors.includes(doc.id)) return done({ reason: 'already muted' });
296
+ m.actors.push(doc.id);
297
+ agent.store.setMuted(m);
298
+ return done();
299
+ }
300
+ if (row.kind === 'list') {
301
+ const doc = await this.resolve(row.value);
302
+ const lists = agent.store.getLists();
303
+ let l = lists.find(x => x.title === row.list);
304
+ if (!l) {
305
+ l = { id: crypto.randomBytes(8).toString('hex'), title: row.list, repliesPolicy: 'list', members: [] };
306
+ lists.push(l);
307
+ }
308
+ if ((l.members || []).includes(doc.id)) return done({ reason: 'already on the list' });
309
+ l.members = [...(l.members || []), doc.id];
310
+ agent.store.setLists(lists);
311
+ return done();
312
+ }
313
+ return { status: 'failed', reason: `unknown kind ${row.kind}` };
314
+ } catch (e) {
315
+ if (agent.remote?.pausedUntil > Date.now()) return null;
316
+ return { status: 'failed', reason: e.message };
317
+ }
318
+ }
319
+
320
+ // Everything applied: the batched republishes, once, then the record closes
321
+ // — done rows pruned to a count so a long run's record stays small, failed
322
+ // rows kept until --clear so they can be read. A publish the pod refuses
323
+ // leaves the run open; a restart or the next batch retries it. A batch that
324
+ // landed while the publishes were in flight keeps the run open too.
325
+ async finish() {
326
+ const agent = this.agent;
327
+ let st = this.state();
328
+ if (!st?.rows) { this.stop(); return; }
329
+ if (st.rows.some(r => r.status === 'pending')) return;
330
+ try {
331
+ if (st.followsSincePublish) {
332
+ await agent.publisher.publishCollections({ following: true, pending: true });
333
+ const s = this.state();
334
+ if (s) { s.followsSincePublish = 0; agent.store.write(IMPORT_STATE_DOC, s); }
335
+ }
336
+ if (this.state()?.blocksApplied) {
337
+ await agent.publisher.publishCollections({ blocked: true });
338
+ const s = this.state();
339
+ if (s) { s.blocksApplied = false; agent.store.write(IMPORT_STATE_DOC, s); }
340
+ }
341
+ } catch (e) {
342
+ this.log(`import: final publish failed: ${e.message} — retried on restart or the next batch`);
343
+ this.stop();
344
+ return;
345
+ }
346
+ st = this.state();
347
+ if (!st?.rows || st.rows.some(r => r.status === 'pending')) return;
348
+ const doneNow = st.rows.filter(r => r.status === 'done').length;
349
+ const failed = st.rows.filter(r => r.status === 'failed').length;
350
+ st.doneCount = (st.doneCount || 0) + doneNow;
351
+ st.rows = st.rows.filter(r => r.status !== 'done');
352
+ st.completedAt = new Date().toISOString();
353
+ agent.store.write(IMPORT_STATE_DOC, st);
354
+ this.log(`import finished: ${st.doneCount} applied, ${failed} failed`);
355
+ this.stop();
356
+ }
357
+ }