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.
- package/LICENSE +21 -0
- package/README.md +123 -0
- package/architecture.svg +89 -0
- package/bin/fedipod.mjs +2303 -0
- package/cli.md +243 -0
- package/groups.md +85 -0
- package/gui.md +99 -0
- package/lib/account.mjs +87 -0
- package/lib/admin.mjs +1606 -0
- package/lib/atproto.mjs +310 -0
- package/lib/bskyfeed.mjs +166 -0
- package/lib/bskygroup.mjs +162 -0
- package/lib/c2s.mjs +305 -0
- package/lib/certs.mjs +265 -0
- package/lib/deliver.mjs +249 -0
- package/lib/directory.mjs +90 -0
- package/lib/embed.mjs +214 -0
- package/lib/export-collections.mjs +161 -0
- package/lib/front-core.mjs +322 -0
- package/lib/gateway-core.mjs +115 -0
- package/lib/guard.mjs +213 -0
- package/lib/home.mjs +161 -0
- package/lib/httpsig.mjs +109 -0
- package/lib/import.mjs +357 -0
- package/lib/intake.mjs +1710 -0
- package/lib/keys.mjs +139 -0
- package/lib/lease.mjs +190 -0
- package/lib/localapi.mjs +87 -0
- package/lib/mastoapi.mjs +1588 -0
- package/lib/migrate.mjs +127 -0
- package/lib/oidc-auth.mjs +53 -0
- package/lib/podrdf.mjs +132 -0
- package/lib/ports.mjs +27 -0
- package/lib/publisher.mjs +1003 -0
- package/lib/remote.mjs +303 -0
- package/lib/safefetch.mjs +235 -0
- package/lib/setup.mjs +262 -0
- package/lib/social.mjs +505 -0
- package/lib/storage.mjs +170 -0
- package/lib/store.mjs +470 -0
- package/lib/streaming.mjs +154 -0
- package/lib/tagfeed.mjs +158 -0
- package/lib/ua.mjs +29 -0
- package/lib/update.mjs +91 -0
- package/lib/webpush.mjs +98 -0
- package/lib/wire.mjs +762 -0
- package/package.json +60 -0
- package/phanpy/dist/404.html +40 -0
- package/phanpy/dist/_headers +2 -0
- package/phanpy/dist/apple-touch-icon.png +0 -0
- package/phanpy/dist/assets/ICONS-DIPJeU0C.js +2 -0
- package/phanpy/dist/assets/ICONS-DIPJeU0C.js.map +1 -0
- package/phanpy/dist/assets/ScheduledAtField-D-Abfo4J.js +183 -0
- package/phanpy/dist/assets/ScheduledAtField-D-Abfo4J.js.map +1 -0
- package/phanpy/dist/assets/Temml-CCjoacWt.woff2 +0 -0
- package/phanpy/dist/assets/__vite-browser-external-C7iut881.js +2 -0
- package/phanpy/dist/assets/__vite-browser-external-C7iut881.js.map +1 -0
- package/phanpy/dist/assets/boosts-carousel-BiOaNdMT.jpg +0 -0
- package/phanpy/dist/assets/catch-up-O5q5C75Z.png +0 -0
- package/phanpy/dist/assets/chunk-aKtaBQYM.js +1 -0
- package/phanpy/dist/assets/compose-CFEx7wb4.js +2 -0
- package/phanpy/dist/assets/compose-CFEx7wb4.js.map +1 -0
- package/phanpy/dist/assets/compose-ZJN5xSb4.js +35 -0
- package/phanpy/dist/assets/compose-ZJN5xSb4.js.map +1 -0
- package/phanpy/dist/assets/debug.module-YFQgwnIr.js +52 -0
- package/phanpy/dist/assets/debug.module-YFQgwnIr.js.map +1 -0
- package/phanpy/dist/assets/dom-p1YcJ5Rw.js +2 -0
- package/phanpy/dist/assets/dom-p1YcJ5Rw.js.map +1 -0
- package/phanpy/dist/assets/home-mobile-dark@2x-Dc-J8-SM.png +0 -0
- package/phanpy/dist/assets/home-mobile-light@2x-Bd3fod8m.png +0 -0
- package/phanpy/dist/assets/home-tablet-dark@2x-BgcNI8le.png +0 -0
- package/phanpy/dist/assets/home-tablet-light@2x-BW2RV9T6.png +0 -0
- package/phanpy/dist/assets/icons/add-circle-line-WD0mwORD.js +1 -0
- package/phanpy/dist/assets/icons/alert-line-CpFUMf9s.js +1 -0
- package/phanpy/dist/assets/icons/android-2-line-Bzxnrd3S.js +1 -0
- package/phanpy/dist/assets/icons/announcement-line-5nwaJFJm.js +1 -0
- package/phanpy/dist/assets/icons/arrow-down-circle-line-a7Id9-Iv.js +1 -0
- package/phanpy/dist/assets/icons/arrow-down-line-B8uzXzq6.js +1 -0
- package/phanpy/dist/assets/icons/arrow-left-line-CGrGT9C-.js +1 -0
- package/phanpy/dist/assets/icons/arrow-right-line-CE-HERfX.js +1 -0
- package/phanpy/dist/assets/icons/arrow-to-up-line-CvdQjmcy.js +1 -0
- package/phanpy/dist/assets/icons/arrow-up-circle-line-DEparSNF.js +1 -0
- package/phanpy/dist/assets/icons/arrow-up-line-C7yBeugc.js +1 -0
- package/phanpy/dist/assets/icons/arrows-right-line-BtbXmJGP.js +1 -0
- package/phanpy/dist/assets/icons/at-line-CF66Bznk.js +1 -0
- package/phanpy/dist/assets/icons/attachment-line-BY8It-l9.js +1 -0
- package/phanpy/dist/assets/icons/blockquote-line-BS6pU_hm.js +1 -0
- package/phanpy/dist/assets/icons/board-line-C0UWXyA6.js +1 -0
- package/phanpy/dist/assets/icons/bookmark-line-1CtX5JLF.js +1 -0
- package/phanpy/dist/assets/icons/building-5-line-Dd59MzbO.js +1 -0
- package/phanpy/dist/assets/icons/bus-2-line-6_dvA0GG.js +1 -0
- package/phanpy/dist/assets/icons/calendar-day-line-Crpl_fmu.js +1 -0
- package/phanpy/dist/assets/icons/calendar-month-line-vommts2C.js +1 -0
- package/phanpy/dist/assets/icons/calendar-time-add-line-BU_DTlQk.js +1 -0
- package/phanpy/dist/assets/icons/camera-line-Cv69MEei.js +1 -0
- package/phanpy/dist/assets/icons/celebrate-line-B8DvIoi0.js +1 -0
- package/phanpy/dist/assets/icons/chart-bar-line-FK-QOmtF.js +1 -0
- package/phanpy/dist/assets/icons/chart-line-line-BGcJAFWw.js +1 -0
- package/phanpy/dist/assets/icons/chat-3-line-YFK2-hwz.js +1 -0
- package/phanpy/dist/assets/icons/check-circle-line-DsAxFr5-.js +1 -0
- package/phanpy/dist/assets/icons/clipboard-line-BBp364gJ.js +1 -0
- package/phanpy/dist/assets/icons/close-circle-line-CICdiuGz.js +1 -0
- package/phanpy/dist/assets/icons/close-line-DYrFWcjv.js +1 -0
- package/phanpy/dist/assets/icons/cloud-line-tiQAbah8.js +1 -0
- package/phanpy/dist/assets/icons/code-line-fAl8iiV3.js +1 -0
- package/phanpy/dist/assets/icons/comment-2-line-CR3g7WeB.js +1 -0
- package/phanpy/dist/assets/icons/copy-2-line-CLW69yF5.js +1 -0
- package/phanpy/dist/assets/icons/delete-2-line-B8xmbr4n.js +1 -0
- package/phanpy/dist/assets/icons/document-3-line-DBGNhAg2.js +1 -0
- package/phanpy/dist/assets/icons/document-line-C7mb9sb7.js +1 -0
- package/phanpy/dist/assets/icons/down-line-BGqSSThd.js +1 -0
- package/phanpy/dist/assets/icons/edit-4-line-DcgGaEhx.js +1 -0
- package/phanpy/dist/assets/icons/emoji-2-line-D8YgnM-y.js +1 -0
- package/phanpy/dist/assets/icons/exit-line-Dc07BS-x.js +1 -0
- package/phanpy/dist/assets/icons/external-link-line-BvpSkmjO.js +1 -0
- package/phanpy/dist/assets/icons/eye-2-line-CiWXx-f_.js +1 -0
- package/phanpy/dist/assets/icons/eye-close-line-BgTaSJ4P.js +1 -0
- package/phanpy/dist/assets/icons/filter-2-line-B_mR2usy.js +1 -0
- package/phanpy/dist/assets/icons/filter-line-C_nm-3cU.js +1 -0
- package/phanpy/dist/assets/icons/flag-1-line-C2HB05I0.js +1 -0
- package/phanpy/dist/assets/icons/forbid-circle-line-D3LTT9yd.js +1 -0
- package/phanpy/dist/assets/icons/formula-line-DLrt2d6j.js +1 -0
- package/phanpy/dist/assets/icons/grid-line-CgUmEikf.js +1 -0
- package/phanpy/dist/assets/icons/group-line-C5fnX0wU.js +1 -0
- package/phanpy/dist/assets/icons/hand-finger-2-line-BGgxbuL9.js +1 -0
- package/phanpy/dist/assets/icons/hashtag-line-CyTEEXBb.js +1 -0
- package/phanpy/dist/assets/icons/heart-crack-line-BZddbO2C.js +1 -0
- package/phanpy/dist/assets/icons/heart-line-CJ7K47J4.js +1 -0
- package/phanpy/dist/assets/icons/history-2-line-kWWvFzqv.js +1 -0
- package/phanpy/dist/assets/icons/history-line-bGQiwbTH.js +1 -0
- package/phanpy/dist/assets/icons/home-3-line-fRU1zeLG.js +1 -0
- package/phanpy/dist/assets/icons/information-line-iNk6-sOY.js +1 -0
- package/phanpy/dist/assets/icons/keyboard-line-DsCyuQNz.js +1 -0
- package/phanpy/dist/assets/icons/layout-4-line-CZkOX52Z.js +1 -0
- package/phanpy/dist/assets/icons/layout-5-line-B_znt5wO.js +1 -0
- package/phanpy/dist/assets/icons/left-line-eUw0tya4.js +1 -0
- package/phanpy/dist/assets/icons/lightning-line-DaKoDdix.js +1 -0
- package/phanpy/dist/assets/icons/link-2-line-DCqLmMA7.js +1 -0
- package/phanpy/dist/assets/icons/list-check-line-BAxosBh9.js +1 -0
- package/phanpy/dist/assets/icons/lock-line-CPoaXeUW.js +1 -0
- package/phanpy/dist/assets/icons/mail-line-B0P8aa1L.js +1 -0
- package/phanpy/dist/assets/icons/moon-line-CEqNAToP.js +1 -0
- package/phanpy/dist/assets/icons/more-1-fill-Dw2e5xab.js +1 -0
- package/phanpy/dist/assets/icons/more-3-line-BIiVjYTz.js +1 -0
- package/phanpy/dist/assets/icons/notification-line-BZZq2Gtu.js +1 -0
- package/phanpy/dist/assets/icons/pencil-line-CKh8-A1v.js +1 -0
- package/phanpy/dist/assets/icons/photo-album-line-Bnfg2u40.js +1 -0
- package/phanpy/dist/assets/icons/pin-line-DpTolyhs.js +1 -0
- package/phanpy/dist/assets/icons/play-fill-B0-G_8lX.js +1 -0
- package/phanpy/dist/assets/icons/qrcode-2-line-B6SPCaHn.js +1 -0
- package/phanpy/dist/assets/icons/quill-pen-line-B6LUrnCP.js +1 -0
- package/phanpy/dist/assets/icons/quote-left-fill-hQZ1cj_6.js +1 -0
- package/phanpy/dist/assets/icons/radar-line-CKyroTth.js +1 -0
- package/phanpy/dist/assets/icons/react-line-CoIKhhtK.js +1 -0
- package/phanpy/dist/assets/icons/refresh-2-line-D0mTF97S.js +1 -0
- package/phanpy/dist/assets/icons/right-line-B3KWJaqc.js +1 -0
- package/phanpy/dist/assets/icons/rocket-line-4KZl537D.js +1 -0
- package/phanpy/dist/assets/icons/round-fill-C69NTrZ0.js +1 -0
- package/phanpy/dist/assets/icons/round-line-B9k_1uo-.js +1 -0
- package/phanpy/dist/assets/icons/route-line-B28ReHcE.js +1 -0
- package/phanpy/dist/assets/icons/rows-4-line-CrBniagM.js +1 -0
- package/phanpy/dist/assets/icons/scan-line-DSf74YdP.js +1 -0
- package/phanpy/dist/assets/icons/search-2-line-B6geIU1z.js +1 -0
- package/phanpy/dist/assets/icons/settings-3-line-azJwK47w.js +1 -0
- package/phanpy/dist/assets/icons/settings-6-line-C_5zc5YR.js +1 -0
- package/phanpy/dist/assets/icons/share-2-line-D5MxWjrm.js +1 -0
- package/phanpy/dist/assets/icons/share-forward-line-Cp61fZzZ.js +1 -0
- package/phanpy/dist/assets/icons/sparkles-2-line-DYYyXTaW.js +1 -0
- package/phanpy/dist/assets/icons/sparkles-line-C2hY1s6I.js +1 -0
- package/phanpy/dist/assets/icons/time-line-BF3eet1v.js +1 -0
- package/phanpy/dist/assets/icons/transfer-4-line-oisfvTBw.js +1 -0
- package/phanpy/dist/assets/icons/translate-line-BFWol2Ce.js +1 -0
- package/phanpy/dist/assets/icons/unlock-line-X_x5vwv3.js +1 -0
- package/phanpy/dist/assets/icons/upload-3-line-DgzwUJeW.js +1 -0
- package/phanpy/dist/assets/icons/user-4-line-BVSynZCp.js +1 -0
- package/phanpy/dist/assets/icons/user-add-2-line-BlGS5Q8q.js +1 -0
- package/phanpy/dist/assets/icons/user-add-line-BbXdHG4z.js +1 -0
- package/phanpy/dist/assets/icons/user-edit-line-BFAHOV0X.js +1 -0
- package/phanpy/dist/assets/icons/user-follow-line-BXux0wSd.js +1 -0
- package/phanpy/dist/assets/icons/user-star-line-DdYlpHLU.js +1 -0
- package/phanpy/dist/assets/icons/user-warning-line-Cj43j0_E.js +1 -0
- package/phanpy/dist/assets/icons/user-x-line-DIDgNlPH.js +1 -0
- package/phanpy/dist/assets/icons/volume-line-Bs390KQt.js +1 -0
- package/phanpy/dist/assets/icons/volume-mute-line-DzlzIqFZ.js +1 -0
- package/phanpy/dist/assets/icons/walk-line-yNXfB-md.js +1 -0
- package/phanpy/dist/assets/icons/world-2-line-CenkGIwj.js +1 -0
- package/phanpy/dist/assets/icons/zoom-in-line-CaENUphf.js +1 -0
- package/phanpy/dist/assets/icons/zoom-out-line-BDNk0EWo.js +1 -0
- package/phanpy/dist/assets/instances-BNPptnn-.json +333 -0
- package/phanpy/dist/assets/locales/ar-SA-CfheRbCu.js +1 -0
- package/phanpy/dist/assets/locales/ca-ES-DHRW86Tz.js +1 -0
- package/phanpy/dist/assets/locales/cs-CZ-CKoqEuA1.js +1 -0
- package/phanpy/dist/assets/locales/de-DE-ByRPMRFy.js +1 -0
- package/phanpy/dist/assets/locales/eo-UY-CPC7wLCS.js +1 -0
- package/phanpy/dist/assets/locales/es-ES-dJa0Fd9t.js +1 -0
- package/phanpy/dist/assets/locales/eu-ES-C3djlPR2.js +1 -0
- package/phanpy/dist/assets/locales/fa-IR-DTT490Ck.js +1 -0
- package/phanpy/dist/assets/locales/fi-FI-B23MCPuz.js +1 -0
- package/phanpy/dist/assets/locales/fr-FR-BcaQsO5C.js +1 -0
- package/phanpy/dist/assets/locales/gl-ES-BR-CVKBN.js +1 -0
- package/phanpy/dist/assets/locales/he-IL-CfheRbCu.js +1 -0
- package/phanpy/dist/assets/locales/hu-HU-CfheRbCu.js +1 -0
- package/phanpy/dist/assets/locales/it-IT-C1136UFV.js +1 -0
- package/phanpy/dist/assets/locales/ja-JP-CRZmRAhD.js +1 -0
- package/phanpy/dist/assets/locales/kab-Cz8xH_TT.js +1 -0
- package/phanpy/dist/assets/locales/ko-KR-CG9UiY4o.js +1 -0
- package/phanpy/dist/assets/locales/lt-LT-CJcI6sHZ.js +1 -0
- package/phanpy/dist/assets/locales/nb-NO-CfheRbCu.js +1 -0
- package/phanpy/dist/assets/locales/nl-NL-DxN4Yc1t.js +1 -0
- package/phanpy/dist/assets/locales/oc-FR-CfheRbCu.js +1 -0
- package/phanpy/dist/assets/locales/pl-PL-DkwGXf6P.js +1 -0
- package/phanpy/dist/assets/locales/pseudo-LOCALE-CQ4XxkQ0.js +1 -0
- package/phanpy/dist/assets/locales/pt-BR-nGL0HdFS.js +1 -0
- package/phanpy/dist/assets/locales/pt-PT-Cp17aj2z.js +1 -0
- package/phanpy/dist/assets/locales/ru-RU-0Y8cIoaK.js +1 -0
- package/phanpy/dist/assets/locales/th-TH-CfheRbCu.js +1 -0
- package/phanpy/dist/assets/locales/tok-CfheRbCu.js +1 -0
- package/phanpy/dist/assets/locales/tr-TR-CfheRbCu.js +1 -0
- package/phanpy/dist/assets/locales/uk-UA-C5E3dFBf.js +1 -0
- package/phanpy/dist/assets/locales/zh-CN-BHJi7_et.js +1 -0
- package/phanpy/dist/assets/locales/zh-TW-CfheRbCu.js +1 -0
- package/phanpy/dist/assets/main-BdqNbG-a.js +26 -0
- package/phanpy/dist/assets/main-BdqNbG-a.js.map +1 -0
- package/phanpy/dist/assets/mock-home-DGhR__He.js +2 -0
- package/phanpy/dist/assets/mock-home-DGhR__He.js.map +1 -0
- package/phanpy/dist/assets/multi-column-ROck0NVt.jpg +0 -0
- package/phanpy/dist/assets/multi-hashtag-timeline-DH5INVHi.jpg +0 -0
- package/phanpy/dist/assets/nested-comments-thread-hv59kZLc.jpg +0 -0
- package/phanpy/dist/assets/polyfill-force-H8sUkLGp.js +2 -0
- package/phanpy/dist/assets/polyfill-force-H8sUkLGp.js.map +1 -0
- package/phanpy/dist/assets/pwa-viewport-ogKbF51K.js +20 -0
- package/phanpy/dist/assets/pwa-viewport-ogKbF51K.js.map +1 -0
- package/phanpy/dist/assets/style-B1iLPTPR.css +2 -0
- package/phanpy/dist/assets/temml-DuyIQMei.js +97 -0
- package/phanpy/dist/assets/temml-DuyIQMei.js.map +1 -0
- package/phanpy/dist/assets/tinyld.light.node-DtbBhLYC.js +2 -0
- package/phanpy/dist/assets/tinyld.light.node-DtbBhLYC.js.map +1 -0
- package/phanpy/dist/assets/year-in-posts-B2lywqdx.png +0 -0
- package/phanpy/dist/assets/year-in-posts-DJv_star.js +3 -0
- package/phanpy/dist/assets/year-in-posts-DJv_star.js.map +1 -0
- package/phanpy/dist/compose/index.html +20 -0
- package/phanpy/dist/favicon.ico +0 -0
- package/phanpy/dist/index.html +73 -0
- package/phanpy/dist/logo-192.png +0 -0
- package/phanpy/dist/logo-512.png +0 -0
- package/phanpy/dist/logo-badge-72.png +0 -0
- package/phanpy/dist/logo-maskable-512.png +0 -0
- package/phanpy/dist/logo-monochrome-512.png +0 -0
- package/phanpy/dist/logo-monochrome-maskable-512.png +0 -0
- package/phanpy/dist/manifest.webmanifest +1 -0
- package/phanpy/dist/og-image-2.jpg +0 -0
- package/phanpy/dist/og-image.png +0 -0
- package/phanpy/dist/robots.txt +2 -0
- package/phanpy/dist/sw.js +25 -0
- package/phanpy/dist/version.json +1 -0
- package/run-agent.mjs +805 -0
- package/scripts/build-dist.mjs +37 -0
- package/scripts/verify-ui.mjs +60 -0
- package/ui/README.md +23 -0
- package/vendor/gate.cjs +132 -0
- package/vendor/idp-grant.cjs +394 -0
- package/vendor/jfetch.cjs +16 -0
- package/web/admin/admin.js +958 -0
- package/web/admin/bar.css +64 -0
- package/web/admin/bar.js +61 -0
- package/web/admin/client/client.js +66 -0
- package/web/admin/client/index.html +42 -0
- package/web/admin/index.html +493 -0
- package/web/admin/setup/index.html +172 -0
- package/web/admin/setup/setup.js +268 -0
- package/web/admin/window.css +55 -0
- package/web/admin/window.js +136 -0
- package/web/front/install.sh +69 -0
- package/web/front/new-account.html +424 -0
- package/web/front/run.html +138 -0
- 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
|
+
}
|