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,33 @@
|
|
|
1
|
+
-- Keep note revisions after deletion so a stale device cannot overwrite a recreated note.
|
|
2
|
+
CREATE TABLE item_note_revisions (
|
|
3
|
+
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
4
|
+
item_id INTEGER NOT NULL REFERENCES items(id) ON DELETE CASCADE,
|
|
5
|
+
revision INTEGER NOT NULL,
|
|
6
|
+
updated_at TEXT NOT NULL,
|
|
7
|
+
PRIMARY KEY (user_id, item_id)
|
|
8
|
+
);
|
|
9
|
+
|
|
10
|
+
INSERT INTO item_note_revisions (user_id, item_id, revision, updated_at)
|
|
11
|
+
SELECT user_id, item_id, revision, updated_at FROM item_notes;
|
|
12
|
+
|
|
13
|
+
CREATE TRIGGER trg_note_revision_insert AFTER INSERT ON item_notes
|
|
14
|
+
BEGIN
|
|
15
|
+
INSERT INTO item_note_revisions (user_id, item_id, revision, updated_at)
|
|
16
|
+
VALUES (NEW.user_id, NEW.item_id, NEW.revision, NEW.updated_at)
|
|
17
|
+
ON CONFLICT (user_id, item_id) DO UPDATE SET revision = excluded.revision, updated_at = excluded.updated_at;
|
|
18
|
+
END;
|
|
19
|
+
|
|
20
|
+
CREATE TRIGGER trg_note_revision_update AFTER UPDATE ON item_notes
|
|
21
|
+
BEGIN
|
|
22
|
+
INSERT INTO item_note_revisions (user_id, item_id, revision, updated_at)
|
|
23
|
+
VALUES (NEW.user_id, NEW.item_id, NEW.revision, NEW.updated_at)
|
|
24
|
+
ON CONFLICT (user_id, item_id) DO UPDATE SET revision = excluded.revision, updated_at = excluded.updated_at;
|
|
25
|
+
END;
|
|
26
|
+
|
|
27
|
+
CREATE TRIGGER trg_note_revision_delete AFTER DELETE ON item_notes
|
|
28
|
+
WHEN EXISTS (SELECT 1 FROM items WHERE id = OLD.item_id)
|
|
29
|
+
BEGIN
|
|
30
|
+
UPDATE item_note_revisions SET revision = OLD.revision + 1,
|
|
31
|
+
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
|
|
32
|
+
WHERE user_id = OLD.user_id AND item_id = OLD.item_id;
|
|
33
|
+
END;
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
-- Native presentation preferences never change the web folder order or stream.
|
|
2
|
+
CREATE TABLE native_preferences (
|
|
3
|
+
user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
|
|
4
|
+
revision INTEGER NOT NULL DEFAULT 1,
|
|
5
|
+
settings TEXT NOT NULL CHECK (json_valid(settings)),
|
|
6
|
+
updated_at TEXT NOT NULL
|
|
7
|
+
);
|
|
8
|
+
|
|
9
|
+
CREATE TABLE native_requests (
|
|
10
|
+
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
11
|
+
request_id TEXT NOT NULL,
|
|
12
|
+
fingerprint TEXT NOT NULL,
|
|
13
|
+
response TEXT NOT NULL,
|
|
14
|
+
created_at TEXT NOT NULL,
|
|
15
|
+
PRIMARY KEY (user_id, request_id)
|
|
16
|
+
) WITHOUT ROWID;
|
|
17
|
+
|
|
18
|
+
ALTER TABLE editions ADD COLUMN edition_id TEXT;
|
|
19
|
+
ALTER TABLE editions ADD COLUMN source TEXT NOT NULL DEFAULT 'curated';
|
|
20
|
+
ALTER TABLE editions ADD COLUMN status TEXT NOT NULL DEFAULT 'active';
|
|
21
|
+
ALTER TABLE editions ADD COLUMN period TEXT;
|
|
22
|
+
ALTER TABLE editions ADD COLUMN title TEXT;
|
|
23
|
+
ALTER TABLE editions ADD COLUMN summary TEXT;
|
|
24
|
+
ALTER TABLE editions ADD COLUMN preferences_revision INTEGER NOT NULL DEFAULT 0;
|
|
25
|
+
UPDATE editions SET edition_id = lower(hex(randomblob(16))),
|
|
26
|
+
expires_at = COALESCE(expires_at, strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '+24 hours'));
|
|
27
|
+
|
|
28
|
+
-- A retained slot keeps revisions monotonic, including expiry and dismissal.
|
|
29
|
+
CREATE TABLE edition_history (
|
|
30
|
+
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
31
|
+
edition_id TEXT NOT NULL,
|
|
32
|
+
item_ids TEXT NOT NULL CHECK (json_valid(item_ids)),
|
|
33
|
+
revision INTEGER NOT NULL,
|
|
34
|
+
created_at TEXT NOT NULL,
|
|
35
|
+
PRIMARY KEY (user_id, edition_id)
|
|
36
|
+
) WITHOUT ROWID;
|
|
37
|
+
INSERT INTO edition_history SELECT user_id, edition_id, item_ids, revision, created_at FROM editions;
|
|
38
|
+
|
|
39
|
+
DROP TRIGGER trg_changes_edition_update;
|
|
40
|
+
CREATE TRIGGER trg_changes_edition_update AFTER UPDATE ON editions
|
|
41
|
+
BEGIN
|
|
42
|
+
INSERT INTO changes (user_id, entity, entity_id, op, seq, changed_at)
|
|
43
|
+
VALUES (NEW.user_id, 'edition', 1, CASE WHEN NEW.status = 'active' THEN 'upsert' ELSE 'delete' END,
|
|
44
|
+
(SELECT COALESCE(MAX(seq), 0) + 1 FROM changes WHERE user_id = NEW.user_id), NEW.updated_at)
|
|
45
|
+
ON CONFLICT (user_id, entity, entity_id) DO UPDATE SET op = excluded.op, seq = excluded.seq, changed_at = excluded.changed_at;
|
|
46
|
+
END;
|
|
47
|
+
|
|
48
|
+
CREATE TRIGGER trg_changes_native_preferences_insert AFTER INSERT ON native_preferences
|
|
49
|
+
BEGIN
|
|
50
|
+
INSERT INTO changes (user_id, entity, entity_id, op, seq, changed_at)
|
|
51
|
+
VALUES (NEW.user_id, 'native_preferences', 1, 'upsert',
|
|
52
|
+
(SELECT COALESCE(MAX(seq), 0) + 1 FROM changes WHERE user_id = NEW.user_id), NEW.updated_at)
|
|
53
|
+
ON CONFLICT (user_id, entity, entity_id) DO UPDATE SET op = excluded.op, seq = excluded.seq, changed_at = excluded.changed_at;
|
|
54
|
+
END;
|
|
55
|
+
|
|
56
|
+
CREATE TRIGGER trg_changes_native_preferences_update AFTER UPDATE ON native_preferences
|
|
57
|
+
BEGIN
|
|
58
|
+
INSERT INTO changes (user_id, entity, entity_id, op, seq, changed_at)
|
|
59
|
+
VALUES (NEW.user_id, 'native_preferences', 1, 'upsert',
|
|
60
|
+
(SELECT COALESCE(MAX(seq), 0) + 1 FROM changes WHERE user_id = NEW.user_id), NEW.updated_at)
|
|
61
|
+
ON CONFLICT (user_id, entity, entity_id) DO UPDATE SET op = excluded.op, seq = excluded.seq, changed_at = excluded.changed_at;
|
|
62
|
+
END;
|
|
63
|
+
|
|
64
|
+
-- Remove stale folder IDs atomically when any API deletes a folder. A new folder
|
|
65
|
+
-- with the same name has a new identity and must not inherit old preferences.
|
|
66
|
+
CREATE TRIGGER trg_native_preferences_folder_delete AFTER DELETE ON folders
|
|
67
|
+
WHEN EXISTS (SELECT 1 FROM native_preferences WHERE user_id = OLD.user_id)
|
|
68
|
+
BEGIN
|
|
69
|
+
UPDATE native_preferences SET
|
|
70
|
+
settings = json_set(settings,
|
|
71
|
+
'$.folderOrder', json(COALESCE((SELECT json_group_array(value) FROM json_each(settings, '$.folderOrder') WHERE value IS NULL OR value != OLD.id), '[]')),
|
|
72
|
+
'$.hiddenFolderIds', json(COALESCE((SELECT json_group_array(value) FROM json_each(settings, '$.hiddenFolderIds') WHERE value != OLD.id), '[]')),
|
|
73
|
+
'$.preferredFolderIds', json(COALESCE((SELECT json_group_array(value) FROM json_each(settings, '$.preferredFolderIds') WHERE value != OLD.id), '[]'))),
|
|
74
|
+
revision = revision + 1,
|
|
75
|
+
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
|
|
76
|
+
WHERE user_id = OLD.user_id AND (
|
|
77
|
+
EXISTS (SELECT 1 FROM json_each(settings, '$.folderOrder') WHERE value = OLD.id) OR
|
|
78
|
+
EXISTS (SELECT 1 FROM json_each(settings, '$.hiddenFolderIds') WHERE value = OLD.id) OR
|
|
79
|
+
EXISTS (SELECT 1 FROM json_each(settings, '$.preferredFolderIds') WHERE value = OLD.id)
|
|
80
|
+
);
|
|
81
|
+
END;
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
-- Migration 0024: Universal Inbox ("Save to FeedKeeper")
|
|
2
|
+
-- Creates a dedicated system feed for each user's saved web articles and inbound clippings.
|
|
3
|
+
|
|
4
|
+
-- Mark feeds that are internal system inboxes rather than polled RSS sources.
|
|
5
|
+
ALTER TABLE feeds ADD COLUMN is_system_inbox INTEGER NOT NULL DEFAULT 0;
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
-- Shared reader content lifecycle; no client- or AI-specific state.
|
|
2
|
+
ALTER TABLE items ADD COLUMN content_revision INTEGER NOT NULL DEFAULT 1;
|
|
3
|
+
ALTER TABLE items ADD COLUMN extraction_status TEXT NOT NULL DEFAULT 'pending';
|
|
4
|
+
ALTER TABLE items ADD COLUMN extraction_attempted_at TEXT;
|
|
5
|
+
ALTER TABLE items ADD COLUMN extraction_retry_at TEXT;
|
|
6
|
+
UPDATE items SET extraction_status = 'ready' WHERE full_content_html IS NOT NULL;
|
|
7
|
+
CREATE TABLE full_text_jobs (
|
|
8
|
+
item_id INTEGER PRIMARY KEY REFERENCES items(id) ON DELETE CASCADE,
|
|
9
|
+
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
10
|
+
priority INTEGER NOT NULL DEFAULT 0,
|
|
11
|
+
available_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
|
12
|
+
);
|
|
13
|
+
CREATE INDEX idx_full_text_jobs_due ON full_text_jobs(available_at, priority);
|
|
14
|
+
CREATE TRIGGER trg_content_revision AFTER UPDATE OF content_html, full_content_html, content_snippet, image_url, title, link ON items
|
|
15
|
+
WHEN OLD.content_html IS NOT NEW.content_html OR OLD.full_content_html IS NOT NEW.full_content_html
|
|
16
|
+
OR OLD.content_snippet IS NOT NEW.content_snippet OR OLD.image_url IS NOT NEW.image_url OR OLD.title IS NOT NEW.title OR OLD.link IS NOT NEW.link
|
|
17
|
+
BEGIN
|
|
18
|
+
UPDATE items SET content_revision = OLD.content_revision + 1,
|
|
19
|
+
extraction_status = CASE WHEN NEW.full_content_html IS NOT NULL THEN 'ready' WHEN OLD.extraction_status = 'feed_only' THEN 'pending' ELSE NEW.extraction_status END
|
|
20
|
+
WHERE id = NEW.id;
|
|
21
|
+
END;
|
|
22
|
+
CREATE TRIGGER trg_content_changes AFTER UPDATE OF content_revision, extraction_status, extraction_retry_at ON items
|
|
23
|
+
WHEN OLD.content_revision IS NOT NEW.content_revision OR OLD.extraction_status IS NOT NEW.extraction_status OR OLD.extraction_retry_at IS NOT NEW.extraction_retry_at
|
|
24
|
+
BEGIN
|
|
25
|
+
INSERT INTO changes(user_id, entity, entity_id, op, seq, changed_at)
|
|
26
|
+
SELECT owners.user_id, 'item_content', NEW.id, 'upsert',
|
|
27
|
+
(SELECT COALESCE(MAX(seq), 0) + 1 FROM changes WHERE user_id = owners.user_id), strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
|
|
28
|
+
FROM (SELECT user_id FROM subscriptions WHERE feed_id = NEW.feed_id
|
|
29
|
+
UNION SELECT user_id FROM item_bookmarks WHERE item_id = NEW.id
|
|
30
|
+
UNION SELECT user_id FROM item_notes WHERE item_id = NEW.id
|
|
31
|
+
UNION SELECT e.user_id FROM editions e, json_each(e.item_ids) selected
|
|
32
|
+
WHERE selected.value = NEW.id AND e.status = 'active' AND julianday(e.expires_at) > julianday('now')) owners WHERE 1
|
|
33
|
+
ON CONFLICT(user_id, entity, entity_id) DO UPDATE SET seq = excluded.seq, changed_at = excluded.changed_at, op = excluded.op;
|
|
34
|
+
END;
|
|
35
|
+
|
|
36
|
+
-- Metadata-only nested updates must not issue a second FTS delete for the new body.
|
|
37
|
+
DROP TRIGGER trg_items_fts_update;
|
|
38
|
+
CREATE TRIGGER trg_items_fts_update AFTER UPDATE OF title, content_snippet, content_html, full_content_html ON items BEGIN
|
|
39
|
+
INSERT INTO items_fts(items_fts, rowid, title, content_snippet, content_html, full_content_html)
|
|
40
|
+
VALUES ('delete', old.id, old.title, old.content_snippet, old.content_html, old.full_content_html);
|
|
41
|
+
INSERT INTO items_fts(rowid, title, content_snippet, content_html, full_content_html)
|
|
42
|
+
VALUES (new.id, new.title, new.content_snippet, new.content_html, new.full_content_html);
|
|
43
|
+
END;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
-- Migration 0027: OAuth 2.1 for the remote MCP endpoint (ChatGPT, Claude and other connectors).
|
|
2
|
+
-- OAuth grants live apart from personal_access_tokens: they expire, rotate and only work on /mcp.
|
|
3
|
+
|
|
4
|
+
CREATE TABLE oauth_clients (
|
|
5
|
+
client_id TEXT PRIMARY KEY,
|
|
6
|
+
client_name TEXT NOT NULL,
|
|
7
|
+
redirect_uris TEXT NOT NULL,
|
|
8
|
+
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
|
9
|
+
);
|
|
10
|
+
|
|
11
|
+
-- Single-use authorization codes (60 seconds), stored as hashes.
|
|
12
|
+
CREATE TABLE oauth_codes (
|
|
13
|
+
code_hash TEXT PRIMARY KEY,
|
|
14
|
+
client_id TEXT NOT NULL REFERENCES oauth_clients(client_id) ON DELETE CASCADE,
|
|
15
|
+
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
16
|
+
redirect_uri TEXT NOT NULL,
|
|
17
|
+
code_challenge TEXT NOT NULL,
|
|
18
|
+
scope TEXT NOT NULL CHECK (scope IN ('read', 'write')),
|
|
19
|
+
expires_at TEXT NOT NULL
|
|
20
|
+
);
|
|
21
|
+
|
|
22
|
+
-- One row per connected app: the current access token and its rotating refresh token.
|
|
23
|
+
CREATE TABLE oauth_grants (
|
|
24
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
25
|
+
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
26
|
+
client_id TEXT NOT NULL REFERENCES oauth_clients(client_id) ON DELETE CASCADE,
|
|
27
|
+
scope TEXT NOT NULL CHECK (scope IN ('read', 'write')),
|
|
28
|
+
access_hash TEXT NOT NULL UNIQUE,
|
|
29
|
+
access_expires_at TEXT NOT NULL,
|
|
30
|
+
refresh_hash TEXT NOT NULL UNIQUE,
|
|
31
|
+
-- The refresh token this one replaced: presenting it again means it leaked, so the grant is revoked.
|
|
32
|
+
previous_refresh_hash TEXT,
|
|
33
|
+
refresh_expires_at TEXT NOT NULL,
|
|
34
|
+
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
|
35
|
+
last_used_at TEXT
|
|
36
|
+
);
|
|
37
|
+
CREATE INDEX idx_oauth_grants_user ON oauth_grants(user_id);
|
|
38
|
+
CREATE INDEX idx_oauth_grants_client ON oauth_grants(client_id);
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
-- Account security is independent of the client used to sign in.
|
|
2
|
+
CREATE TABLE account_security (
|
|
3
|
+
user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
|
|
4
|
+
user_handle TEXT NOT NULL UNIQUE,
|
|
5
|
+
version INTEGER NOT NULL DEFAULT 0,
|
|
6
|
+
totp_secret TEXT,
|
|
7
|
+
totp_last_step INTEGER NOT NULL DEFAULT -1
|
|
8
|
+
);
|
|
9
|
+
CREATE TABLE passkeys (
|
|
10
|
+
id TEXT PRIMARY KEY,
|
|
11
|
+
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
12
|
+
public_key BLOB NOT NULL,
|
|
13
|
+
counter INTEGER NOT NULL DEFAULT 0,
|
|
14
|
+
transports TEXT NOT NULL,
|
|
15
|
+
name TEXT NOT NULL,
|
|
16
|
+
created_at TEXT NOT NULL,
|
|
17
|
+
last_used_at TEXT
|
|
18
|
+
);
|
|
19
|
+
CREATE INDEX passkeys_user ON passkeys(user_id);
|
|
20
|
+
CREATE TABLE security_challenges (
|
|
21
|
+
id_hash TEXT PRIMARY KEY,
|
|
22
|
+
purpose TEXT NOT NULL,
|
|
23
|
+
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
|
|
24
|
+
binding TEXT NOT NULL,
|
|
25
|
+
version INTEGER NOT NULL,
|
|
26
|
+
payload TEXT NOT NULL,
|
|
27
|
+
expires_at INTEGER NOT NULL,
|
|
28
|
+
attempts INTEGER NOT NULL DEFAULT 0
|
|
29
|
+
);
|
|
30
|
+
CREATE INDEX security_challenges_expiry ON security_challenges(expires_at);
|
|
31
|
+
CREATE TABLE recovery_codes (
|
|
32
|
+
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
33
|
+
code_hash TEXT NOT NULL,
|
|
34
|
+
PRIMARY KEY (user_id, code_hash)
|
|
35
|
+
);
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
-- One application process owns polling. Pending work is durable; running locks are memory-only.
|
|
2
|
+
CREATE TABLE feed_poll_jobs (
|
|
3
|
+
feed_id INTEGER PRIMARY KEY REFERENCES feeds(id) ON DELETE CASCADE,
|
|
4
|
+
host TEXT NOT NULL,
|
|
5
|
+
enqueued_at INTEGER NOT NULL,
|
|
6
|
+
available_at INTEGER NOT NULL,
|
|
7
|
+
attempts INTEGER NOT NULL DEFAULT 0
|
|
8
|
+
);
|
|
9
|
+
CREATE INDEX idx_feed_poll_jobs_due ON feed_poll_jobs(available_at, enqueued_at, feed_id);
|
|
10
|
+
CREATE INDEX idx_feed_poll_jobs_host ON feed_poll_jobs(host);
|
|
11
|
+
CREATE TABLE feed_poll_hosts (
|
|
12
|
+
host TEXT PRIMARY KEY,
|
|
13
|
+
retry_at INTEGER NOT NULL
|
|
14
|
+
) WITHOUT ROWID;
|
|
15
|
+
CREATE INDEX idx_feed_poll_jobs_fifo ON feed_poll_jobs(enqueued_at, feed_id);
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
-- One event per committed feed update, not one waiting task per recipient.
|
|
2
|
+
-- Cursors are durable; one application process dispatches device deliveries.
|
|
3
|
+
CREATE TABLE push_outbox (
|
|
4
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
5
|
+
feed_id INTEGER NOT NULL REFERENCES feeds(id) ON DELETE CASCADE,
|
|
6
|
+
max_item_id INTEGER NOT NULL,
|
|
7
|
+
item_count INTEGER NOT NULL,
|
|
8
|
+
subscription_ceiling INTEGER NOT NULL,
|
|
9
|
+
subscription_cursor INTEGER NOT NULL DEFAULT 0,
|
|
10
|
+
current_subscription INTEGER NOT NULL DEFAULT 0,
|
|
11
|
+
device_cursor INTEGER NOT NULL DEFAULT 0,
|
|
12
|
+
device_ceiling INTEGER NOT NULL DEFAULT 0,
|
|
13
|
+
target_device INTEGER NOT NULL DEFAULT 0,
|
|
14
|
+
attempts INTEGER NOT NULL DEFAULT 0,
|
|
15
|
+
available_at INTEGER NOT NULL,
|
|
16
|
+
created_at INTEGER NOT NULL
|
|
17
|
+
);
|
|
18
|
+
CREATE INDEX idx_push_outbox_due ON push_outbox(available_at, id);
|
|
19
|
+
CREATE INDEX idx_push_outbox_feed ON push_outbox(feed_id, id);
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { resolve } from "node:path";
|
|
4
|
+
import { HELP, UsageError, parseArgs, prepareEnvironment } from "./cli.js";
|
|
5
|
+
import { APP_VERSION } from "./version.js";
|
|
6
|
+
// Everything that reads the settings is imported only after the environment is complete: the configuration
|
|
7
|
+
// module validates SESSION_SECRET the moment it loads.
|
|
8
|
+
async function main() {
|
|
9
|
+
const options = parseArgs(process.argv.slice(2));
|
|
10
|
+
if (options.action === "help")
|
|
11
|
+
return void console.log(HELP);
|
|
12
|
+
if (options.action === "version")
|
|
13
|
+
return void console.log(APP_VERSION);
|
|
14
|
+
const envFile = resolve(process.cwd(), ".env");
|
|
15
|
+
if (existsSync(envFile))
|
|
16
|
+
process.loadEnvFile(envFile);
|
|
17
|
+
const prepared = prepareEnvironment(options, process.env);
|
|
18
|
+
if (options.action === "backup" || options.action === "restore") {
|
|
19
|
+
process.argv.splice(2, process.argv.length, ...options.forwarded);
|
|
20
|
+
await import("./backup-cli.js");
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
console.log(`[feedkeeper] version ${APP_VERSION}, data in ${prepared.dataDir}`);
|
|
24
|
+
if (prepared.secretCreated)
|
|
25
|
+
console.log(`[feedkeeper] created a session secret at ${prepared.secretPath}; keep it with your backups`);
|
|
26
|
+
await import("./server.js");
|
|
27
|
+
}
|
|
28
|
+
main().catch((error) => {
|
|
29
|
+
if (error instanceof UsageError) {
|
|
30
|
+
console.error(`feedkeeper: ${error.message}\nRun "feedkeeper --help" for usage.`);
|
|
31
|
+
process.exitCode = 2;
|
|
32
|
+
}
|
|
33
|
+
else {
|
|
34
|
+
console.error(error instanceof Error ? error.message : error);
|
|
35
|
+
process.exitCode = 1;
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
//# sourceMappingURL=feedkeeper.js.map
|
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { mkdirSync, readdirSync, unlinkSync } from "node:fs";
|
|
3
|
+
import { join, resolve } from "node:path";
|
|
4
|
+
import { link, readFile, rename, unlink, writeFile } from "node:fs/promises";
|
|
5
|
+
import { config } from "../config.js";
|
|
6
|
+
import { db } from "../db/index.js";
|
|
7
|
+
import { fetchImage } from "./fetcher.js";
|
|
8
|
+
import { loadFullText } from "./fullText.js";
|
|
9
|
+
import { canAccessItem, findItemById, findItemForUser } from "./repository.js";
|
|
10
|
+
import { decodeEntities } from "./text.js";
|
|
11
|
+
import { ImageCache, ImageCacheBusyError } from "./imageCache.js";
|
|
12
|
+
import { detectImageType } from "./imageTypes.js";
|
|
13
|
+
export { detectImageType } from "./imageTypes.js";
|
|
14
|
+
const MAX_IMAGES_PER_ARTICLE = 30;
|
|
15
|
+
const MAX_IMAGE_BYTES = 5 * 1024 * 1024;
|
|
16
|
+
const FILE_NAME = /^\d+-[0-9a-f]{16}\.(jpg|png|webp|gif|avif)$/;
|
|
17
|
+
let imageCache;
|
|
18
|
+
let imageCacheTimer;
|
|
19
|
+
let imageCacheStopping = false;
|
|
20
|
+
let imageCachePruning = false;
|
|
21
|
+
const archiveWrites = new Set();
|
|
22
|
+
async function storeArchivedImage(destination, image) {
|
|
23
|
+
const temporary = `${destination}.tmp`;
|
|
24
|
+
archiveWrites.add(temporary);
|
|
25
|
+
try {
|
|
26
|
+
await unlink(temporary).catch((error) => { if (error.code !== "ENOENT")
|
|
27
|
+
throw error; });
|
|
28
|
+
if (image.path)
|
|
29
|
+
await link(image.path, temporary).catch(() => writeFile(temporary, image.buffer, { mode: 0o600 }));
|
|
30
|
+
else
|
|
31
|
+
await writeFile(temporary, image.buffer, { mode: 0o600 });
|
|
32
|
+
await rename(temporary, destination);
|
|
33
|
+
}
|
|
34
|
+
finally {
|
|
35
|
+
archiveWrites.delete(temporary);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
function sharedImages() {
|
|
39
|
+
if (!imageCache) {
|
|
40
|
+
imageCache = new ImageCache(config.imageCache);
|
|
41
|
+
imageCacheTimer = setInterval(() => {
|
|
42
|
+
if (imageCachePruning)
|
|
43
|
+
return;
|
|
44
|
+
imageCachePruning = true;
|
|
45
|
+
void imageCache.prune().catch(() => undefined).then(() => {
|
|
46
|
+
console.log(JSON.stringify({ event: "image_cache", ...imageCache.metrics() }));
|
|
47
|
+
}).finally(() => { imageCachePruning = false; });
|
|
48
|
+
}, 60_000);
|
|
49
|
+
imageCacheTimer.unref();
|
|
50
|
+
}
|
|
51
|
+
return imageCache;
|
|
52
|
+
}
|
|
53
|
+
export async function stopImageCache() {
|
|
54
|
+
imageCacheStopping = true;
|
|
55
|
+
if (imageCacheTimer)
|
|
56
|
+
clearInterval(imageCacheTimer);
|
|
57
|
+
await imageCache?.close();
|
|
58
|
+
}
|
|
59
|
+
function sharedImage(item, url) {
|
|
60
|
+
if (imageCacheStopping)
|
|
61
|
+
return Promise.reject(new ImageCacheBusyError());
|
|
62
|
+
const archived = db.prepare("SELECT file_name FROM archived_images WHERE item_id = ? AND source_url = ?").get(item.id, url);
|
|
63
|
+
// An old saved snapshot must not replace a newer article's image at the same URL.
|
|
64
|
+
const scope = archived ? `${item.feed_id}:archive:${item.id}` : item.feed_id;
|
|
65
|
+
return sharedImages().get(scope, url, async (signal) => {
|
|
66
|
+
if (archived) {
|
|
67
|
+
const path = join(archiveDir(), archived.file_name);
|
|
68
|
+
try {
|
|
69
|
+
const buffer = await readFile(path);
|
|
70
|
+
if (buffer.length <= MAX_IMAGE_BYTES && detectImageType(buffer))
|
|
71
|
+
return { buffer, path };
|
|
72
|
+
}
|
|
73
|
+
catch { /* Recover a missing archived file from its source. */ }
|
|
74
|
+
}
|
|
75
|
+
return fetchImage(url, MAX_IMAGE_BYTES, signal);
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
function archiveDir() {
|
|
79
|
+
return resolve(config.archivePath);
|
|
80
|
+
}
|
|
81
|
+
function srcAttribute(tag) {
|
|
82
|
+
const match = /\ssrc\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))/i.exec(tag);
|
|
83
|
+
if (!match)
|
|
84
|
+
return null;
|
|
85
|
+
const value = match[1] ?? match[2] ?? match[3] ?? "";
|
|
86
|
+
const start = match.index + match[0].indexOf(value, match[0].indexOf("="));
|
|
87
|
+
return { value, start, end: start + value.length };
|
|
88
|
+
}
|
|
89
|
+
function resolveImageUrl(raw, baseUrl) {
|
|
90
|
+
try {
|
|
91
|
+
const url = new URL(decodeEntities(raw.trim()), baseUrl ?? undefined);
|
|
92
|
+
return url.protocol === "https:" || url.protocol === "http:" ? url.toString() : null;
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
/** Image URLs an article shows, in order, without tracking pixels. */
|
|
99
|
+
export function articleImageUrls(html, baseUrl) {
|
|
100
|
+
const urls = new Set();
|
|
101
|
+
for (const [tag] of html.matchAll(/<img\b[^>]*>/gi)) {
|
|
102
|
+
if (/\swidth\s*=\s*["']?1["'\s>]/i.test(tag) && /\sheight\s*=\s*["']?1["'\s>]/i.test(tag))
|
|
103
|
+
continue;
|
|
104
|
+
const src = srcAttribute(tag);
|
|
105
|
+
const url = src && resolveImageUrl(src.value, baseUrl);
|
|
106
|
+
if (url)
|
|
107
|
+
urls.add(url);
|
|
108
|
+
}
|
|
109
|
+
return [...urls];
|
|
110
|
+
}
|
|
111
|
+
/** Replace the `src` of every image for which `replacement` knows a new address. */
|
|
112
|
+
function rewriteImageSources(html, baseUrl, replacement) {
|
|
113
|
+
return html.replace(/<img\b[^>]*>/gi, (tag) => {
|
|
114
|
+
const src = srcAttribute(tag);
|
|
115
|
+
const url = src && resolveImageUrl(src.value, baseUrl);
|
|
116
|
+
const next = url ? replacement(url) : undefined;
|
|
117
|
+
return src && next ? `${tag.slice(0, src.start)}${next}${tag.slice(src.end)}` : tag;
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
/** Point archived images in article HTML at their local copies under `origin`. */
|
|
121
|
+
export function rewriteArchivedImages(html, baseUrl, images, origin, apiBase = "/api") {
|
|
122
|
+
if (images.size === 0)
|
|
123
|
+
return html;
|
|
124
|
+
return rewriteImageSources(html, baseUrl, (url) => {
|
|
125
|
+
const id = images.get(url);
|
|
126
|
+
return id ? archivedImageUrl(origin, id, apiBase) : undefined;
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
/** Absolute, so the web app treats archived images like any other image URL. */
|
|
130
|
+
export function archivedImageUrl(origin, imageId, apiBase = "/api") {
|
|
131
|
+
return `${origin.replace(/\/$/, "")}${apiBase}/archive/images/${imageId}`;
|
|
132
|
+
}
|
|
133
|
+
function hasBookmark(itemId) {
|
|
134
|
+
return db.prepare("SELECT user_id FROM item_bookmarks WHERE item_id = ? LIMIT 1").get(itemId)?.user_id;
|
|
135
|
+
}
|
|
136
|
+
/** Keep a saved article readable for good: fetch its full text and store its images locally. */
|
|
137
|
+
export async function archiveItem(itemId) {
|
|
138
|
+
const userId = hasBookmark(itemId);
|
|
139
|
+
if (userId === undefined)
|
|
140
|
+
return;
|
|
141
|
+
// Respects per-feed full-text settings and consent walls; falls back to the feed content.
|
|
142
|
+
await loadFullText(userId, itemId).catch(() => undefined);
|
|
143
|
+
const item = findItemById(itemId);
|
|
144
|
+
if (!item)
|
|
145
|
+
return;
|
|
146
|
+
const html = item.full_content_html ?? item.content_html ?? "";
|
|
147
|
+
const heroUrl = item.image_url ? resolveImageUrl(item.image_url, item.link) : null;
|
|
148
|
+
const urls = [...new Set([...(heroUrl ? [heroUrl] : []), ...articleImageUrls(html, item.link)])].slice(0, MAX_IMAGES_PER_ARTICLE);
|
|
149
|
+
const known = new Set(db.prepare("SELECT source_url FROM archived_images WHERE item_id = ?").all(itemId).map((row) => row.source_url));
|
|
150
|
+
const insert = db.prepare("INSERT INTO archived_images (item_id, source_url, file_name, mime_type, byte_size) VALUES (?, ?, ?, ?, ?) ON CONFLICT (item_id, source_url) DO NOTHING");
|
|
151
|
+
mkdirSync(archiveDir(), { recursive: true });
|
|
152
|
+
for (const url of urls) {
|
|
153
|
+
if (imageCacheStopping)
|
|
154
|
+
return;
|
|
155
|
+
if (known.has(url))
|
|
156
|
+
continue;
|
|
157
|
+
try {
|
|
158
|
+
const image = await sharedImage(item, url);
|
|
159
|
+
if (image === "unsupported")
|
|
160
|
+
continue;
|
|
161
|
+
const { buffer } = image;
|
|
162
|
+
const type = detectImageType(buffer);
|
|
163
|
+
if (!type)
|
|
164
|
+
continue;
|
|
165
|
+
const fileName = `${itemId}-${createHash("sha256").update(url).digest("hex").slice(0, 16)}.${type.ext}`;
|
|
166
|
+
const destination = join(archiveDir(), fileName);
|
|
167
|
+
await storeArchivedImage(destination, image);
|
|
168
|
+
// The last bookmark or article may have disappeared while the image was loading.
|
|
169
|
+
if (!hasBookmark(itemId) || !findItemById(itemId)) {
|
|
170
|
+
unlinkSync(destination);
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
insert.run(itemId, url, fileName, type.mime, buffer.length);
|
|
174
|
+
}
|
|
175
|
+
catch (error) {
|
|
176
|
+
// Leave the durable bookmark pending so startup can finish it later.
|
|
177
|
+
if (error instanceof ImageCacheBusyError || imageCacheStopping)
|
|
178
|
+
return;
|
|
179
|
+
// A missing image should not stop the rest of the article from being archived.
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
if (!imageCacheStopping)
|
|
183
|
+
db.prepare("UPDATE items SET archived_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?").run(itemId);
|
|
184
|
+
}
|
|
185
|
+
const queue = [];
|
|
186
|
+
let draining = false;
|
|
187
|
+
async function drain() {
|
|
188
|
+
if (draining)
|
|
189
|
+
return;
|
|
190
|
+
draining = true;
|
|
191
|
+
try {
|
|
192
|
+
while (queue.length > 0) {
|
|
193
|
+
const itemId = queue.shift();
|
|
194
|
+
await archiveItem(itemId).catch((error) => console.warn(`[archive] item ${itemId}:`, error));
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
finally {
|
|
198
|
+
draining = false;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
/** Archive in the background, one article at a time, so saving stays instant. */
|
|
202
|
+
export function scheduleArchive(itemId) {
|
|
203
|
+
if (!queue.includes(itemId))
|
|
204
|
+
queue.push(itemId);
|
|
205
|
+
void drain();
|
|
206
|
+
}
|
|
207
|
+
/** Catch up on saved articles that were never archived, e.g. saved before this feature. */
|
|
208
|
+
export function scheduleMissingArchives() {
|
|
209
|
+
const rows = db.prepare("SELECT DISTINCT i.id FROM items i JOIN item_bookmarks b ON b.item_id = i.id WHERE i.archived_at IS NULL ORDER BY i.id").all();
|
|
210
|
+
rows.forEach((row) => scheduleArchive(row.id));
|
|
211
|
+
return rows.length;
|
|
212
|
+
}
|
|
213
|
+
/** Wait until queued archiving is done (used by tests and shutdown). */
|
|
214
|
+
export async function archiveIdle() {
|
|
215
|
+
while (draining || queue.length > 0)
|
|
216
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
217
|
+
}
|
|
218
|
+
/** Delete archived images of articles nobody has saved anymore, plus stray files. */
|
|
219
|
+
export function pruneArchive() {
|
|
220
|
+
const stale = db.prepare("SELECT a.id, a.item_id, a.file_name FROM archived_images a WHERE NOT EXISTS (SELECT 1 FROM item_bookmarks b WHERE b.item_id = a.item_id)").all();
|
|
221
|
+
db.transaction(() => {
|
|
222
|
+
for (const row of stale) {
|
|
223
|
+
db.prepare("DELETE FROM archived_images WHERE id = ?").run(row.id);
|
|
224
|
+
db.prepare("UPDATE items SET archived_at = NULL WHERE id = ?").run(row.item_id);
|
|
225
|
+
}
|
|
226
|
+
db.prepare("UPDATE items SET archived_at = NULL WHERE archived_at IS NOT NULL AND id NOT IN (SELECT item_id FROM item_bookmarks)").run();
|
|
227
|
+
})();
|
|
228
|
+
let removed = 0;
|
|
229
|
+
let files;
|
|
230
|
+
try {
|
|
231
|
+
files = readdirSync(archiveDir());
|
|
232
|
+
}
|
|
233
|
+
catch {
|
|
234
|
+
return 0;
|
|
235
|
+
}
|
|
236
|
+
const kept = new Set(db.prepare("SELECT file_name FROM archived_images").all().map((row) => row.file_name));
|
|
237
|
+
for (const file of files) {
|
|
238
|
+
if (file.endsWith(".tmp") && FILE_NAME.test(file.slice(0, -4)) && !archiveWrites.has(join(archiveDir(), file))) {
|
|
239
|
+
try {
|
|
240
|
+
unlinkSync(join(archiveDir(), file));
|
|
241
|
+
removed++;
|
|
242
|
+
}
|
|
243
|
+
catch { /* Already gone. */ }
|
|
244
|
+
continue;
|
|
245
|
+
}
|
|
246
|
+
if (!FILE_NAME.test(file) || kept.has(file))
|
|
247
|
+
continue;
|
|
248
|
+
try {
|
|
249
|
+
unlinkSync(join(archiveDir(), file));
|
|
250
|
+
removed++;
|
|
251
|
+
}
|
|
252
|
+
catch {
|
|
253
|
+
// Already gone.
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
return removed;
|
|
257
|
+
}
|
|
258
|
+
/** Local copies for the given items, keyed by original URL. */
|
|
259
|
+
export function archivedImagesFor(itemIds) {
|
|
260
|
+
const result = new Map();
|
|
261
|
+
if (itemIds.length === 0)
|
|
262
|
+
return result;
|
|
263
|
+
const rows = db.prepare(`SELECT id, item_id, source_url FROM archived_images WHERE item_id IN (${itemIds.map(() => "?").join(",")})`).all(...itemIds);
|
|
264
|
+
for (const row of rows) {
|
|
265
|
+
if (!result.has(row.item_id))
|
|
266
|
+
result.set(row.item_id, new Map());
|
|
267
|
+
result.get(row.item_id).set(row.source_url, row.id);
|
|
268
|
+
}
|
|
269
|
+
return result;
|
|
270
|
+
}
|
|
271
|
+
/** An archived image the user may see (they subscribe to or saved its article). */
|
|
272
|
+
export function findArchivedImage(userId, imageId) {
|
|
273
|
+
const row = db.prepare("SELECT item_id, file_name, mime_type FROM archived_images WHERE id = ?").get(imageId);
|
|
274
|
+
if (!row || !canAccessItem(userId, row.item_id))
|
|
275
|
+
return null;
|
|
276
|
+
return { path: join(archiveDir(), row.file_name), mime: row.mime_type };
|
|
277
|
+
}
|
|
278
|
+
/** Swap image URLs of archived articles for their local copies before sending them to the browser. */
|
|
279
|
+
export function withArchivedImages(items, origin, apiBase = "/api") {
|
|
280
|
+
const archived = archivedImagesFor(items.map((item) => item.id));
|
|
281
|
+
return items.map((item) => {
|
|
282
|
+
const images = archived.get(item.id);
|
|
283
|
+
if (!images)
|
|
284
|
+
return item;
|
|
285
|
+
const hero = item.image_url ? resolveImageUrl(item.image_url, item.link) : null;
|
|
286
|
+
return {
|
|
287
|
+
...item,
|
|
288
|
+
image_url: hero && images.has(hero) ? archivedImageUrl(origin, images.get(hero), apiBase) : item.image_url,
|
|
289
|
+
content_html: item.content_html ? rewriteArchivedImages(item.content_html, item.link, images, origin, apiBase) : item.content_html,
|
|
290
|
+
full_content_html: item.full_content_html ? rewriteArchivedImages(item.full_content_html, item.link, images, origin, apiBase) : item.full_content_html,
|
|
291
|
+
};
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
/** Every image address an article shows: the lead image first, then those in the text. */
|
|
295
|
+
export function itemImageUrls(item) {
|
|
296
|
+
const hero = item.image_url ? resolveImageUrl(item.image_url, item.link) : null;
|
|
297
|
+
return [...new Set([...(hero ? [hero] : []), ...articleImageUrls(item.full_content_html ?? "", item.link), ...articleImageUrls(item.content_html ?? "", item.link)])];
|
|
298
|
+
}
|
|
299
|
+
function proxiedImageUrl(origin, itemId, imageUrl, apiBase = "/api") {
|
|
300
|
+
return `${origin.replace(/\/$/, "")}${apiBase}/items/${itemId}/image?src=${encodeURIComponent(imageUrl).replace(/[!'()*]/g, (char) => `%${char.charCodeAt(0).toString(16).toUpperCase()}`)}`;
|
|
301
|
+
}
|
|
302
|
+
/**
|
|
303
|
+
* For offline copies: point the first images of articles that are not archived at an endpoint
|
|
304
|
+
* of this server, so the browser can store them without cross-origin requests.
|
|
305
|
+
*/
|
|
306
|
+
export function withProxiedImages(items, origin, apiBase = "/api") {
|
|
307
|
+
const own = `${origin.replace(/\/$/, "")}/api/`;
|
|
308
|
+
return items.map((item) => {
|
|
309
|
+
const urls = new Set(itemImageUrls(item).filter((url) => !url.startsWith(own)));
|
|
310
|
+
if (urls.size === 0)
|
|
311
|
+
return item;
|
|
312
|
+
const replace = (url) => (urls.has(url) ? proxiedImageUrl(origin, item.id, url, apiBase) : undefined);
|
|
313
|
+
const hero = item.image_url ? resolveImageUrl(item.image_url, item.link) : null;
|
|
314
|
+
return {
|
|
315
|
+
...item,
|
|
316
|
+
image_url: hero && urls.has(hero) ? proxiedImageUrl(origin, item.id, hero, apiBase) : item.image_url,
|
|
317
|
+
content_html: item.content_html ? rewriteImageSources(item.content_html, item.link, replace) : item.content_html,
|
|
318
|
+
full_content_html: item.full_content_html ? rewriteImageSources(item.full_content_html, item.link, replace) : item.full_content_html,
|
|
319
|
+
};
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
/** Download one of an article's own images for the offline copy, or null when it is not one of them. */
|
|
323
|
+
export async function fetchItemImage(userId, itemId, imageUrl) {
|
|
324
|
+
const item = findItemForUser(userId, itemId);
|
|
325
|
+
if (!item || !itemImageUrls(item).includes(imageUrl))
|
|
326
|
+
return "forbidden";
|
|
327
|
+
const image = await sharedImage(item, imageUrl);
|
|
328
|
+
// Access or the article's image list can change while a shared download is running.
|
|
329
|
+
const current = findItemForUser(userId, itemId);
|
|
330
|
+
if (!current || !itemImageUrls(current).includes(imageUrl))
|
|
331
|
+
return "forbidden";
|
|
332
|
+
return image;
|
|
333
|
+
}
|
|
334
|
+
//# sourceMappingURL=archive.js.map
|