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.
- 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 +167 -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/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 +21 -0
- package/server/dist/auth/security.js +126 -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/db/migrations/0032_normalize_published_at.sql +12 -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/dates.js +21 -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 +223 -0
- package/server/dist/feeds/repository.js +687 -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/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 +370 -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,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 @@
|
|
|
1
|
+
ALTER TABLE items ADD COLUMN full_content_html TEXT;
|
|
@@ -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;
|