feedkeeper 0.0.0-stage → 0.23.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 +22 -2
- package/package.json +57 -4
- package/server/dist/api/archive.js +19 -0
- package/server/dist/api/auth.js +162 -0
- package/server/dist/api/feeds.js +207 -0
- package/server/dist/api/filters.js +32 -0
- package/server/dist/api/folders.js +53 -0
- package/server/dist/api/index.js +37 -0
- package/server/dist/api/items.js +129 -0
- package/server/dist/api/oauth.js +14 -0
- package/server/dist/api/onboarding.js +30 -0
- package/server/dist/api/push.js +51 -0
- package/server/dist/api/system.js +42 -0
- package/server/dist/api/tokens.js +28 -0
- package/server/dist/api/v1/index.js +178 -0
- package/server/dist/api/v1/resources.js +653 -0
- package/server/dist/api/v1/serialize.js +48 -0
- package/server/dist/app.js +100 -0
- package/server/dist/auth/avatar.js +13 -0
- package/server/dist/auth/capabilities.js +53 -0
- package/server/dist/auth/middleware.js +77 -0
- package/server/dist/auth/pairing.js +36 -0
- package/server/dist/auth/password.js +9 -0
- package/server/dist/auth/plan.js +58 -0
- package/server/dist/auth/security.js +117 -0
- package/server/dist/auth/securityRoutes.js +249 -0
- package/server/dist/auth/tokens.js +64 -0
- package/server/dist/auth/users.js +54 -0
- package/server/dist/backup-cli.js +101 -0
- package/server/dist/cli.js +135 -0
- package/server/dist/clientAddress.js +26 -0
- package/server/dist/config.js +51 -0
- package/server/dist/db/index.js +40 -0
- package/server/dist/db/migrations/0001_init.sql +70 -0
- package/server/dist/db/migrations/0002_feed_health.sql +5 -0
- package/server/dist/db/migrations/0003_retention.sql +13 -0
- package/server/dist/db/migrations/0004_bookmarks_and_filters.sql +20 -0
- package/server/dist/db/migrations/0005_folders.sql +15 -0
- package/server/dist/db/migrations/0006_feed_position.sql +5 -0
- package/server/dist/db/migrations/0007_item_image_url.sql +2 -0
- package/server/dist/db/migrations/0008_item_content_html.sql +2 -0
- package/server/dist/db/migrations/0009_item_full_content_html.sql +1 -0
- package/server/dist/db/migrations/0010_token_scope.sql +3 -0
- package/server/dist/db/migrations/0011_user_avatars.sql +8 -0
- package/server/dist/db/migrations/0012_full_text_settings.sql +5 -0
- package/server/dist/db/migrations/0013_feed_icons.sql +4 -0
- package/server/dist/db/migrations/0014_bookmark_archive.sql +16 -0
- package/server/dist/db/migrations/0015_push.sql +13 -0
- package/server/dist/db/migrations/0016_feed_badge.sql +2 -0
- package/server/dist/db/migrations/0017_devices.sql +13 -0
- package/server/dist/db/migrations/0018_change_log.sql +166 -0
- package/server/dist/db/migrations/0019_native_extensions.sql +111 -0
- package/server/dist/db/migrations/0020_fts.sql +32 -0
- package/server/dist/db/migrations/0021_note_revisions.sql +33 -0
- package/server/dist/db/migrations/0022_high_resolution_icons.sql +2 -0
- package/server/dist/db/migrations/0023_server_editions.sql +81 -0
- package/server/dist/db/migrations/0024_inbox.sql +5 -0
- package/server/dist/db/migrations/0025_edition_overview.sql +2 -0
- package/server/dist/db/migrations/0026_article_content.sql +43 -0
- package/server/dist/db/migrations/0027_oauth.sql +38 -0
- package/server/dist/db/migrations/0028_user_intro.sql +2 -0
- package/server/dist/db/migrations/0029_account_security.sql +35 -0
- package/server/dist/db/migrations/0030_feed_poll_jobs.sql +15 -0
- package/server/dist/db/migrations/0031_push_outbox.sql +19 -0
- package/server/dist/feedkeeper.js +38 -0
- package/server/dist/feeds/archive.js +334 -0
- package/server/dist/feeds/articleContent.js +26 -0
- package/server/dist/feeds/cleanup.js +179 -0
- package/server/dist/feeds/discovery.js +153 -0
- package/server/dist/feeds/extractor.js +208 -0
- package/server/dist/feeds/feedIcon.js +171 -0
- package/server/dist/feeds/fetcher.js +217 -0
- package/server/dist/feeds/fullText.js +91 -0
- package/server/dist/feeds/fullTextQueue.js +159 -0
- package/server/dist/feeds/icon.js +97 -0
- package/server/dist/feeds/imageCache.js +266 -0
- package/server/dist/feeds/imageCacheSettings.js +19 -0
- package/server/dist/feeds/imageTypes.js +16 -0
- package/server/dist/feeds/inbox.js +128 -0
- package/server/dist/feeds/opml.js +190 -0
- package/server/dist/feeds/pollQueue.js +301 -0
- package/server/dist/feeds/pollSettings.js +23 -0
- package/server/dist/feeds/poller.js +222 -0
- package/server/dist/feeds/repository.js +674 -0
- package/server/dist/feeds/requestGate.js +99 -0
- package/server/dist/feeds/service.js +162 -0
- package/server/dist/feeds/ssrfGuard.js +98 -0
- package/server/dist/feeds/text.js +35 -0
- package/server/dist/index.js +15 -0
- package/server/dist/mcp/digest.js +73 -0
- package/server/dist/mcp/http.js +46 -0
- package/server/dist/mcp/items.js +73 -0
- package/server/dist/mcp/promptsAndResources.js +43 -0
- package/server/dist/mcp/server.js +547 -0
- package/server/dist/native/editionSelection.js +127 -0
- package/server/dist/native/editionText.js +15 -0
- package/server/dist/native/editions.js +218 -0
- package/server/dist/native/preferences.js +66 -0
- package/server/dist/native/requests.js +35 -0
- package/server/dist/oauth/constants.js +6 -0
- package/server/dist/oauth/host.js +8 -0
- package/server/dist/oauth/pages.js +148 -0
- package/server/dist/oauth/routes.js +361 -0
- package/server/dist/oauth/store.js +104 -0
- package/server/dist/push.js +147 -0
- package/server/dist/pushOutbox.js +164 -0
- package/server/dist/security-recover-cli.js +29 -0
- package/server/dist/server.js +108 -0
- package/server/dist/setup-cli.js +97 -0
- package/server/dist/sync/changeLog.js +170 -0
- package/server/dist/sync/mutations.js +144 -0
- package/server/dist/trustProxy.js +14 -0
- package/server/dist/version.js +6 -0
- package/server/package.json +6 -0
- package/web/dist/app-icon-180.png +0 -0
- package/web/dist/app-icon-192.png +0 -0
- package/web/dist/app-icon-512.png +0 -0
- package/web/dist/assets/index-B4e2oqbo.css +2 -0
- package/web/dist/assets/index-xx6TyOwB.js +32 -0
- package/web/dist/assets/inter-latin-400-normal-C38fXH4l.woff2 +0 -0
- package/web/dist/assets/inter-latin-400-normal-CyCys3Eg.woff +0 -0
- package/web/dist/assets/inter-latin-500-normal-BL9OpVg8.woff +0 -0
- package/web/dist/assets/inter-latin-500-normal-Cerq10X2.woff2 +0 -0
- package/web/dist/assets/inter-latin-600-normal-CiBQ2DWP.woff +0 -0
- package/web/dist/assets/inter-latin-600-normal-LgqL8muc.woff2 +0 -0
- package/web/dist/assets/manrope-latin-600-normal-4f0koTD-.woff2 +0 -0
- package/web/dist/assets/manrope-latin-600-normal-BqgrALkZ.woff +0 -0
- package/web/dist/assets/manrope-latin-700-normal-BZp_XxE4.woff2 +0 -0
- package/web/dist/assets/manrope-latin-700-normal-DGRFkw-m.woff +0 -0
- package/web/dist/favicon-32.png +0 -0
- package/web/dist/index.html +40 -0
- package/web/dist/logo.svg +50 -0
- package/web/dist/manifest.webmanifest +64 -0
- package/web/dist/robots.txt +3 -0
- package/web/dist/sw.js +198 -0
- package/web/dist/theme-init.js +10 -0
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import { AsyncResource } from "node:async_hooks";
|
|
2
|
+
import { db } from "./db/index.js";
|
|
3
|
+
import { hasUserCapability } from "./auth/capabilities.js";
|
|
4
|
+
import { newItemsPushPayload, sendPushTarget } from "./push.js";
|
|
5
|
+
import { parseRetryAfter } from "./feeds/fetcher.js";
|
|
6
|
+
// Initialized during startup, so background work never retains an HTTP capability cache.
|
|
7
|
+
const context = new AsyncResource("FeedKeeperPushOutbox");
|
|
8
|
+
/** Call inside the article/job-completion transaction. No network or recipient-sized array. */
|
|
9
|
+
export function enqueueNewItemNotifications(feedId, newItems) {
|
|
10
|
+
if (!newItems)
|
|
11
|
+
return;
|
|
12
|
+
const ceiling = db.prepare("SELECT MAX(id) id FROM subscriptions WHERE feed_id = ? AND notify = 1").get(feedId).id;
|
|
13
|
+
if (ceiling === null)
|
|
14
|
+
return;
|
|
15
|
+
const maxItem = db.prepare("SELECT MAX(id) id FROM items WHERE feed_id = ?").get(feedId).id;
|
|
16
|
+
db.prepare(`INSERT INTO push_outbox(feed_id,max_item_id,item_count,subscription_ceiling,available_at,created_at)
|
|
17
|
+
VALUES (?,?,?,?,?,?)`).run(feedId, maxItem, Math.min(newItems, 10), ceiling, Date.now(), Date.now());
|
|
18
|
+
}
|
|
19
|
+
/** Exactly one worker per process. SQLite holds fanout and progress, not suspended tasks.
|
|
20
|
+
* A delivery accepted immediately before a crash can repeat (at-least-once); stable notification
|
|
21
|
+
* tags replace it in the browser. Unstarted recipients survive shutdown and feed deadlines.
|
|
22
|
+
*/
|
|
23
|
+
export class PushOutbox {
|
|
24
|
+
stopped = false;
|
|
25
|
+
scheduled = false;
|
|
26
|
+
active;
|
|
27
|
+
timer;
|
|
28
|
+
counters = { delivered: 0, errors: 0, retries: 0, dropped: 0 };
|
|
29
|
+
start() {
|
|
30
|
+
if (this.stopped || this.timer)
|
|
31
|
+
return;
|
|
32
|
+
context.runInAsyncScope(() => {
|
|
33
|
+
this.timer = setInterval(() => this.kick(), 1000);
|
|
34
|
+
this.timer.unref();
|
|
35
|
+
});
|
|
36
|
+
this.kick();
|
|
37
|
+
}
|
|
38
|
+
kick() {
|
|
39
|
+
if (this.stopped || this.scheduled || this.active)
|
|
40
|
+
return;
|
|
41
|
+
this.scheduled = true;
|
|
42
|
+
context.runInAsyncScope(() => setImmediate(() => {
|
|
43
|
+
this.scheduled = false;
|
|
44
|
+
if (this.stopped)
|
|
45
|
+
return;
|
|
46
|
+
this.active = this.step().catch(() => {
|
|
47
|
+
// A failed progress write must not immediately repeat a delivery forever.
|
|
48
|
+
this.stopped = true;
|
|
49
|
+
if (this.timer)
|
|
50
|
+
clearInterval(this.timer);
|
|
51
|
+
console.error(JSON.stringify({ event: "push_outbox_error", code: "storage_or_eligibility_failure" }));
|
|
52
|
+
}).finally(() => { this.active = undefined; });
|
|
53
|
+
void this.active.then(() => {
|
|
54
|
+
if (!this.stopped && this.nextEvent())
|
|
55
|
+
this.kick();
|
|
56
|
+
}).catch(() => {
|
|
57
|
+
this.stopped = true;
|
|
58
|
+
if (this.timer)
|
|
59
|
+
clearInterval(this.timer);
|
|
60
|
+
console.error(JSON.stringify({ event: "push_outbox_error", code: "queue_storage_failure" }));
|
|
61
|
+
});
|
|
62
|
+
}));
|
|
63
|
+
}
|
|
64
|
+
advanceSubscription(event, subscriptionId) {
|
|
65
|
+
db.prepare(`UPDATE push_outbox SET subscription_cursor = ?, current_subscription = 0,
|
|
66
|
+
device_cursor = 0, device_ceiling = 0, target_device = 0, attempts = 0, available_at = ? WHERE id = ?`)
|
|
67
|
+
.run(subscriptionId, Date.now(), event.id);
|
|
68
|
+
}
|
|
69
|
+
nextEvent() {
|
|
70
|
+
return db.prepare(`SELECT p.* FROM push_outbox p WHERE available_at <= ?
|
|
71
|
+
AND NOT EXISTS (SELECT 1 FROM push_outbox earlier WHERE earlier.feed_id = p.feed_id AND earlier.id < p.id)
|
|
72
|
+
ORDER BY available_at, id LIMIT 1`).get(Date.now());
|
|
73
|
+
}
|
|
74
|
+
async step() {
|
|
75
|
+
const event = this.nextEvent();
|
|
76
|
+
if (!event)
|
|
77
|
+
return;
|
|
78
|
+
const subscription = db.prepare(`
|
|
79
|
+
SELECT id,user_id,NULLIF(TRIM(label),'') label FROM subscriptions
|
|
80
|
+
WHERE feed_id = ? AND id > ? AND id <= ? AND notify = 1 ORDER BY id LIMIT 1`)
|
|
81
|
+
.get(event.feed_id, event.subscription_cursor, event.subscription_ceiling);
|
|
82
|
+
if (!subscription) {
|
|
83
|
+
db.prepare("DELETE FROM push_outbox WHERE id = ?").run(event.id);
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
// Revocation/unsubscribe/opt-out while waiting always takes effect before another delivery.
|
|
87
|
+
if (!hasUserCapability(subscription.user_id, "sync")) {
|
|
88
|
+
this.advanceSubscription(event, subscription.id);
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
if (subscription.id !== event.current_subscription) {
|
|
92
|
+
event.device_cursor = 0;
|
|
93
|
+
event.attempts = 0;
|
|
94
|
+
event.device_ceiling = db.prepare("SELECT COALESCE(MAX(id),0) id FROM push_devices WHERE user_id = ?").get(subscription.user_id).id;
|
|
95
|
+
db.prepare(`UPDATE push_outbox SET current_subscription = ?, device_cursor = 0, device_ceiling = ?, target_device = 0, attempts = 0 WHERE id = ?`)
|
|
96
|
+
.run(subscription.id, event.device_ceiling, event.id);
|
|
97
|
+
}
|
|
98
|
+
const device = db.prepare(`
|
|
99
|
+
SELECT id,endpoint,p256dh,auth FROM push_devices WHERE user_id = ? AND id > ? AND id <= ? ORDER BY id LIMIT 1`)
|
|
100
|
+
.get(subscription.user_id, event.device_cursor, event.device_ceiling);
|
|
101
|
+
if (!device) {
|
|
102
|
+
this.advanceSubscription(event, subscription.id);
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
// A removed/reassigned retry target must not consume the next device's retry budget.
|
|
106
|
+
if (device.id !== event.target_device) {
|
|
107
|
+
event.attempts = 0;
|
|
108
|
+
db.prepare("UPDATE push_outbox SET target_device = ?, attempts = 0 WHERE id = ?").run(device.id, event.id);
|
|
109
|
+
}
|
|
110
|
+
const feed = db.prepare("SELECT id,title,url FROM feeds WHERE id = ?").get(event.feed_id);
|
|
111
|
+
if (!feed)
|
|
112
|
+
return; // Feed deletion cascades the event.
|
|
113
|
+
const latest = db.prepare(`
|
|
114
|
+
SELECT id,title,content_snippet FROM items WHERE feed_id = ? AND id <= ? ORDER BY id DESC LIMIT ?`)
|
|
115
|
+
.all(event.feed_id, event.max_item_id, event.item_count);
|
|
116
|
+
const payload = newItemsPushPayload(feed, subscription.user_id, subscription.label, latest);
|
|
117
|
+
if (!payload) {
|
|
118
|
+
this.advanceSubscription(event, subscription.id);
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
try {
|
|
122
|
+
await sendPushTarget({ endpoint: device.endpoint, keys: { p256dh: device.p256dh, auth: device.auth } }, payload);
|
|
123
|
+
this.counters.delivered++;
|
|
124
|
+
}
|
|
125
|
+
catch (error) {
|
|
126
|
+
this.counters.errors++;
|
|
127
|
+
const status = error.statusCode;
|
|
128
|
+
if (status === 404 || status === 410) {
|
|
129
|
+
// Do not remove a device if its ownership changed during I/O.
|
|
130
|
+
db.prepare("DELETE FROM push_devices WHERE id = ? AND user_id = ?").run(device.id, subscription.user_id);
|
|
131
|
+
}
|
|
132
|
+
else if ((status === undefined || [408, 425, 429, 500, 502, 503, 504].includes(status)) && event.attempts < 2) {
|
|
133
|
+
const headers = error.headers;
|
|
134
|
+
const retryAt = [429, 503].includes(status ?? 0) ? parseRetryAfter(headers?.["retry-after"] ?? null) ?? 0 : 0;
|
|
135
|
+
const delay = Math.ceil(1000 * 2 ** event.attempts * (0.5 + Math.random() * 0.5));
|
|
136
|
+
db.prepare("UPDATE push_outbox SET attempts = attempts + 1, available_at = ? WHERE id = ?")
|
|
137
|
+
.run(Math.max(Date.now() + delay, retryAt), event.id);
|
|
138
|
+
this.counters.retries++;
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
else
|
|
142
|
+
this.counters.dropped++;
|
|
143
|
+
}
|
|
144
|
+
// Advance only after an attempted delivery. Round-robin events between device attempts.
|
|
145
|
+
db.prepare("UPDATE push_outbox SET device_cursor = ?, target_device = 0, attempts = 0, available_at = ? WHERE id = ?")
|
|
146
|
+
.run(device.id, Date.now(), event.id);
|
|
147
|
+
}
|
|
148
|
+
metrics() {
|
|
149
|
+
const row = db.prepare("SELECT COUNT(*) pending, COALESCE(MAX(? - created_at),0) oldestMs FROM push_outbox").get(Date.now());
|
|
150
|
+
return { ...row, running: this.active ? 1 : 0, ...this.counters };
|
|
151
|
+
}
|
|
152
|
+
async stop() {
|
|
153
|
+
this.stopped = true;
|
|
154
|
+
if (this.timer)
|
|
155
|
+
clearInterval(this.timer);
|
|
156
|
+
// Finish at most the one device already in flight (absolute network deadline: 10 seconds).
|
|
157
|
+
await this.active;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
let outbox;
|
|
161
|
+
export function startPushOutbox() { outbox ??= new PushOutbox(); outbox.start(); outbox.kick(); }
|
|
162
|
+
export function pushOutboxMetrics() { return outbox?.metrics() ?? { pending: 0, running: 0 }; }
|
|
163
|
+
export async function stopPushOutbox() { await outbox?.stop(); }
|
|
164
|
+
//# sourceMappingURL=pushOutbox.js.map
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { runMigrations, db } from "./db/index.js";
|
|
2
|
+
import { findUserByEmail } from "./auth/users.js";
|
|
3
|
+
import { revokeSecuritySessions, securityAccount } from "./auth/security.js";
|
|
4
|
+
const args = process.argv.slice(2);
|
|
5
|
+
const emailIndex = args.indexOf("--email");
|
|
6
|
+
const email = emailIndex >= 0 ? args[emailIndex + 1] : undefined;
|
|
7
|
+
if (!email || !args.includes("--confirm")) {
|
|
8
|
+
console.error("Usage: npm run security:recover -- --email reader@example.com --confirm\nDisables the authenticator and recovery codes for this account and revokes all sessions, tokens and pending sign-ins. Password and passkeys stay unchanged. Requires local database access.");
|
|
9
|
+
process.exitCode = 1;
|
|
10
|
+
}
|
|
11
|
+
else {
|
|
12
|
+
runMigrations();
|
|
13
|
+
const user = findUserByEmail(email);
|
|
14
|
+
if (!user) {
|
|
15
|
+
console.error("Account not found.");
|
|
16
|
+
process.exitCode = 1;
|
|
17
|
+
}
|
|
18
|
+
else {
|
|
19
|
+
db.transaction(() => {
|
|
20
|
+
securityAccount(user.id);
|
|
21
|
+
db.prepare("UPDATE account_security SET totp_secret = NULL, totp_last_step = -1 WHERE user_id = ?").run(user.id);
|
|
22
|
+
db.prepare("DELETE FROM recovery_codes WHERE user_id = ?").run(user.id);
|
|
23
|
+
revokeSecuritySessions(user.id);
|
|
24
|
+
})();
|
|
25
|
+
console.log("Authenticator removed. The account must sign in again. Set up a new authenticator and save new recovery codes.");
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
db.close();
|
|
29
|
+
//# sourceMappingURL=security-recover-cli.js.map
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { dirname, join, sep } from "node:path";
|
|
2
|
+
import { fileURLToPath } from "node:url";
|
|
3
|
+
import { existsSync } from "node:fs";
|
|
4
|
+
import express from "express";
|
|
5
|
+
import helmet from "helmet";
|
|
6
|
+
import cors from "cors";
|
|
7
|
+
import cookieSession from "cookie-session";
|
|
8
|
+
import rateLimit from "express-rate-limit";
|
|
9
|
+
import { config } from "./config.js";
|
|
10
|
+
import { clientAddressFromHeader } from "./clientAddress.js";
|
|
11
|
+
import { runMigrations } from "./db/index.js";
|
|
12
|
+
import { apiRouter } from "./api/index.js";
|
|
13
|
+
import { mcpRouter } from "./mcp/http.js";
|
|
14
|
+
import { oauthAuthorizeRouter, oauthPublicRouter } from "./oauth/routes.js";
|
|
15
|
+
import { startFullTextScheduler } from "./feeds/fullTextQueue.js";
|
|
16
|
+
import { installPollingShutdown, startPollingScheduler } from "./feeds/poller.js";
|
|
17
|
+
import { startCleanupScheduler } from "./feeds/cleanup.js";
|
|
18
|
+
import { repairEncodedText } from "./feeds/repository.js";
|
|
19
|
+
import { pruneArchive, scheduleMissingArchives } from "./feeds/archive.js";
|
|
20
|
+
import { startEditionScheduler } from "./native/editions.js";
|
|
21
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
22
|
+
const WEB_DIST = join(__dirname, "../../web/dist");
|
|
23
|
+
runMigrations();
|
|
24
|
+
const repairedItems = repairEncodedText();
|
|
25
|
+
if (repairedItems > 0)
|
|
26
|
+
console.log(`[db] decoded HTML entities in ${repairedItems} stored items`);
|
|
27
|
+
pruneArchive();
|
|
28
|
+
const pendingArchives = scheduleMissingArchives();
|
|
29
|
+
if (pendingArchives > 0)
|
|
30
|
+
console.log(`[archive] archiving ${pendingArchives} saved articles in the background`);
|
|
31
|
+
startPollingScheduler();
|
|
32
|
+
startFullTextScheduler();
|
|
33
|
+
startCleanupScheduler();
|
|
34
|
+
startEditionScheduler();
|
|
35
|
+
const app = express();
|
|
36
|
+
// Express validates the addresses here, so a mistyped TRUST_PROXY list stops the server at startup.
|
|
37
|
+
if (config.trustProxy !== false) {
|
|
38
|
+
app.set("trust proxy", config.trustProxy);
|
|
39
|
+
}
|
|
40
|
+
if (config.clientIpHeader && !Array.isArray(config.trustProxy)) {
|
|
41
|
+
console.warn("[config] CLIENT_IP_HEADER is ignored: it needs TRUST_PROXY to list the trusted proxies");
|
|
42
|
+
}
|
|
43
|
+
// Before every rate limiter, so limits count the CDN's client rather than its edge.
|
|
44
|
+
app.use(clientAddressFromHeader(config.clientIpHeader, config.trustProxy));
|
|
45
|
+
app.use(helmet({
|
|
46
|
+
contentSecurityPolicy: {
|
|
47
|
+
directives: {
|
|
48
|
+
...helmet.contentSecurityPolicy.getDefaultDirectives(),
|
|
49
|
+
"script-src": ["'self'"],
|
|
50
|
+
"img-src": ["'self'", "data:", "https:", "http:"],
|
|
51
|
+
},
|
|
52
|
+
},
|
|
53
|
+
}));
|
|
54
|
+
// Send X-Robots-Tag to ensure all HTTP endpoints disallow indexing even outside HTML
|
|
55
|
+
app.use((_req, res, next) => {
|
|
56
|
+
res.setHeader("X-Robots-Tag", "noindex, nofollow");
|
|
57
|
+
next();
|
|
58
|
+
});
|
|
59
|
+
// Connector backends and browser tools call these without our cookies, so they sit before the same-origin CORS rule.
|
|
60
|
+
app.use(oauthPublicRouter);
|
|
61
|
+
app.use(cors({
|
|
62
|
+
origin: config.publicUrl,
|
|
63
|
+
credentials: true,
|
|
64
|
+
}));
|
|
65
|
+
app.use(express.json({ limit: "1mb" }));
|
|
66
|
+
app.use(express.text({ type: ["application/xml", "text/xml", "text/plain"], limit: "5mb" }));
|
|
67
|
+
app.use(cookieSession({
|
|
68
|
+
name: "feedkeeper.sid",
|
|
69
|
+
secret: config.sessionSecret,
|
|
70
|
+
maxAge: 30 * 24 * 60 * 60 * 1000,
|
|
71
|
+
httpOnly: true,
|
|
72
|
+
sameSite: "lax",
|
|
73
|
+
secure: config.publicUrl.startsWith("https://"),
|
|
74
|
+
}));
|
|
75
|
+
// Generic API rate limit as defense in depth on top of the stricter
|
|
76
|
+
// per-route limiter on /api/auth/login.
|
|
77
|
+
app.use("/api", rateLimit({
|
|
78
|
+
windowMs: 60 * 1000,
|
|
79
|
+
limit: 120,
|
|
80
|
+
standardHeaders: true,
|
|
81
|
+
legacyHeaders: false,
|
|
82
|
+
// Lists load many article images and feed icons at once; those routes have their own, higher limit.
|
|
83
|
+
skip: (req) => /^\/(v1\/)?(items\/\d+\/image|feeds\/\d+\/icon|subscriptions\/\d+\/icon|archive\/images\/\d+)$/.test(req.path),
|
|
84
|
+
}));
|
|
85
|
+
app.use("/api", apiRouter);
|
|
86
|
+
app.use(oauthAuthorizeRouter);
|
|
87
|
+
app.use("/mcp", mcpRouter);
|
|
88
|
+
if (existsSync(WEB_DIST)) {
|
|
89
|
+
app.use(express.static(WEB_DIST, {
|
|
90
|
+
setHeaders(res, filePath) {
|
|
91
|
+
// Hashed build files never change; the service worker must always be re-checked.
|
|
92
|
+
if (filePath.includes(`${sep}assets${sep}`))
|
|
93
|
+
res.setHeader("Cache-Control", "public, max-age=31536000, immutable");
|
|
94
|
+
if (filePath.endsWith(`${sep}sw.js`))
|
|
95
|
+
res.setHeader("Cache-Control", "no-cache");
|
|
96
|
+
},
|
|
97
|
+
}));
|
|
98
|
+
app.get(/(.*)/, (req, res, next) => {
|
|
99
|
+
if (req.path.startsWith("/api") || req.path.startsWith("/mcp") || req.path.startsWith("/oauth") || req.path.startsWith("/.well-known"))
|
|
100
|
+
return next();
|
|
101
|
+
res.sendFile(join(WEB_DIST, "index.html"));
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
const server = app.listen(config.port, () => {
|
|
105
|
+
console.log(`[feedkeeper] listening on port ${config.port}`);
|
|
106
|
+
});
|
|
107
|
+
installPollingShutdown(server);
|
|
108
|
+
//# sourceMappingURL=server.js.map
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { stdin, stdout } from "node:process";
|
|
2
|
+
import { runMigrations, hasAnyUser } from "./db/index.js";
|
|
3
|
+
import { createUser } from "./auth/users.js";
|
|
4
|
+
// Alternative to the in-browser onboarding screen: creates the first admin
|
|
5
|
+
// user from the terminal, e.g. right after deploying on a fresh server.
|
|
6
|
+
//
|
|
7
|
+
// A single persistent stdin listener maintains the current line and echoes
|
|
8
|
+
// it (masked for the password prompt), one character at a time. This avoids
|
|
9
|
+
// the classic bug of mixing readline.question() with ad-hoc raw-mode reads,
|
|
10
|
+
// where switching prompts mid-stream can drop already-buffered input.
|
|
11
|
+
const isTty = stdin.isTTY === true;
|
|
12
|
+
let line = "";
|
|
13
|
+
let masked = false;
|
|
14
|
+
const pendingLines = [];
|
|
15
|
+
let lineReady = null;
|
|
16
|
+
if (isTty)
|
|
17
|
+
stdin.setRawMode?.(true);
|
|
18
|
+
stdin.resume();
|
|
19
|
+
stdin.setEncoding("utf8");
|
|
20
|
+
stdin.on("data", (chunk) => {
|
|
21
|
+
for (const char of chunk) {
|
|
22
|
+
if (char === "") {
|
|
23
|
+
stdout.write("\n");
|
|
24
|
+
process.exit(1);
|
|
25
|
+
}
|
|
26
|
+
if (char === "\n" || char === "\r") {
|
|
27
|
+
const value = line;
|
|
28
|
+
line = "";
|
|
29
|
+
stdout.write("\n");
|
|
30
|
+
if (lineReady) {
|
|
31
|
+
const resolve = lineReady;
|
|
32
|
+
lineReady = null;
|
|
33
|
+
resolve(value);
|
|
34
|
+
}
|
|
35
|
+
else {
|
|
36
|
+
// Answer arrived before the next prompt started asking for it
|
|
37
|
+
// (e.g. buffered/piped input) — queue it instead of dropping it.
|
|
38
|
+
pendingLines.push(value);
|
|
39
|
+
}
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
if (char === "" || char === "\b") {
|
|
43
|
+
if (line.length > 0) {
|
|
44
|
+
line = line.slice(0, -1);
|
|
45
|
+
stdout.write("\b \b");
|
|
46
|
+
}
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
line += char;
|
|
50
|
+
if (masked)
|
|
51
|
+
stdout.write("*");
|
|
52
|
+
else if (!isTty)
|
|
53
|
+
stdout.write(char); // piped input isn't echoed by the terminal itself
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
function readLine(question, opts = {}) {
|
|
57
|
+
stdout.write(question);
|
|
58
|
+
masked = Boolean(opts.mask);
|
|
59
|
+
const queued = pendingLines.shift();
|
|
60
|
+
if (queued !== undefined) {
|
|
61
|
+
stdout.write(`${queued}\n`);
|
|
62
|
+
return Promise.resolve(queued.trim());
|
|
63
|
+
}
|
|
64
|
+
return new Promise((resolve) => {
|
|
65
|
+
lineReady = (value) => resolve(value.trim());
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
async function main() {
|
|
69
|
+
runMigrations();
|
|
70
|
+
if (hasAnyUser()) {
|
|
71
|
+
console.log("An admin user already exists. Use the web UI to manage additional accounts.");
|
|
72
|
+
process.exit(0);
|
|
73
|
+
}
|
|
74
|
+
console.log("FeedKeeper setup — create the first admin account.\n");
|
|
75
|
+
const email = await readLine("Admin email: ");
|
|
76
|
+
const displayName = await readLine("Display name: ");
|
|
77
|
+
let password = "";
|
|
78
|
+
while (password.length < 10) {
|
|
79
|
+
password = await readLine("Password (min. 10 characters): ", { mask: true });
|
|
80
|
+
if (password.length < 10)
|
|
81
|
+
console.log("Password too short, try again.");
|
|
82
|
+
}
|
|
83
|
+
const user = createUser({ email, password, displayName, role: "admin" });
|
|
84
|
+
console.log(`\nAdmin account "${user.email}" created. You can now log in at your FeedKeeper URL.`);
|
|
85
|
+
process.exit(0);
|
|
86
|
+
}
|
|
87
|
+
main()
|
|
88
|
+
.catch((error) => {
|
|
89
|
+
console.error(error);
|
|
90
|
+
process.exitCode = 1;
|
|
91
|
+
})
|
|
92
|
+
.finally(() => {
|
|
93
|
+
if (isTty)
|
|
94
|
+
stdin.setRawMode?.(false);
|
|
95
|
+
stdin.pause();
|
|
96
|
+
});
|
|
97
|
+
//# sourceMappingURL=setup-cli.js.map
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { contentMetadata } from "../feeds/articleContent.js";
|
|
2
|
+
import { canAccessItem, findItemById } from "../feeds/repository.js";
|
|
3
|
+
import { db } from "../db/index.js";
|
|
4
|
+
import { findItemNote, listFoldersForUser, listMutedKeywords, listSubscriptionsForUser, } from "../feeds/repository.js";
|
|
5
|
+
import { getEditionState } from "../native/editions.js";
|
|
6
|
+
import { getNativePreferences } from "../native/preferences.js";
|
|
7
|
+
const TOMBSTONE_DAYS = 90;
|
|
8
|
+
const MAX_PAGE = 500;
|
|
9
|
+
export function serializeSubscription(subscription) {
|
|
10
|
+
return {
|
|
11
|
+
id: subscription.id,
|
|
12
|
+
url: subscription.url,
|
|
13
|
+
title: subscription.title,
|
|
14
|
+
siteUrl: subscription.site_url,
|
|
15
|
+
label: subscription.label,
|
|
16
|
+
folderId: subscription.folder_id,
|
|
17
|
+
position: subscription.position,
|
|
18
|
+
pollIntervalMinutes: subscription.poll_interval_minutes,
|
|
19
|
+
fullTextMode: subscription.full_text_mode,
|
|
20
|
+
notify: Boolean(subscription.notify),
|
|
21
|
+
badge: Boolean(subscription.badge),
|
|
22
|
+
isInbox: Boolean(subscription.is_system_inbox),
|
|
23
|
+
iconHash: subscription.icon_hash ?? null,
|
|
24
|
+
iconUrl: subscription.icon_hash
|
|
25
|
+
? `/api/v1/subscriptions/${subscription.id}/icon?v=${subscription.icon_hash}`
|
|
26
|
+
: `/api/v1/subscriptions/${subscription.id}/icon`,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
function itemState(userId, itemId) {
|
|
30
|
+
const exists = db.prepare("SELECT 1 FROM items WHERE id = ?").get(itemId);
|
|
31
|
+
if (!exists)
|
|
32
|
+
return null;
|
|
33
|
+
const row = db
|
|
34
|
+
.prepare(`SELECT EXISTS (SELECT 1 FROM item_reads WHERE user_id = ? AND item_id = ?) AS read,
|
|
35
|
+
(SELECT created_at FROM item_bookmarks WHERE user_id = ? AND item_id = ?) AS saved_at,
|
|
36
|
+
(SELECT position FROM item_progress WHERE user_id = ? AND item_id = ?) AS progress`)
|
|
37
|
+
.get(userId, itemId, userId, itemId, userId, itemId);
|
|
38
|
+
return {
|
|
39
|
+
read: Boolean(row.read),
|
|
40
|
+
saved: row.saved_at !== null,
|
|
41
|
+
savedAt: row.saved_at,
|
|
42
|
+
progress: row.progress,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Add the data a user already had before the log existed. Everything that is in the log
|
|
47
|
+
* keeps its place; the rest is appended, so a first sync with since=0 delivers the full state.
|
|
48
|
+
*/
|
|
49
|
+
export function ensureChangeLog(userId) {
|
|
50
|
+
if (db.prepare("SELECT 1 FROM sync_state WHERE user_id = ?").get(userId))
|
|
51
|
+
return;
|
|
52
|
+
db.transaction(() => {
|
|
53
|
+
db.prepare("INSERT OR IGNORE INTO sync_state (user_id) VALUES (?)").run(userId);
|
|
54
|
+
let seq = db.prepare("SELECT COALESCE(MAX(seq), 0) AS seq FROM changes WHERE user_id = ?").get(userId).seq;
|
|
55
|
+
const add = db.prepare(`INSERT OR IGNORE INTO changes (user_id, entity, entity_id, op, seq, changed_at) VALUES (?, ?, ?, 'upsert', ?, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))`);
|
|
56
|
+
const addAll = (entity, ids) => ids.forEach((id) => { if (add.run(userId, entity, id, seq + 1).changes)
|
|
57
|
+
seq++; });
|
|
58
|
+
addAll("subscription", listSubscriptionsForUser(userId).map((subscription) => subscription.id));
|
|
59
|
+
addAll("folder", listFoldersForUser(userId).map((folder) => folder.id));
|
|
60
|
+
addAll("muted_keyword", listMutedKeywords(userId).map((keyword) => keyword.id));
|
|
61
|
+
const itemIds = db
|
|
62
|
+
.prepare(`SELECT item_id FROM item_reads WHERE user_id = ?
|
|
63
|
+
UNION SELECT item_id FROM item_bookmarks WHERE user_id = ?
|
|
64
|
+
UNION SELECT item_id FROM item_progress WHERE user_id = ?`)
|
|
65
|
+
.all(userId, userId, userId)
|
|
66
|
+
.map((row) => row.item_id);
|
|
67
|
+
addAll("item_state", itemIds);
|
|
68
|
+
const noteIds = db
|
|
69
|
+
.prepare("SELECT item_id FROM item_notes WHERE user_id = ?")
|
|
70
|
+
.all(userId)
|
|
71
|
+
.map((row) => row.item_id);
|
|
72
|
+
addAll("note", noteIds);
|
|
73
|
+
if (db.prepare("SELECT user_id FROM editions WHERE user_id = ?").get(userId)) {
|
|
74
|
+
addAll("edition", [1]);
|
|
75
|
+
}
|
|
76
|
+
if (getNativePreferences(userId).revision > 0)
|
|
77
|
+
addAll("native_preferences", [1]);
|
|
78
|
+
})();
|
|
79
|
+
}
|
|
80
|
+
export class ResyncRequired extends Error {
|
|
81
|
+
constructor() {
|
|
82
|
+
super("resync_required");
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
export function currentSeq(userId) {
|
|
86
|
+
return db.prepare("SELECT COALESCE(MAX(seq), 0) AS seq FROM changes WHERE user_id = ?").get(userId).seq;
|
|
87
|
+
}
|
|
88
|
+
/** Everything that changed after `since`, oldest first, with the current data of each object. */
|
|
89
|
+
export function listChanges(userId, since, limit = 200) {
|
|
90
|
+
ensureChangeLog(userId);
|
|
91
|
+
const state = db.prepare("SELECT pruned_through FROM sync_state WHERE user_id = ?").get(userId);
|
|
92
|
+
// A client that is behind a forgotten deletion cannot know about it and has to start over.
|
|
93
|
+
if (since > 0 && since < state.pruned_through)
|
|
94
|
+
throw new ResyncRequired();
|
|
95
|
+
// A position beyond the end of the log means the server was restored from an older backup.
|
|
96
|
+
if (since > currentSeq(userId))
|
|
97
|
+
throw new ResyncRequired();
|
|
98
|
+
const page = Math.min(Math.max(limit, 1), MAX_PAGE);
|
|
99
|
+
const rows = db
|
|
100
|
+
.prepare("SELECT entity, entity_id, op, seq FROM changes WHERE user_id = ? AND seq > ? ORDER BY seq LIMIT ?")
|
|
101
|
+
.all(userId, since, page + 1);
|
|
102
|
+
const hasMore = rows.length > page;
|
|
103
|
+
const slice = rows.slice(0, page);
|
|
104
|
+
const subscriptions = new Map(listSubscriptionsForUser(userId).map((subscription) => [subscription.id, subscription]));
|
|
105
|
+
const folders = new Map(listFoldersForUser(userId).map((folder) => [folder.id, folder]));
|
|
106
|
+
const keywords = new Map(listMutedKeywords(userId).map((keyword) => [keyword.id, keyword]));
|
|
107
|
+
const changes = slice.map((row) => {
|
|
108
|
+
const change = { seq: row.seq, entity: row.entity, id: row.entity_id, op: row.op };
|
|
109
|
+
if (row.entity === "edition") {
|
|
110
|
+
const state = getEditionState(userId);
|
|
111
|
+
return state.edition ? { ...change, op: "upsert", data: { ...state.edition } } :
|
|
112
|
+
{ ...change, op: "delete", data: { revision: state.revision, status: state.status } };
|
|
113
|
+
}
|
|
114
|
+
if (row.entity === "note" && row.op === "delete") {
|
|
115
|
+
const version = db.prepare("SELECT revision FROM item_note_revisions WHERE user_id = ? AND item_id = ?").get(userId, row.entity_id);
|
|
116
|
+
return version ? { ...change, data: { revision: version.revision } } : change;
|
|
117
|
+
}
|
|
118
|
+
if (row.op === "delete")
|
|
119
|
+
return change;
|
|
120
|
+
let data;
|
|
121
|
+
if (row.entity === "item_content") {
|
|
122
|
+
const item = findItemById(row.entity_id);
|
|
123
|
+
data = item && canAccessItem(userId, row.entity_id) ? contentMetadata(item) : null;
|
|
124
|
+
}
|
|
125
|
+
else if (row.entity === "item_state")
|
|
126
|
+
data = itemState(userId, row.entity_id);
|
|
127
|
+
else if (row.entity === "subscription") {
|
|
128
|
+
const subscription = subscriptions.get(row.entity_id);
|
|
129
|
+
data = subscription && serializeSubscription(subscription);
|
|
130
|
+
}
|
|
131
|
+
else if (row.entity === "folder") {
|
|
132
|
+
const folder = folders.get(row.entity_id);
|
|
133
|
+
data = folder && { name: folder.name, iconSymbol: folder.icon_symbol ?? null };
|
|
134
|
+
}
|
|
135
|
+
else if (row.entity === "muted_keyword") {
|
|
136
|
+
const keyword = keywords.get(row.entity_id);
|
|
137
|
+
data = keyword && { keyword: keyword.keyword };
|
|
138
|
+
}
|
|
139
|
+
else if (row.entity === "note") {
|
|
140
|
+
const note = findItemNote(userId, row.entity_id);
|
|
141
|
+
data = note && {
|
|
142
|
+
itemId: note.item_id,
|
|
143
|
+
content: note.content,
|
|
144
|
+
revision: note.revision,
|
|
145
|
+
createdAt: note.created_at,
|
|
146
|
+
updatedAt: note.updated_at,
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
else if (row.entity === "native_preferences") {
|
|
150
|
+
data = { ...getNativePreferences(userId) };
|
|
151
|
+
}
|
|
152
|
+
// The object disappeared after it was logged (for example an article removed by retention).
|
|
153
|
+
return data ? { ...change, data } : { ...change, op: "delete" };
|
|
154
|
+
});
|
|
155
|
+
return { changes, nextSeq: slice.length ? slice[slice.length - 1].seq : since, hasMore };
|
|
156
|
+
}
|
|
157
|
+
/** Forget deletions older than the tombstone window and objects that no longer exist. */
|
|
158
|
+
export function pruneChangeLog() {
|
|
159
|
+
const cutoff = new Date(Date.now() - TOMBSTONE_DAYS * 86_400_000).toISOString();
|
|
160
|
+
return db.transaction(() => {
|
|
161
|
+
db.prepare(`UPDATE sync_state SET pruned_through = MAX(pruned_through, COALESCE(
|
|
162
|
+
(SELECT MAX(seq) FROM changes WHERE changes.user_id = sync_state.user_id AND op = 'delete' AND changed_at < ?), 0))`).run(cutoff);
|
|
163
|
+
const old = db.prepare("DELETE FROM changes WHERE op = 'delete' AND changed_at < ?").run(cutoff).changes;
|
|
164
|
+
const gone = db.prepare("DELETE FROM changes WHERE entity IN ('item_state', 'item_content') AND entity_id NOT IN (SELECT id FROM items)").run().changes;
|
|
165
|
+
const goneNotes = db.prepare("DELETE FROM changes WHERE entity = 'note' AND entity_id NOT IN (SELECT id FROM items)").run().changes;
|
|
166
|
+
db.prepare("DELETE FROM applied_mutations WHERE applied_at < ?").run(new Date(Date.now() - 30 * 86_400_000).toISOString());
|
|
167
|
+
return old + gone + goneNotes;
|
|
168
|
+
})();
|
|
169
|
+
}
|
|
170
|
+
//# sourceMappingURL=changeLog.js.map
|