feedkeeper 0.0.0-stage → 0.23.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (136) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +22 -2
  3. package/package.json +57 -4
  4. package/server/dist/api/archive.js +19 -0
  5. package/server/dist/api/auth.js +167 -0
  6. package/server/dist/api/feeds.js +207 -0
  7. package/server/dist/api/filters.js +32 -0
  8. package/server/dist/api/folders.js +53 -0
  9. package/server/dist/api/index.js +37 -0
  10. package/server/dist/api/items.js +129 -0
  11. package/server/dist/api/oauth.js +14 -0
  12. package/server/dist/api/onboarding.js +30 -0
  13. package/server/dist/api/push.js +51 -0
  14. package/server/dist/api/system.js +42 -0
  15. package/server/dist/api/tokens.js +28 -0
  16. package/server/dist/api/v1/index.js +178 -0
  17. package/server/dist/api/v1/resources.js +653 -0
  18. package/server/dist/api/v1/serialize.js +48 -0
  19. package/server/dist/auth/avatar.js +13 -0
  20. package/server/dist/auth/capabilities.js +53 -0
  21. package/server/dist/auth/middleware.js +77 -0
  22. package/server/dist/auth/pairing.js +36 -0
  23. package/server/dist/auth/password.js +21 -0
  24. package/server/dist/auth/security.js +126 -0
  25. package/server/dist/auth/securityRoutes.js +249 -0
  26. package/server/dist/auth/tokens.js +64 -0
  27. package/server/dist/auth/users.js +54 -0
  28. package/server/dist/backup-cli.js +101 -0
  29. package/server/dist/cli.js +135 -0
  30. package/server/dist/clientAddress.js +26 -0
  31. package/server/dist/config.js +51 -0
  32. package/server/dist/db/index.js +40 -0
  33. package/server/dist/db/migrations/0001_init.sql +70 -0
  34. package/server/dist/db/migrations/0002_feed_health.sql +5 -0
  35. package/server/dist/db/migrations/0003_retention.sql +13 -0
  36. package/server/dist/db/migrations/0004_bookmarks_and_filters.sql +20 -0
  37. package/server/dist/db/migrations/0005_folders.sql +15 -0
  38. package/server/dist/db/migrations/0006_feed_position.sql +5 -0
  39. package/server/dist/db/migrations/0007_item_image_url.sql +2 -0
  40. package/server/dist/db/migrations/0008_item_content_html.sql +2 -0
  41. package/server/dist/db/migrations/0009_item_full_content_html.sql +1 -0
  42. package/server/dist/db/migrations/0010_token_scope.sql +3 -0
  43. package/server/dist/db/migrations/0011_user_avatars.sql +8 -0
  44. package/server/dist/db/migrations/0012_full_text_settings.sql +5 -0
  45. package/server/dist/db/migrations/0013_feed_icons.sql +4 -0
  46. package/server/dist/db/migrations/0014_bookmark_archive.sql +16 -0
  47. package/server/dist/db/migrations/0015_push.sql +13 -0
  48. package/server/dist/db/migrations/0016_feed_badge.sql +2 -0
  49. package/server/dist/db/migrations/0017_devices.sql +13 -0
  50. package/server/dist/db/migrations/0018_change_log.sql +166 -0
  51. package/server/dist/db/migrations/0019_native_extensions.sql +111 -0
  52. package/server/dist/db/migrations/0020_fts.sql +32 -0
  53. package/server/dist/db/migrations/0021_note_revisions.sql +33 -0
  54. package/server/dist/db/migrations/0022_high_resolution_icons.sql +2 -0
  55. package/server/dist/db/migrations/0023_server_editions.sql +81 -0
  56. package/server/dist/db/migrations/0024_inbox.sql +5 -0
  57. package/server/dist/db/migrations/0025_edition_overview.sql +2 -0
  58. package/server/dist/db/migrations/0026_article_content.sql +43 -0
  59. package/server/dist/db/migrations/0027_oauth.sql +38 -0
  60. package/server/dist/db/migrations/0028_user_intro.sql +2 -0
  61. package/server/dist/db/migrations/0029_account_security.sql +35 -0
  62. package/server/dist/db/migrations/0030_feed_poll_jobs.sql +15 -0
  63. package/server/dist/db/migrations/0031_push_outbox.sql +19 -0
  64. package/server/dist/db/migrations/0032_normalize_published_at.sql +12 -0
  65. package/server/dist/feedkeeper.js +38 -0
  66. package/server/dist/feeds/archive.js +334 -0
  67. package/server/dist/feeds/articleContent.js +26 -0
  68. package/server/dist/feeds/cleanup.js +179 -0
  69. package/server/dist/feeds/dates.js +21 -0
  70. package/server/dist/feeds/discovery.js +153 -0
  71. package/server/dist/feeds/extractor.js +208 -0
  72. package/server/dist/feeds/feedIcon.js +171 -0
  73. package/server/dist/feeds/fetcher.js +217 -0
  74. package/server/dist/feeds/fullText.js +91 -0
  75. package/server/dist/feeds/fullTextQueue.js +159 -0
  76. package/server/dist/feeds/icon.js +97 -0
  77. package/server/dist/feeds/imageCache.js +266 -0
  78. package/server/dist/feeds/imageCacheSettings.js +19 -0
  79. package/server/dist/feeds/imageTypes.js +16 -0
  80. package/server/dist/feeds/inbox.js +128 -0
  81. package/server/dist/feeds/opml.js +190 -0
  82. package/server/dist/feeds/pollQueue.js +301 -0
  83. package/server/dist/feeds/pollSettings.js +23 -0
  84. package/server/dist/feeds/poller.js +223 -0
  85. package/server/dist/feeds/repository.js +687 -0
  86. package/server/dist/feeds/requestGate.js +99 -0
  87. package/server/dist/feeds/service.js +162 -0
  88. package/server/dist/feeds/ssrfGuard.js +98 -0
  89. package/server/dist/feeds/text.js +35 -0
  90. package/server/dist/mcp/digest.js +73 -0
  91. package/server/dist/mcp/http.js +46 -0
  92. package/server/dist/mcp/items.js +73 -0
  93. package/server/dist/mcp/promptsAndResources.js +43 -0
  94. package/server/dist/mcp/server.js +547 -0
  95. package/server/dist/native/editionSelection.js +127 -0
  96. package/server/dist/native/editionText.js +15 -0
  97. package/server/dist/native/editions.js +218 -0
  98. package/server/dist/native/preferences.js +66 -0
  99. package/server/dist/native/requests.js +35 -0
  100. package/server/dist/oauth/constants.js +6 -0
  101. package/server/dist/oauth/host.js +8 -0
  102. package/server/dist/oauth/pages.js +148 -0
  103. package/server/dist/oauth/routes.js +370 -0
  104. package/server/dist/oauth/store.js +104 -0
  105. package/server/dist/push.js +147 -0
  106. package/server/dist/pushOutbox.js +164 -0
  107. package/server/dist/security-recover-cli.js +29 -0
  108. package/server/dist/server.js +108 -0
  109. package/server/dist/setup-cli.js +97 -0
  110. package/server/dist/sync/changeLog.js +170 -0
  111. package/server/dist/sync/mutations.js +144 -0
  112. package/server/dist/trustProxy.js +14 -0
  113. package/server/dist/version.js +6 -0
  114. package/server/package.json +6 -0
  115. package/web/dist/app-icon-180.png +0 -0
  116. package/web/dist/app-icon-192.png +0 -0
  117. package/web/dist/app-icon-512.png +0 -0
  118. package/web/dist/assets/index-B4e2oqbo.css +2 -0
  119. package/web/dist/assets/index-xx6TyOwB.js +32 -0
  120. package/web/dist/assets/inter-latin-400-normal-C38fXH4l.woff2 +0 -0
  121. package/web/dist/assets/inter-latin-400-normal-CyCys3Eg.woff +0 -0
  122. package/web/dist/assets/inter-latin-500-normal-BL9OpVg8.woff +0 -0
  123. package/web/dist/assets/inter-latin-500-normal-Cerq10X2.woff2 +0 -0
  124. package/web/dist/assets/inter-latin-600-normal-CiBQ2DWP.woff +0 -0
  125. package/web/dist/assets/inter-latin-600-normal-LgqL8muc.woff2 +0 -0
  126. package/web/dist/assets/manrope-latin-600-normal-4f0koTD-.woff2 +0 -0
  127. package/web/dist/assets/manrope-latin-600-normal-BqgrALkZ.woff +0 -0
  128. package/web/dist/assets/manrope-latin-700-normal-BZp_XxE4.woff2 +0 -0
  129. package/web/dist/assets/manrope-latin-700-normal-DGRFkw-m.woff +0 -0
  130. package/web/dist/favicon-32.png +0 -0
  131. package/web/dist/index.html +40 -0
  132. package/web/dist/logo.svg +50 -0
  133. package/web/dist/manifest.webmanifest +64 -0
  134. package/web/dist/robots.txt +3 -0
  135. package/web/dist/sw.js +198 -0
  136. package/web/dist/theme-init.js +10 -0
@@ -0,0 +1,64 @@
1
+ import { randomBytes, createHash, timingSafeEqual } from "node:crypto";
2
+ import { db } from "../db/index.js";
3
+ import { resolveAccessToken } from "../oauth/store.js";
4
+ const TOKEN_PREFIX = "fk_";
5
+ const OAUTH_ACCESS_PREFIX = "fk_oat_";
6
+ function hashToken(token) {
7
+ return createHash("sha256").update(token).digest("hex");
8
+ }
9
+ // Returns the plaintext token once; only the hash is persisted.
10
+ export function createPersonalAccessToken(userId, name, scope, device) {
11
+ const secret = randomBytes(32).toString("base64url");
12
+ const token = `${TOKEN_PREFIX}${device ? "dev_" : ""}${secret}`;
13
+ const tokenHash = hashToken(token);
14
+ const tokenPrefix = token.slice(0, 10);
15
+ const result = db
16
+ .prepare(`INSERT INTO personal_access_tokens (user_id, name, token_hash, token_prefix, scope, kind, platform, app_version)
17
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`)
18
+ .run(userId, name, tokenHash, tokenPrefix, scope, device ? "device" : "api", device?.platform ?? null, device?.appVersion ?? null);
19
+ return { id: Number(result.lastInsertRowid), token };
20
+ }
21
+ export function resolveToken(token) {
22
+ if (!token.startsWith(TOKEN_PREFIX))
23
+ return null;
24
+ if (token.startsWith(OAUTH_ACCESS_PREFIX)) {
25
+ const grant = resolveAccessToken(token);
26
+ return grant ? { userId: grant.userId, scope: grant.scope, kind: "oauth", tokenId: grant.grantId } : null;
27
+ }
28
+ const tokenHash = hashToken(token);
29
+ const row = db
30
+ .prepare("SELECT * FROM personal_access_tokens WHERE token_hash = ?")
31
+ .get(tokenHash);
32
+ if (!row)
33
+ return null;
34
+ // Defense in depth: even though the lookup is by exact hash match, compare
35
+ // in constant time to avoid timing side channels on the hash comparison.
36
+ const stored = Buffer.from(row.token_hash, "hex");
37
+ const provided = Buffer.from(tokenHash, "hex");
38
+ if (stored.length !== provided.length || !timingSafeEqual(stored, provided)) {
39
+ return null;
40
+ }
41
+ db.prepare("UPDATE personal_access_tokens SET last_used_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?").run(row.id);
42
+ return { userId: row.user_id, scope: row.scope, kind: row.kind, tokenId: row.id };
43
+ }
44
+ /** Hand-made tokens only; paired devices are listed separately. */
45
+ export function listTokensForUser(userId) {
46
+ return db
47
+ .prepare("SELECT * FROM personal_access_tokens WHERE user_id = ? AND kind = 'api' ORDER BY created_at DESC")
48
+ .all(userId)
49
+ .map(({ token_hash, ...rest }) => rest);
50
+ }
51
+ export function deleteToken(userId, tokenId) {
52
+ db.prepare("DELETE FROM personal_access_tokens WHERE id = ? AND user_id = ? AND kind = 'api'").run(tokenId, userId);
53
+ }
54
+ export function listDevicesForUser(userId) {
55
+ return db
56
+ .prepare("SELECT * FROM personal_access_tokens WHERE user_id = ? AND kind = 'device' ORDER BY created_at DESC")
57
+ .all(userId)
58
+ .map(({ token_hash, ...rest }) => rest);
59
+ }
60
+ /** Revokes a device; its token stops working immediately. Returns whether one was removed. */
61
+ export function deleteDevice(userId, deviceId) {
62
+ return db.prepare("DELETE FROM personal_access_tokens WHERE id = ? AND user_id = ? AND kind = 'device'").run(deviceId, userId).changes > 0;
63
+ }
64
+ //# sourceMappingURL=tokens.js.map
@@ -0,0 +1,54 @@
1
+ import { db } from "../db/index.js";
2
+ import { hashPassword } from "./password.js";
3
+ // Joins only the avatar timestamp; the image itself is loaded on demand.
4
+ const SELECT_USER = `SELECT users.*, user_avatars.updated_at AS avatar_updated_at
5
+ FROM users LEFT JOIN user_avatars ON user_avatars.user_id = users.id`;
6
+ export function toPublicUser(user) {
7
+ const { password_hash, ...rest } = user;
8
+ return rest;
9
+ }
10
+ /** Marks the introduction as seen; it stays marked once it is (the first time counts). */
11
+ export function dismissIntro(userId) {
12
+ db.prepare("UPDATE users SET intro_dismissed_at = COALESCE(intro_dismissed_at, strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) WHERE id = ?").run(userId);
13
+ }
14
+ export function findUserByEmail(email) {
15
+ return db.prepare(`${SELECT_USER} WHERE users.email = ?`).get(email.toLowerCase());
16
+ }
17
+ export function findUserById(id) {
18
+ return db.prepare(`${SELECT_USER} WHERE users.id = ?`).get(id);
19
+ }
20
+ export function createUser(input) {
21
+ const result = db
22
+ .prepare(`INSERT INTO users (email, password_hash, display_name, role)
23
+ VALUES (?, ?, ?, ?)`)
24
+ .run(input.email.toLowerCase(), hashPassword(input.password), input.displayName, input.role);
25
+ return findUserById(Number(result.lastInsertRowid));
26
+ }
27
+ export function updateUserPassword(userId, newPassword) {
28
+ db.prepare("UPDATE users SET password_hash = ? WHERE id = ?").run(hashPassword(newPassword), userId);
29
+ }
30
+ export function listUsers() {
31
+ return db
32
+ .prepare(`${SELECT_USER} ORDER BY users.created_at ASC`)
33
+ .all()
34
+ .map(toPublicUser);
35
+ }
36
+ export function updateDisplayName(userId, displayName) {
37
+ db.prepare("UPDATE users SET display_name = ? WHERE id = ?").run(displayName, userId);
38
+ }
39
+ export function getUserAvatar(userId) {
40
+ return db
41
+ .prepare("SELECT mime_type, data FROM user_avatars WHERE user_id = ?")
42
+ .get(userId);
43
+ }
44
+ export function setUserAvatar(userId, mimeType, data) {
45
+ db.prepare(`INSERT INTO user_avatars (user_id, mime_type, data) VALUES (?, ?, ?)
46
+ ON CONFLICT(user_id) DO UPDATE SET
47
+ mime_type = excluded.mime_type,
48
+ data = excluded.data,
49
+ updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')`).run(userId, mimeType, data);
50
+ }
51
+ export function deleteUserAvatar(userId) {
52
+ db.prepare("DELETE FROM user_avatars WHERE user_id = ?").run(userId);
53
+ }
54
+ //# sourceMappingURL=users.js.map
@@ -0,0 +1,101 @@
1
+ import Database from "better-sqlite3";
2
+ import { copyFileSync, cpSync, existsSync, mkdirSync, mkdtempSync, readdirSync, rmSync, statSync, unlinkSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { dirname, join, resolve } from "node:path";
5
+ import { config } from "./config.js";
6
+ function assertHealthy(database) {
7
+ const result = database.pragma("integrity_check", { simple: true });
8
+ if (result !== "ok")
9
+ throw new Error(`SQLite integrity check failed: ${String(result)}`);
10
+ }
11
+ // Images of saved articles live next to the backup file as "<backup>.archive".
12
+ const archiveCopyOf = (backupFile) => `${backupFile}.archive`;
13
+ function hasFiles(directory) {
14
+ return existsSync(directory) && readdirSync(directory).length > 0;
15
+ }
16
+ async function main() {
17
+ const [action, filename, flag] = process.argv.slice(2);
18
+ if (!(["backup", "restore"].includes(action) && filename)) {
19
+ throw new Error("Usage: npm run db:backup -- <destination> | npm run db:restore -- <backup> --force");
20
+ }
21
+ const databasePath = resolve(config.databasePath);
22
+ const filePath = resolve(filename);
23
+ if (databasePath === filePath)
24
+ throw new Error("Source and destination must differ");
25
+ if (action === "backup") {
26
+ if (!existsSync(databasePath))
27
+ throw new Error(`Database not found: ${databasePath}`);
28
+ if (existsSync(filePath))
29
+ throw new Error(`Backup already exists: ${filePath}`);
30
+ mkdirSync(dirname(filePath), { recursive: true, mode: 0o700 });
31
+ process.umask(0o077);
32
+ const { db } = await import("./db/index.js");
33
+ try {
34
+ await db.backup(filePath);
35
+ const saved = new Database(filePath, { readonly: true, fileMustExist: true });
36
+ try {
37
+ assertHealthy(saved);
38
+ }
39
+ finally {
40
+ saved.close();
41
+ }
42
+ console.log(`Backup verified: ${filePath}`);
43
+ const archivePath = resolve(config.archivePath);
44
+ if (hasFiles(archivePath)) {
45
+ cpSync(archivePath, archiveCopyOf(filePath), { recursive: true, errorOnExist: true, force: false });
46
+ console.log(`Archived images copied: ${archiveCopyOf(filePath)}`);
47
+ }
48
+ }
49
+ catch (error) {
50
+ if (existsSync(filePath))
51
+ unlinkSync(filePath);
52
+ rmSync(archiveCopyOf(filePath), { recursive: true, force: true });
53
+ throw error;
54
+ }
55
+ finally {
56
+ db.close();
57
+ }
58
+ return;
59
+ }
60
+ if (flag !== "--force")
61
+ throw new Error("Restore replaces the configured database. Stop FeedKeeper and pass --force.");
62
+ if (!existsSync(filePath) || !statSync(filePath).isFile())
63
+ throw new Error(`Backup file not found: ${filePath}`);
64
+ // A backup is a WAL-mode database, and SQLite needs to create a -shm file next to a database it opens, even for
65
+ // reading. The backup may sit where nothing can be created (a single file mounted into a container, a read-only
66
+ // volume), so it is opened from a copy in the temporary folder.
67
+ const scratch = mkdtempSync(join(tmpdir(), "feedkeeper-restore-"));
68
+ const copy = join(scratch, "backup.sqlite");
69
+ copyFileSync(filePath, copy);
70
+ const source = new Database(copy, { readonly: true, fileMustExist: true });
71
+ try {
72
+ assertHealthy(source);
73
+ mkdirSync(dirname(databasePath), { recursive: true, mode: 0o700 });
74
+ process.umask(0o077);
75
+ await source.backup(databasePath);
76
+ }
77
+ finally {
78
+ source.close();
79
+ rmSync(scratch, { recursive: true, force: true });
80
+ }
81
+ const restored = new Database(databasePath, { readonly: true, fileMustExist: true });
82
+ try {
83
+ assertHealthy(restored);
84
+ }
85
+ finally {
86
+ restored.close();
87
+ }
88
+ console.log(`Database restored and verified: ${databasePath}`);
89
+ const archiveBackup = archiveCopyOf(filePath);
90
+ if (existsSync(archiveBackup)) {
91
+ const archivePath = resolve(config.archivePath);
92
+ rmSync(archivePath, { recursive: true, force: true });
93
+ cpSync(archiveBackup, archivePath, { recursive: true });
94
+ console.log(`Archived images restored: ${archivePath}`);
95
+ }
96
+ }
97
+ main().catch((error) => {
98
+ console.error(error instanceof Error ? error.message : String(error));
99
+ process.exitCode = 1;
100
+ });
101
+ //# sourceMappingURL=backup-cli.js.map
@@ -0,0 +1,135 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
+ import { homedir } from "node:os";
4
+ import { dirname, join, resolve } from "node:path";
5
+ export const HELP = `FeedKeeper: a self-hosted RSS reader and remote MCP server
6
+
7
+ Usage:
8
+ feedkeeper [options] Start the server
9
+ feedkeeper backup <file> Back up the database (see docs for restore)
10
+ feedkeeper restore <file> --force Restore the database from a backup
11
+
12
+ Options:
13
+ --port <number> Port to listen on (default: 3000, or PORT)
14
+ --public-url <url> Address people reach this instance at (default: http://localhost:<port>)
15
+ --data-dir <folder> Where the database, archive and session secret live
16
+ (default: the folder of DATABASE_PATH, otherwise ~/.feedkeeper)
17
+ -v, --version Print the version
18
+ -h, --help Print this help
19
+
20
+ A .env file in the current folder is read first; real environment variables win over it.
21
+ Without SESSION_SECRET, one is generated on first start and kept in the data folder.
22
+ All other settings are the environment variables of .env.example in the repository.`;
23
+ export class UsageError extends Error {
24
+ }
25
+ function takeValue(argv, index, flag) {
26
+ const argument = argv[index];
27
+ const inline = argument.indexOf("=");
28
+ if (inline !== -1)
29
+ return [argument.slice(inline + 1), index];
30
+ const next = argv[index + 1];
31
+ if (next === undefined || next.startsWith("-"))
32
+ throw new UsageError(`${flag} needs a value`);
33
+ return [next, index + 1];
34
+ }
35
+ /** Turns the command line into options, or throws a UsageError that says what is wrong. */
36
+ export function parseArgs(argv) {
37
+ const options = { action: "start", forwarded: [] };
38
+ for (let index = 0; index < argv.length; index++) {
39
+ const argument = argv[index];
40
+ const name = argument.split("=")[0];
41
+ if (argument === "-h" || argument === "--help")
42
+ return { ...options, action: "help" };
43
+ if (argument === "-v" || argument === "--version")
44
+ return { ...options, action: "version" };
45
+ if (name === "--port") {
46
+ const [value, next] = takeValue(argv, index, "--port");
47
+ const port = Number(value);
48
+ if (!Number.isInteger(port) || port < 1 || port > 65535)
49
+ throw new UsageError(`--port must be a number from 1 to 65535, got "${value}"`);
50
+ options.port = port;
51
+ index = next;
52
+ }
53
+ else if (name === "--public-url") {
54
+ const [value, next] = takeValue(argv, index, "--public-url");
55
+ let url;
56
+ try {
57
+ url = new URL(value);
58
+ }
59
+ catch {
60
+ throw new UsageError(`--public-url must be an address such as https://feeds.example.org, got "${value}"`);
61
+ }
62
+ if (url.protocol !== "http:" && url.protocol !== "https:")
63
+ throw new UsageError(`--public-url must start with http:// or https://, got "${value}"`);
64
+ options.publicUrl = value.replace(/\/+$/, "");
65
+ index = next;
66
+ }
67
+ else if (name === "--data-dir") {
68
+ const [value, next] = takeValue(argv, index, "--data-dir");
69
+ options.dataDir = value;
70
+ index = next;
71
+ }
72
+ else if (options.action === "start" && (argument === "backup" || argument === "restore")) {
73
+ options.action = argument;
74
+ options.forwarded.push(argument);
75
+ }
76
+ else if (options.action !== "start" && (argument === "--force" || !argument.startsWith("-"))) {
77
+ // The file name and --force belong to the backup tool; options above are the only ones read here.
78
+ options.forwarded.push(argument);
79
+ }
80
+ else {
81
+ throw new UsageError(`Unknown ${argument.startsWith("-") ? "option" : "command"} "${argument}"`);
82
+ }
83
+ }
84
+ return options;
85
+ }
86
+ /**
87
+ * Fills the settings the server needs and an operator should not have to think about on a first start: a data
88
+ * folder, the database path, the public address that follows the port, and a session secret. Values that are
89
+ * already set are never replaced, apart from the ones given as options.
90
+ */
91
+ export function prepareEnvironment(options, env, home = homedir(), persist = options.action === "start") {
92
+ if (options.port !== undefined)
93
+ env.PORT = String(options.port);
94
+ if (options.publicUrl !== undefined)
95
+ env.PUBLIC_URL = options.publicUrl;
96
+ if (!env.PUBLIC_URL?.trim())
97
+ env.PUBLIC_URL = `http://localhost:${env.PORT?.trim() || 3000}`;
98
+ const configured = env.DATABASE_PATH?.trim();
99
+ const dataDir = resolve(options.dataDir ?? (env.FEEDKEEPER_DATA_DIR?.trim() || (configured ? dirname(resolve(configured)) : join(home, ".feedkeeper"))));
100
+ if (!configured || options.dataDir !== undefined)
101
+ env.DATABASE_PATH = join(dataDir, "feedkeeper.sqlite");
102
+ if (persist)
103
+ mkdirSync(dirname(resolve(env.DATABASE_PATH)), { recursive: true, mode: 0o700 });
104
+ let secretCreated = false;
105
+ let secretPath = null;
106
+ if (!env.SESSION_SECRET?.trim()) {
107
+ secretPath = join(dataDir, "session-secret");
108
+ const read = () => (existsSync(secretPath) ? readFileSync(secretPath, "utf8").trim() : "");
109
+ let secret = read();
110
+ if (!secret && !persist) {
111
+ // Backup and restore never sign anything, but the configuration insists on a secret: a throwaway one, not stored.
112
+ env.SESSION_SECRET = randomBytes(32).toString("hex");
113
+ return { dataDir, secretCreated: false, secretPath: null };
114
+ }
115
+ if (!secret) {
116
+ mkdirSync(dataDir, { recursive: true, mode: 0o700 });
117
+ try {
118
+ writeFileSync(secretPath, `${randomBytes(32).toString("hex")}\n`, { flag: "wx", mode: 0o600 });
119
+ secretCreated = true;
120
+ }
121
+ catch (error) {
122
+ // Another process created it in the meantime: use theirs.
123
+ if (error.code !== "EEXIST")
124
+ throw error;
125
+ }
126
+ secret = read();
127
+ }
128
+ if (!secret)
129
+ throw new Error(`Could not read or create the session secret at ${secretPath}`);
130
+ env.SESSION_SECRET = secret;
131
+ }
132
+ env.NODE_ENV ??= "production";
133
+ return { dataDir, secretCreated, secretPath };
134
+ }
135
+ //# sourceMappingURL=cli.js.map
@@ -0,0 +1,26 @@
1
+ import { isIP } from "node:net";
2
+ /**
3
+ * Takes the client address from a header set by a CDN, such as Cloudflare's CF-Connecting-IP.
4
+ *
5
+ * Some web servers in front of the app replace X-Forwarded-For with the address of the CDN edge
6
+ * that connected to them, so `trust proxy` alone finds only that edge. The header is used only when
7
+ * the request reached the app through the trusted proxies listed in TRUST_PROXY: `req.ip` must itself
8
+ * be one of them. A client that bypasses the CDN is not trusted, so its own header is ignored.
9
+ * Hop counts cannot tell a CDN edge from a client, so the header needs a TRUST_PROXY list.
10
+ */
11
+ export function clientAddressFromHeader(header, trustProxy) {
12
+ const name = header?.trim().toLowerCase() || null;
13
+ const enabled = name !== null && Array.isArray(trustProxy);
14
+ return (req, _res, next) => {
15
+ if (enabled) {
16
+ const value = req.headers[name];
17
+ const address = typeof value === "string" ? value.trim() : "";
18
+ const trusted = req.app.get("trust proxy fn");
19
+ if (address && isIP(address) && req.ip && trusted(req.ip, 0)) {
20
+ Object.defineProperty(req, "ip", { value: address, configurable: true, enumerable: true });
21
+ }
22
+ }
23
+ next();
24
+ };
25
+ }
26
+ //# sourceMappingURL=clientAddress.js.map
@@ -0,0 +1,51 @@
1
+ import { config as loadEnv } from "dotenv";
2
+ import { dirname, join } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import { readPollSettings } from "./feeds/pollSettings.js";
5
+ import { parseTrustProxy } from "./trustProxy.js";
6
+ import { readImageCacheSettings } from "./feeds/imageCacheSettings.js";
7
+ const __dirname = dirname(fileURLToPath(import.meta.url));
8
+ // Loads the repo-root .env regardless of the process's current working
9
+ // directory, so `npm run dev`/`start` behave the same from anywhere.
10
+ // `quiet: true` suppresses dotenv's own console output (including its
11
+ // unrelated third-party promo "tips"), which has no place in server logs.
12
+ loadEnv({ path: join(__dirname, "../../.env"), quiet: true });
13
+ function required(name, fallback) {
14
+ const value = process.env[name] ?? fallback;
15
+ if (value === undefined) {
16
+ throw new Error(`Missing required environment variable: ${name}`);
17
+ }
18
+ return value;
19
+ }
20
+ export const config = {
21
+ imageCache: readImageCacheSettings(),
22
+ polling: readPollSettings(),
23
+ port: Number(process.env.PORT ?? 3000),
24
+ publicUrl: required("PUBLIC_URL", "http://localhost:3000"),
25
+ databasePath: required("DATABASE_PATH", "./data/feedkeeper.sqlite"),
26
+ // Images of saved articles live outside the database so it stays small.
27
+ archivePath: process.env.ARCHIVE_PATH ?? join(dirname(process.env.DATABASE_PATH ?? "./data/feedkeeper.sqlite"), "archive"),
28
+ sessionSecret: required("SESSION_SECRET"),
29
+ allowSignup: process.env.ALLOW_SIGNUP === "true",
30
+ trustProxy: parseTrustProxy(process.env.TRUST_PROXY),
31
+ clientIpHeader: process.env.CLIENT_IP_HEADER?.trim() || null,
32
+ minPollIntervalMinutes: Number(process.env.MIN_POLL_INTERVAL_MINUTES ?? 5),
33
+ retentionReadDays: Number(process.env.RETENTION_READ_DAYS ?? 30),
34
+ retentionMaxDays: Number(process.env.RETENTION_MAX_DAYS ?? 90),
35
+ retentionMaxItemsPerFeed: Number(process.env.RETENTION_MAX_ITEMS_PER_FEED ?? 1000),
36
+ autoCleanupEnabled: process.env.AUTO_CLEANUP_ENABLED !== "false",
37
+ // Optional: only these hosts may register OAuth redirect URIs (comma-separated). Loopback addresses are always allowed.
38
+ oauthAllowedRedirectHosts: (process.env.OAUTH_ALLOWED_REDIRECT_HOSTS ?? "")
39
+ .split(",")
40
+ .map((host) => host.trim().toLowerCase())
41
+ .filter(Boolean),
42
+ showGithubLink: process.env.SHOW_GITHUB_LINK !== "false",
43
+ githubUrl: process.env.GITHUB_URL ?? "https://github.com/visualfusion/feedkeeper",
44
+ };
45
+ if (!config.sessionSecret.trim()) {
46
+ throw new Error("SESSION_SECRET must not be empty");
47
+ }
48
+ if (config.sessionSecret.trim().length < 32) {
49
+ console.warn("[config] SESSION_SECRET is shorter than 32 characters; use a long random secret for new installations");
50
+ }
51
+ //# sourceMappingURL=config.js.map
@@ -0,0 +1,40 @@
1
+ import Database from "better-sqlite3";
2
+ import { mkdirSync, readdirSync, readFileSync } from "node:fs";
3
+ import { dirname, join } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { config } from "../config.js";
6
+ const __dirname = dirname(fileURLToPath(import.meta.url));
7
+ const MIGRATIONS_DIR = join(__dirname, "migrations");
8
+ mkdirSync(dirname(config.databasePath), { recursive: true });
9
+ export const db = new Database(config.databasePath);
10
+ db.pragma("journal_mode = WAL");
11
+ db.pragma("foreign_keys = ON");
12
+ function ensureMigrationsTable() {
13
+ db.exec(`CREATE TABLE IF NOT EXISTS _migrations (
14
+ name TEXT PRIMARY KEY,
15
+ applied_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
16
+ )`);
17
+ }
18
+ export function runMigrations() {
19
+ ensureMigrationsTable();
20
+ const applied = new Set(db.prepare("SELECT name FROM _migrations").all().map((row) => row.name));
21
+ const files = readdirSync(MIGRATIONS_DIR)
22
+ .filter((file) => file.endsWith(".sql"))
23
+ .sort();
24
+ for (const file of files) {
25
+ if (applied.has(file))
26
+ continue;
27
+ const sql = readFileSync(join(MIGRATIONS_DIR, file), "utf8");
28
+ const applyMigration = db.transaction(() => {
29
+ db.exec(sql);
30
+ db.prepare("INSERT INTO _migrations (name) VALUES (?)").run(file);
31
+ });
32
+ applyMigration();
33
+ console.log(`[db] applied migration ${file}`);
34
+ }
35
+ }
36
+ export function hasAnyUser() {
37
+ const row = db.prepare("SELECT COUNT(*) AS count FROM users").get();
38
+ return row.count > 0;
39
+ }
40
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1,70 @@
1
+ -- Initial schema.
2
+ -- Feeds are shared across users so the same source URL is only ever fetched
3
+ -- once; per-user state (subscription label, read status) lives separately.
4
+
5
+ CREATE TABLE users (
6
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
7
+ email TEXT NOT NULL UNIQUE,
8
+ password_hash TEXT NOT NULL,
9
+ display_name TEXT NOT NULL,
10
+ role TEXT NOT NULL CHECK (role IN ('admin', 'user')) DEFAULT 'user',
11
+ created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
12
+ );
13
+
14
+ CREATE TABLE personal_access_tokens (
15
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
16
+ user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
17
+ name TEXT NOT NULL,
18
+ token_hash TEXT NOT NULL UNIQUE,
19
+ token_prefix TEXT NOT NULL,
20
+ created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
21
+ last_used_at TEXT
22
+ );
23
+
24
+ CREATE INDEX idx_pat_user ON personal_access_tokens(user_id);
25
+
26
+ CREATE TABLE feeds (
27
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
28
+ url TEXT NOT NULL UNIQUE,
29
+ title TEXT,
30
+ site_url TEXT,
31
+ poll_interval_minutes INTEGER NOT NULL DEFAULT 15,
32
+ last_polled_at TEXT,
33
+ last_error TEXT,
34
+ etag TEXT,
35
+ last_modified TEXT,
36
+ created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
37
+ );
38
+
39
+ CREATE TABLE subscriptions (
40
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
41
+ user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
42
+ feed_id INTEGER NOT NULL REFERENCES feeds(id) ON DELETE CASCADE,
43
+ label TEXT,
44
+ created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
45
+ UNIQUE (user_id, feed_id)
46
+ );
47
+
48
+ CREATE INDEX idx_subscriptions_user ON subscriptions(user_id);
49
+ CREATE INDEX idx_subscriptions_feed ON subscriptions(feed_id);
50
+
51
+ CREATE TABLE items (
52
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
53
+ feed_id INTEGER NOT NULL REFERENCES feeds(id) ON DELETE CASCADE,
54
+ guid TEXT NOT NULL,
55
+ title TEXT,
56
+ link TEXT,
57
+ content_snippet TEXT,
58
+ published_at TEXT,
59
+ created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
60
+ UNIQUE (feed_id, guid)
61
+ );
62
+
63
+ CREATE INDEX idx_items_feed ON items(feed_id, published_at DESC);
64
+
65
+ CREATE TABLE item_reads (
66
+ user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
67
+ item_id INTEGER NOT NULL REFERENCES items(id) ON DELETE CASCADE,
68
+ read_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
69
+ PRIMARY KEY (user_id, item_id)
70
+ );
@@ -0,0 +1,5 @@
1
+ ALTER TABLE feeds ADD COLUMN last_success_at TEXT;
2
+ ALTER TABLE feeds ADD COLUMN consecutive_errors INTEGER NOT NULL DEFAULT 0;
3
+
4
+ UPDATE feeds SET last_success_at = last_polled_at WHERE last_error IS NULL AND last_polled_at IS NOT NULL;
5
+ UPDATE feeds SET consecutive_errors = 1 WHERE last_error IS NOT NULL;
@@ -0,0 +1,13 @@
1
+ -- Migration 0003: System settings for database retention and housekeeping
2
+
3
+ CREATE TABLE IF NOT EXISTS system_settings (
4
+ key TEXT PRIMARY KEY,
5
+ value TEXT NOT NULL,
6
+ updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
7
+ );
8
+
9
+ INSERT OR IGNORE INTO system_settings (key, value) VALUES
10
+ ('retention_read_days', '30'),
11
+ ('retention_max_days', '90'),
12
+ ('retention_max_items_per_feed', '1000'),
13
+ ('auto_cleanup_enabled', 'true');
@@ -0,0 +1,20 @@
1
+ -- Migration 0004: Bookmarks (Saved Items) and User Muted Keywords
2
+
3
+ CREATE TABLE IF NOT EXISTS item_bookmarks (
4
+ user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
5
+ item_id INTEGER NOT NULL REFERENCES items(id) ON DELETE CASCADE,
6
+ created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
7
+ PRIMARY KEY (user_id, item_id)
8
+ );
9
+
10
+ CREATE INDEX IF NOT EXISTS idx_bookmarks_user ON item_bookmarks(user_id);
11
+
12
+ CREATE TABLE IF NOT EXISTS user_muted_keywords (
13
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
14
+ user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
15
+ keyword TEXT NOT NULL,
16
+ created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
17
+ UNIQUE (user_id, keyword)
18
+ );
19
+
20
+ CREATE INDEX IF NOT EXISTS idx_muted_user ON user_muted_keywords(user_id);
@@ -0,0 +1,15 @@
1
+ -- Migration 0005: Feed Folders / Categories
2
+
3
+ CREATE TABLE IF NOT EXISTS folders (
4
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
5
+ user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
6
+ name TEXT NOT NULL,
7
+ created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
8
+ UNIQUE (user_id, name)
9
+ );
10
+
11
+ CREATE INDEX IF NOT EXISTS idx_folders_user ON folders(user_id);
12
+
13
+ ALTER TABLE subscriptions ADD COLUMN folder_id INTEGER REFERENCES folders(id) ON DELETE SET NULL;
14
+
15
+ CREATE INDEX IF NOT EXISTS idx_subscriptions_folder ON subscriptions(folder_id);
@@ -0,0 +1,5 @@
1
+ -- Migration 0006: Custom Feed Ordering (position)
2
+
3
+ ALTER TABLE subscriptions ADD COLUMN position INTEGER NOT NULL DEFAULT 0;
4
+
5
+ CREATE INDEX IF NOT EXISTS idx_subscriptions_user_pos ON subscriptions(user_id, position);
@@ -0,0 +1,2 @@
1
+ -- Migration 0007: Store image URL for feed items
2
+ ALTER TABLE items ADD COLUMN image_url TEXT;
@@ -0,0 +1,2 @@
1
+ -- Migration 0008: Store full content HTML for feed items
2
+ ALTER TABLE items ADD COLUMN content_html TEXT;
@@ -0,0 +1 @@
1
+ ALTER TABLE items ADD COLUMN full_content_html TEXT;
@@ -0,0 +1,3 @@
1
+ -- Existing tokens keep their original write access. New tokens can be read-only.
2
+ ALTER TABLE personal_access_tokens
3
+ ADD COLUMN scope TEXT NOT NULL DEFAULT 'write' CHECK (scope IN ('read', 'write'));
@@ -0,0 +1,8 @@
1
+ -- Profile photos live in their own table so user lookups on every request
2
+ -- never load image data, and backups keep covering a single SQLite file.
3
+ CREATE TABLE user_avatars (
4
+ user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
5
+ mime_type TEXT NOT NULL CHECK (mime_type IN ('image/jpeg', 'image/png', 'image/webp')),
6
+ data BLOB NOT NULL,
7
+ updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
8
+ );
@@ -0,0 +1,5 @@
1
+ -- Per-subscription choice whether the reader may fetch full articles, and a
2
+ -- per-feed memory of sites that answer full-text requests with a consent wall.
3
+ ALTER TABLE subscriptions ADD COLUMN full_text_mode TEXT NOT NULL DEFAULT 'auto' CHECK (full_text_mode IN ('auto', 'never'));
4
+ ALTER TABLE feeds ADD COLUMN full_text_blocks INTEGER NOT NULL DEFAULT 0;
5
+ ALTER TABLE feeds ADD COLUMN full_text_blocked_at TEXT;
@@ -0,0 +1,4 @@
1
+ -- Icon declared by the feed's website, so the browser does not rely on a
2
+ -- guessed /favicon.ico that may be missing or blank.
3
+ ALTER TABLE feeds ADD COLUMN icon_url TEXT;
4
+ ALTER TABLE feeds ADD COLUMN icon_checked_at TEXT;