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/tagfeed.mjs
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
// tagfeed.mjs — topical firehose for a single-actor instance: polls public
|
|
2
|
+
// no-auth hashtag timelines on a configured instance and mirrors NEW notes
|
|
3
|
+
// into the statuses index as kind 'tag'. View cache only — tag content is
|
|
4
|
+
// NOT written to the pod; the pod holds followed/own content.
|
|
5
|
+
//
|
|
6
|
+
// Config in tagfeed.json: { instance, tags: [...], intervalMin }. Every note
|
|
7
|
+
// is still verified by dereference at its origin before it is mirrored.
|
|
8
|
+
|
|
9
|
+
import { isContentType, authorOf } from './intake.mjs';
|
|
10
|
+
|
|
11
|
+
const DEFAULTS = {
|
|
12
|
+
instance: 'https://mastodon.social',
|
|
13
|
+
tags: ['solidproject', 'linkeddata', 'rdf'],
|
|
14
|
+
intervalMin: 15,
|
|
15
|
+
};
|
|
16
|
+
const PER_TAG = 20; // statuses requested per tag per sweep
|
|
17
|
+
const MAX_NEW_PER_SWEEP = 12; // dereference budget per sweep — stay light
|
|
18
|
+
const MAX_TAG_ENTRIES = 200; // oldest tag entries pruned beyond this
|
|
19
|
+
// The instance here is a stranger's server we poll on our own schedule, and it
|
|
20
|
+
// has no other way to tell us to stop.
|
|
21
|
+
const BACKOFF_MIN_MS = 15 * 60_000;
|
|
22
|
+
const BACKOFF_MAX_MS = 6 * 60 * 60_000;
|
|
23
|
+
const instanceRefusal = (status) => (status ? `the instance answered ${status}` : 'the instance did not answer');
|
|
24
|
+
|
|
25
|
+
export class TagFeed {
|
|
26
|
+
constructor({ store, intake, log = console.log, fetcher = globalThis.fetch }) {
|
|
27
|
+
Object.assign(this, { store, intake, log, fetcher });
|
|
28
|
+
this.lastSweep = null;
|
|
29
|
+
this.lastAdded = 0;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
config() { return { ...DEFAULTS, ...this.store.read('tagfeed.json', {}) }; }
|
|
33
|
+
|
|
34
|
+
setConfig(patch) {
|
|
35
|
+
const clean = {};
|
|
36
|
+
if (patch.instance) clean.instance = String(patch.instance).replace(/\/+$/, '');
|
|
37
|
+
if (Array.isArray(patch.tags)) clean.tags = patch.tags.map(t => String(t).replace(/^#/, '').trim()).filter(Boolean);
|
|
38
|
+
if (patch.intervalMin) clean.intervalMin = Math.max(5, Number(patch.intervalMin) || DEFAULTS.intervalMin);
|
|
39
|
+
this.store.write('tagfeed.json', { ...this.config(), ...clean });
|
|
40
|
+
this.stop();
|
|
41
|
+
this.start();
|
|
42
|
+
return this.config();
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
start() {
|
|
46
|
+
this.stopped = false; // restartable, the same way Intake.start is
|
|
47
|
+
this.sweep().catch(e => this.log(`tagfeed: ${e.message}`));
|
|
48
|
+
// Jittered and self-scheduling: every agent polling the same instance on
|
|
49
|
+
// the same 15-minute boundary is a beat nobody asked for.
|
|
50
|
+
const tick = () => {
|
|
51
|
+
this.timer = setTimeout(() => {
|
|
52
|
+
this.sweep()
|
|
53
|
+
.catch(e => this.log(`tagfeed: ${e.message}`))
|
|
54
|
+
.finally(() => { if (!this.stopped) tick(); });
|
|
55
|
+
}, Math.round(this.config().intervalMin * 60_000 * (0.85 + Math.random() * 0.3)));
|
|
56
|
+
this.timer.unref?.();
|
|
57
|
+
};
|
|
58
|
+
tick();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// The flag is what makes this stick. Clearing the timer only cancels a sweep
|
|
62
|
+
// that has not started: one already in flight re-arms itself in `finally`,
|
|
63
|
+
// and `this.stopped` was read there but never written — so a stop landing
|
|
64
|
+
// mid-sweep leaked that chain for the life of the process. Intake has had the
|
|
65
|
+
// flag all along; this is the same shape.
|
|
66
|
+
stop() { this.stopped = true; clearTimeout(this.timer); }
|
|
67
|
+
|
|
68
|
+
// Jittered exponential, capped, and cleared by an instance that answers.
|
|
69
|
+
// The next sweep is skipped rather than the timer stretched, so the tag
|
|
70
|
+
// config keeps meaning what it says once the far end is well again.
|
|
71
|
+
_backOff(status, retryAfter) {
|
|
72
|
+
this.failures = (this.failures || 0) + 1;
|
|
73
|
+
const ladder = Math.min(BACKOFF_MIN_MS * 2 ** (this.failures - 1), BACKOFF_MAX_MS);
|
|
74
|
+
const wait = retryAfter || Math.round(ladder * (0.85 + Math.random() * 0.3));
|
|
75
|
+
this.quietUntil = Date.now() + wait;
|
|
76
|
+
this.log(`tagfeed: ${instanceRefusal(status)} — not asking again for ${Math.round(wait / 60_000)} min`);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async sweep() {
|
|
80
|
+
const { instance, tags } = this.config();
|
|
81
|
+
if (!tags.length) return;
|
|
82
|
+
if (this.quietUntil && Date.now() < this.quietUntil) {
|
|
83
|
+
this.log(`tagfeed: still backing off for ${Math.round((this.quietUntil - Date.now()) / 60_000)} min`);
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
this.lastSweep = new Date().toISOString();
|
|
87
|
+
const known = new Set(this.store.getStatuses().map(s => s.noteId));
|
|
88
|
+
let budget = MAX_NEW_PER_SWEEP;
|
|
89
|
+
let added = 0;
|
|
90
|
+
for (const tag of tags) {
|
|
91
|
+
let list;
|
|
92
|
+
try {
|
|
93
|
+
const { safeFetch, retryAfterMs } = await import('./safefetch.mjs');
|
|
94
|
+
const url = `${instance}/api/v1/timelines/tag/${encodeURIComponent(tag)}?limit=${PER_TAG}`;
|
|
95
|
+
const res = this.fetcher === globalThis.fetch
|
|
96
|
+
? await safeFetch(url, { headers: { accept: 'application/json' } })
|
|
97
|
+
: await this.fetcher(url, { headers: { accept: 'application/json' } });
|
|
98
|
+
if (res.status >= 400) {
|
|
99
|
+
// This is somebody else's server, polled on OUR schedule, and it had
|
|
100
|
+
// no way to ask us to stop: a 429 or a 503 logged a line and the next
|
|
101
|
+
// sweep arrived on the same cadence regardless. One refusal now ends
|
|
102
|
+
// the sweep — the remaining tags are on the same instance, so there
|
|
103
|
+
// is nothing to be gained by asking them too.
|
|
104
|
+
this._backOff(res.status, retryAfterMs(res));
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
list = await res.json();
|
|
108
|
+
} catch (e) {
|
|
109
|
+
// A network failure is the same signal, minus the courtesy.
|
|
110
|
+
this.log(`tagfeed #${tag}: ${e.message}`);
|
|
111
|
+
this._backOff(0, null);
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
this.failures = 0; // it answered; the ladder resets
|
|
115
|
+
for (const st of Array.isArray(list) ? list : []) {
|
|
116
|
+
const noteId = st?.uri;
|
|
117
|
+
if (!noteId || known.has(noteId) || this.store.isBlocked(noteId)) continue;
|
|
118
|
+
if (budget-- <= 0) break;
|
|
119
|
+
const note = await this.intake.fetchAP(noteId).catch(() => null);
|
|
120
|
+
if (!note || note.id !== noteId || !isContentType(note.type)) continue;
|
|
121
|
+
// The author is only known once the note is dereferenced, and this
|
|
122
|
+
// path never went through ingestNote, where that check lives. So a
|
|
123
|
+
// blocked account still reached the timeline by posting under a tag —
|
|
124
|
+
// the one route round a block the owner had explicitly set.
|
|
125
|
+
// The author is only known once the note is dereferenced, and this path
|
|
126
|
+
// never went through ingestNote, where that check lives. Two things
|
|
127
|
+
// followed: a blocked account reached the timeline by posting under a
|
|
128
|
+
// tag — the one route round a block the owner had explicitly set — and
|
|
129
|
+
// a note could name an author its own origin does not vouch for, which
|
|
130
|
+
// is the same forgery ingestNote refuses. No `delivered` fallback here:
|
|
131
|
+
// a tag-feed note has no envelope, so it names its author or it is not
|
|
132
|
+
// one we can attribute.
|
|
133
|
+
const author = authorOf(note);
|
|
134
|
+
if (!author || this.store.isBlocked(author)) continue;
|
|
135
|
+
if (!this.store.getActors()[author]) {
|
|
136
|
+
await this.intake.fetchAP(author).catch(() => {}); // warm name+avatar
|
|
137
|
+
}
|
|
138
|
+
const { attachmentsOf, sanitizeHtml } = await import('./wire.mjs');
|
|
139
|
+
const attachments = attachmentsOf(note);
|
|
140
|
+
this.store.addStatus({
|
|
141
|
+
noteId, actor: author, content: sanitizeHtml(note.content),
|
|
142
|
+
published: note.published, inReplyTo: note.inReplyTo, kind: 'tag', tag,
|
|
143
|
+
...(attachments.length ? { attachments } : {}),
|
|
144
|
+
});
|
|
145
|
+
known.add(noteId);
|
|
146
|
+
added++;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
const all = this.store.getStatuses();
|
|
150
|
+
const tagged = all.filter(s => s.kind === 'tag');
|
|
151
|
+
if (tagged.length > MAX_TAG_ENTRIES) {
|
|
152
|
+
const drop = new Set(tagged.slice(MAX_TAG_ENTRIES).map(s => s.noteId)); // arrival order: tail = oldest
|
|
153
|
+
this.store.write('statuses.json', all.filter(s => !drop.has(s.noteId)));
|
|
154
|
+
}
|
|
155
|
+
this.lastAdded = added;
|
|
156
|
+
if (added) this.log(`tagfeed: +${added} from #${tags.join(' #')}`);
|
|
157
|
+
}
|
|
158
|
+
}
|
package/lib/ua.mjs
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// One User-Agent for everything this agent sends. An operator reading an access
|
|
2
|
+
// log should be able to tell what we are and where to raise a complaint;
|
|
3
|
+
// solidcommunity.net's 2026-07-29 report had to infer us from client-credential
|
|
4
|
+
// names because every request identified itself only as "node".
|
|
5
|
+
//
|
|
6
|
+
// vendor/idp-grant.cjs builds the same string from package.json — it is
|
|
7
|
+
// CommonJS and cannot import this.
|
|
8
|
+
|
|
9
|
+
import fs from 'node:fs';
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
import { fileURLToPath } from 'node:url';
|
|
12
|
+
|
|
13
|
+
// Best-effort: a bundled deploy (the Netlify functions) rewrites this file's
|
|
14
|
+
// location and the relative path with it. A User-Agent must never be the
|
|
15
|
+
// reason anything fails to start.
|
|
16
|
+
let version = '0';
|
|
17
|
+
try {
|
|
18
|
+
({ version } = JSON.parse(fs.readFileSync(
|
|
19
|
+
path.join(path.dirname(fileURLToPath(import.meta.url)), '../package.json'), 'utf8')));
|
|
20
|
+
} catch {
|
|
21
|
+
try {
|
|
22
|
+
({ version } = JSON.parse(fs.readFileSync(
|
|
23
|
+
path.join(process.cwd(), 'package.json'), 'utf8')));
|
|
24
|
+
} catch { /* keep '0' */ }
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Renamed 2026-07-31, once solidcommunity.net was back up and both actors there
|
|
28
|
+
// were retired: the name in their logs no longer has to match the name in here.
|
|
29
|
+
export const USER_AGENT = `fedipod/${version} (+https://github.com/jeff-zucker/FediPod)`;
|
package/lib/update.mjs
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// update.mjs — is a newer FediPod published, and the update itself.
|
|
2
|
+
//
|
|
3
|
+
// "Latest" is the version field of package.json on the repo's main branch —
|
|
4
|
+
// the same file a clone reads to know what it is running. The check is one
|
|
5
|
+
// GET of that file; nothing else leaves the machine, and AP_UPDATE_CHECK=0
|
|
6
|
+
// turns it off. The update is what re-running the installer does: fast-forward
|
|
7
|
+
// the checkout, reinstall dependencies, restart the agents.
|
|
8
|
+
|
|
9
|
+
import fs from 'node:fs';
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
import { execFileSync, spawn } from 'node:child_process';
|
|
12
|
+
import { fileURLToPath } from 'node:url';
|
|
13
|
+
|
|
14
|
+
export const LATEST_URL = 'https://raw.githubusercontent.com/jeff-zucker/FediPod/main/package.json';
|
|
15
|
+
|
|
16
|
+
export const repoRoot = () => path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
17
|
+
|
|
18
|
+
export const localVersion = (root = repoRoot()) => {
|
|
19
|
+
try { return JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')).version || null; }
|
|
20
|
+
catch { return null; }
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
// a newer than b, for dotted numeric versions.
|
|
24
|
+
export const newerThan = (a, b) => {
|
|
25
|
+
const pa = String(a).split('.').map(Number);
|
|
26
|
+
const pb = String(b).split('.').map(Number);
|
|
27
|
+
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
|
|
28
|
+
const d = (pa[i] || 0) - (pb[i] || 0);
|
|
29
|
+
if (d) return d > 0;
|
|
30
|
+
}
|
|
31
|
+
return false;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export async function checkLatest({ current = localVersion(), fetchImpl = fetch } = {}) {
|
|
35
|
+
if (process.env.AP_UPDATE_CHECK === '0') return null;
|
|
36
|
+
try {
|
|
37
|
+
const res = await fetchImpl(LATEST_URL, { signal: AbortSignal.timeout(10_000) });
|
|
38
|
+
if (!res.ok) return null;
|
|
39
|
+
const latest = (await res.json()).version || null;
|
|
40
|
+
if (!latest || !current) return null;
|
|
41
|
+
return { current, latest, available: newerThan(latest, current), checkedAt: new Date().toISOString() };
|
|
42
|
+
} catch { return null; }
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Fast-forward only: a checkout with local changes is somebody's work, and an
|
|
46
|
+
// update must refuse rather than eat it.
|
|
47
|
+
export function runUpdate({ root = repoRoot(), log = () => {} } = {}) {
|
|
48
|
+
// The ordinary install comes from npm, where updating is npm's job and the
|
|
49
|
+
// files under us are not ours to move. A checkout is somebody working on it,
|
|
50
|
+
// and gets the fast-forward below.
|
|
51
|
+
if (!fs.existsSync(path.join(root, '.git'))) {
|
|
52
|
+
try {
|
|
53
|
+
log('updating from npm…');
|
|
54
|
+
execFileSync('npm', ['install', '-g', 'fedipod@latest'], { stdio: 'pipe' });
|
|
55
|
+
return { ok: true, note: 'updated from npm — restart the agent to serve it' };
|
|
56
|
+
} catch (e) {
|
|
57
|
+
return { ok: false,
|
|
58
|
+
note: `npm install -g fedipod@latest failed: ${String(e.stderr || e.message).slice(0, 300).trim()}` };
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
try {
|
|
62
|
+
execFileSync('git', ['-C', root, 'pull', '--ff-only'], { stdio: 'pipe' });
|
|
63
|
+
} catch (e) {
|
|
64
|
+
return { ok: false, note: `git pull refused: ${String(e.stderr || e.message).slice(0, 300).trim()}` };
|
|
65
|
+
}
|
|
66
|
+
try {
|
|
67
|
+
execFileSync('npm', ['install', '--omit=dev', '--no-audit', '--no-fund'], { cwd: root, stdio: 'pipe' });
|
|
68
|
+
} catch (e) {
|
|
69
|
+
return { ok: false, note: `npm install failed: ${String(e.stderr || e.message).slice(0, 300).trim()}` };
|
|
70
|
+
}
|
|
71
|
+
const version = localVersion(root);
|
|
72
|
+
log(`updated the checkout to ${version}`);
|
|
73
|
+
return { ok: true, note: `updated to ${version}`, version };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// New code runs only after a restart. Where systemd units exist they are all
|
|
77
|
+
// restarted by name; otherwise the caller re-execs its own process.
|
|
78
|
+
export function restartAgents({ log = () => {} } = {}) {
|
|
79
|
+
try {
|
|
80
|
+
const list = execFileSync('systemctl',
|
|
81
|
+
['--user', 'list-units', '--plain', '--no-legend', 'fedipod-*'], { stdio: 'pipe' })
|
|
82
|
+
.toString().trim();
|
|
83
|
+
if (list) {
|
|
84
|
+
const child = spawn('systemctl', ['--user', 'restart', 'fedipod-*'], { detached: true, stdio: 'ignore' });
|
|
85
|
+
child.unref();
|
|
86
|
+
log('restarting the fedipod units');
|
|
87
|
+
return 'units';
|
|
88
|
+
}
|
|
89
|
+
} catch { /* no systemd here */ }
|
|
90
|
+
return 'self';
|
|
91
|
+
}
|
package/lib/webpush.mjs
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
// webpush.mjs — Mastodon-style Web Push from the agent itself. The VAPID
|
|
2
|
+
// keypair is minted once into state; each client login keeps one subscription;
|
|
3
|
+
// payloads go straight from this process to the browser's push service, so a
|
|
4
|
+
// closed client still hears about mentions. No relay, no third party account,
|
|
5
|
+
// and nothing here touches the pod.
|
|
6
|
+
import crypto from 'node:crypto';
|
|
7
|
+
import webpush from 'web-push';
|
|
8
|
+
|
|
9
|
+
export class Push {
|
|
10
|
+
constructor({ store, subject, log = () => {} }) {
|
|
11
|
+
this.store = store;
|
|
12
|
+
this.subject = subject; // () => the actor URL, VAPID's contact
|
|
13
|
+
this.log = log;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
state() { return this.store.read('webpush.json', { subs: {} }); }
|
|
17
|
+
save(s) { this.store.write('webpush.json', s); }
|
|
18
|
+
|
|
19
|
+
vapid() {
|
|
20
|
+
const st = this.state();
|
|
21
|
+
if (!st.vapid) {
|
|
22
|
+
st.vapid = webpush.generateVAPIDKeys();
|
|
23
|
+
this.save(st);
|
|
24
|
+
this.log('webpush: VAPID keypair minted');
|
|
25
|
+
}
|
|
26
|
+
return st.vapid;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
publicKey() { return this.vapid().publicKey; }
|
|
30
|
+
|
|
31
|
+
// One subscription per client login, keyed by a hash of its bearer token —
|
|
32
|
+
// the token itself is never written down twice.
|
|
33
|
+
keyOf(token) { return crypto.createHash('sha256').update(String(token)).digest('hex').slice(0, 16); }
|
|
34
|
+
|
|
35
|
+
get(token) { return this.state().subs[this.keyOf(token)] || null; }
|
|
36
|
+
|
|
37
|
+
set(token, { endpoint, keys, alerts }) {
|
|
38
|
+
if (!/^https:\/\//.test(String(endpoint || ''))) return null;
|
|
39
|
+
if (!keys?.p256dh || !keys?.auth) return null;
|
|
40
|
+
const st = this.state();
|
|
41
|
+
st.subs[this.keyOf(token)] = { endpoint, keys: { p256dh: keys.p256dh, auth: keys.auth }, alerts: alerts || {} };
|
|
42
|
+
this.save(st);
|
|
43
|
+
return st.subs[this.keyOf(token)];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
setAlerts(token, alerts) {
|
|
47
|
+
const st = this.state();
|
|
48
|
+
const sub = st.subs[this.keyOf(token)];
|
|
49
|
+
if (!sub) return null;
|
|
50
|
+
sub.alerts = { ...sub.alerts, ...(alerts || {}) };
|
|
51
|
+
this.save(st);
|
|
52
|
+
return sub;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
drop(token) {
|
|
56
|
+
const st = this.state();
|
|
57
|
+
delete st.subs[this.keyOf(token)];
|
|
58
|
+
this.save(st);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
json(token, sub) {
|
|
62
|
+
return {
|
|
63
|
+
id: this.keyOf(token), endpoint: sub.endpoint,
|
|
64
|
+
alerts: sub.alerts || {}, policy: 'all',
|
|
65
|
+
server_key: this.publicKey(),
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Push one notification to every subscription whose alerts allow the type.
|
|
70
|
+
// A push service answering 404/410 means the browser dropped the
|
|
71
|
+
// subscription — it is removed rather than retried forever.
|
|
72
|
+
async notify(n, payload) {
|
|
73
|
+
const st = this.state();
|
|
74
|
+
const entries = Object.entries(st.subs);
|
|
75
|
+
if (!entries.length) return;
|
|
76
|
+
const { publicKey, privateKey } = this.vapid();
|
|
77
|
+
const details = { subject: this.subject(), publicKey, privateKey };
|
|
78
|
+
let dropped = false;
|
|
79
|
+
for (const [key, sub] of entries) {
|
|
80
|
+
if (sub.alerts && sub.alerts[n.type] === false) continue;
|
|
81
|
+
try {
|
|
82
|
+
await webpush.sendNotification(
|
|
83
|
+
{ endpoint: sub.endpoint, keys: sub.keys },
|
|
84
|
+
JSON.stringify(payload),
|
|
85
|
+
{ vapidDetails: details, contentEncoding: 'aes128gcm', TTL: 3600 },
|
|
86
|
+
);
|
|
87
|
+
} catch (e) {
|
|
88
|
+
if (e?.statusCode === 404 || e?.statusCode === 410) {
|
|
89
|
+
delete st.subs[key];
|
|
90
|
+
dropped = true;
|
|
91
|
+
} else {
|
|
92
|
+
this.log(`webpush: ${e?.statusCode || ''} ${e?.message || e}`);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
if (dropped) this.save(st);
|
|
97
|
+
}
|
|
98
|
+
}
|