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,129 @@
|
|
|
1
|
+
import { Router } from "express";
|
|
2
|
+
import rateLimit from "express-rate-limit";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { requireSession } from "../auth/middleware.js";
|
|
5
|
+
import { listItemsForUser, markAllRead, markItemRead, markItemUnread, bookmarkItem, unbookmarkItem, findItemById, } from "../feeds/repository.js";
|
|
6
|
+
import { loadFullText } from "../feeds/fullText.js";
|
|
7
|
+
import { fetchItemImage, pruneArchive, scheduleArchive, withArchivedImages, withProxiedImages } from "../feeds/archive.js";
|
|
8
|
+
import { ImageCacheBusyError } from "../feeds/imageCache.js";
|
|
9
|
+
export const FULL_TEXT_STATUS = {
|
|
10
|
+
capability_not_available: 403, paywall: 422, bot_blocked: 422, timeout: 504, partial: 422,
|
|
11
|
+
item_not_found: 404,
|
|
12
|
+
item_has_no_link: 400,
|
|
13
|
+
full_text_disabled: 409,
|
|
14
|
+
consent_wall: 422,
|
|
15
|
+
could_not_extract_content: 422,
|
|
16
|
+
extraction_failed: 502,
|
|
17
|
+
};
|
|
18
|
+
export const itemsRouter = Router();
|
|
19
|
+
// The origin the browser used (honours TRUST_PROXY), so archive URLs work behind proxies and in development.
|
|
20
|
+
function requestOrigin(req) {
|
|
21
|
+
return `${req.protocol}://${req.get("host")}`;
|
|
22
|
+
}
|
|
23
|
+
itemsRouter.use(requireSession);
|
|
24
|
+
const listQuerySchema = z.object({
|
|
25
|
+
feedId: z.coerce.number().int().positive().optional(),
|
|
26
|
+
folderId: z.coerce.number().int().positive().optional(),
|
|
27
|
+
unreadOnly: z.enum(["true", "false"]).transform((value) => value === "true").optional(),
|
|
28
|
+
bookmarkedOnly: z.enum(["true", "false"]).transform((value) => value === "true").optional(),
|
|
29
|
+
includeMuted: z.enum(["true", "false"]).transform((value) => value === "true").optional(),
|
|
30
|
+
search: z.string().max(200).optional(),
|
|
31
|
+
limit: z.coerce.number().int().positive().max(200).optional(),
|
|
32
|
+
offset: z.coerce.number().int().min(0).max(1_000_000).optional(),
|
|
33
|
+
offline: z.enum(["true", "false"]).transform((value) => value === "true").optional(),
|
|
34
|
+
});
|
|
35
|
+
itemsRouter.get("/", (req, res) => {
|
|
36
|
+
const parsed = listQuerySchema.safeParse(req.query);
|
|
37
|
+
if (!parsed.success) {
|
|
38
|
+
res.status(400).json({ error: "invalid_input", details: parsed.error.flatten() });
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
// Saved articles are served with their archived images instead of the originals.
|
|
42
|
+
const origin = requestOrigin(req);
|
|
43
|
+
const archived = withArchivedImages(listItemsForUser(req.user.id, parsed.data), origin);
|
|
44
|
+
// Offline copies also get the images of articles that are not archived, through this server.
|
|
45
|
+
const items = parsed.data.offline ? withProxiedImages(archived, origin) : archived;
|
|
46
|
+
res.json(items.map((item) => ({
|
|
47
|
+
...item,
|
|
48
|
+
read: Boolean(item.read),
|
|
49
|
+
bookmarked: Boolean(item.bookmarked),
|
|
50
|
+
})));
|
|
51
|
+
});
|
|
52
|
+
const imageLimiter = rateLimit({ windowMs: 60 * 1000, limit: 600, standardHeaders: true, legacyHeaders: false });
|
|
53
|
+
// Lets the web app keep an article's images for offline reading; only images the article itself shows are served.
|
|
54
|
+
itemsRouter.get("/:itemId/image", imageLimiter, async (req, res) => {
|
|
55
|
+
const src = typeof req.query.src === "string" ? req.query.src : "";
|
|
56
|
+
try {
|
|
57
|
+
const image = await fetchItemImage(req.user.id, Number(req.params.itemId), src);
|
|
58
|
+
if (image === "forbidden") {
|
|
59
|
+
res.status(404).json({ error: "not_found" });
|
|
60
|
+
}
|
|
61
|
+
else if (image === "unsupported") {
|
|
62
|
+
res.status(415).json({ error: "unsupported_image" });
|
|
63
|
+
}
|
|
64
|
+
else {
|
|
65
|
+
res.setHeader("Cache-Control", "private, max-age=31536000, immutable");
|
|
66
|
+
res.type(image.mime).send(image.buffer);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
catch (error) {
|
|
70
|
+
if (error instanceof ImageCacheBusyError) {
|
|
71
|
+
res.setHeader("Retry-After", "1");
|
|
72
|
+
res.status(503).json({ error: "image_busy" });
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
res.status(502).json({ error: "image_unavailable" });
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
const markAllReadSchema = z.object({
|
|
79
|
+
feedId: z.number().int().positive().optional(),
|
|
80
|
+
folderId: z.number().int().positive().optional(),
|
|
81
|
+
});
|
|
82
|
+
itemsRouter.post("/mark-all-read", (req, res) => {
|
|
83
|
+
const parsed = markAllReadSchema.safeParse(req.body ?? {});
|
|
84
|
+
if (!parsed.success) {
|
|
85
|
+
res.status(400).json({ error: "invalid_input" });
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
const count = markAllRead(req.user.id, { feedId: parsed.data.feedId, folderId: parsed.data.folderId });
|
|
89
|
+
res.json({ marked: count });
|
|
90
|
+
});
|
|
91
|
+
itemsRouter.post("/:itemId/read", (req, res) => {
|
|
92
|
+
markItemRead(req.user.id, Number(req.params.itemId));
|
|
93
|
+
res.status(204).end();
|
|
94
|
+
});
|
|
95
|
+
itemsRouter.post("/:itemId/unread", (req, res) => {
|
|
96
|
+
markItemUnread(req.user.id, Number(req.params.itemId));
|
|
97
|
+
res.status(204).end();
|
|
98
|
+
});
|
|
99
|
+
itemsRouter.post("/:itemId/bookmark", (req, res) => {
|
|
100
|
+
const itemId = Number(req.params.itemId);
|
|
101
|
+
if (bookmarkItem(req.user.id, itemId) > 0)
|
|
102
|
+
scheduleArchive(itemId);
|
|
103
|
+
res.status(204).end();
|
|
104
|
+
});
|
|
105
|
+
itemsRouter.delete("/:itemId/bookmark", (req, res) => {
|
|
106
|
+
if (unbookmarkItem(req.user.id, Number(req.params.itemId)) > 0)
|
|
107
|
+
pruneArchive();
|
|
108
|
+
res.status(204).end();
|
|
109
|
+
});
|
|
110
|
+
itemsRouter.post("/:itemId/extract-content", async (req, res) => {
|
|
111
|
+
const itemId = Number(req.params.itemId);
|
|
112
|
+
if (!Number.isInteger(itemId) || itemId <= 0) {
|
|
113
|
+
res.status(400).json({ error: "invalid_item_id" });
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
const result = await loadFullText(req.user.id, itemId, { force: req.query.force === "true" });
|
|
117
|
+
if (!result.ok) {
|
|
118
|
+
res.status(FULL_TEXT_STATUS[result.error]).json({ error: result.error, ...(result.message ? { message: result.message } : {}) });
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
const [served] = withArchivedImages([{ id: result.id, link: findItemById(result.id)?.link ?? null, full_content_html: result.fullContentHtml }], requestOrigin(req));
|
|
122
|
+
res.json({
|
|
123
|
+
id: result.id,
|
|
124
|
+
full_content_html: served.full_content_html,
|
|
125
|
+
...(result.cached ? {} : { title: result.title, byline: result.byline }),
|
|
126
|
+
cached: result.cached,
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
//# sourceMappingURL=items.js.map
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { Router } from "express";
|
|
2
|
+
import { requireSession } from "../auth/middleware.js";
|
|
3
|
+
import { deleteGrant, listGrantsForUser } from "../oauth/store.js";
|
|
4
|
+
/** Apps (ChatGPT, Claude, ...) that signed in through OAuth; the owner can disconnect them. */
|
|
5
|
+
export const oauthGrantsRouter = Router();
|
|
6
|
+
oauthGrantsRouter.use(requireSession);
|
|
7
|
+
oauthGrantsRouter.get("/", (req, res) => {
|
|
8
|
+
res.json(listGrantsForUser(req.user.id));
|
|
9
|
+
});
|
|
10
|
+
oauthGrantsRouter.delete("/:grantId", (req, res) => {
|
|
11
|
+
deleteGrant(req.user.id, Number(req.params.grantId));
|
|
12
|
+
res.status(204).end();
|
|
13
|
+
});
|
|
14
|
+
//# sourceMappingURL=oauth.js.map
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { Router } from "express";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { hasAnyUser } from "../db/index.js";
|
|
4
|
+
import { createUser } from "../auth/users.js";
|
|
5
|
+
export const onboardingRouter = Router();
|
|
6
|
+
onboardingRouter.get("/status", (_req, res) => {
|
|
7
|
+
res.json({ needsOnboarding: !hasAnyUser() });
|
|
8
|
+
});
|
|
9
|
+
const onboardingSchema = z.object({
|
|
10
|
+
email: z.string().email(),
|
|
11
|
+
password: z.string().min(10),
|
|
12
|
+
displayName: z.string().min(1).max(100),
|
|
13
|
+
});
|
|
14
|
+
// Creates the first admin user. Only works while the instance has zero users;
|
|
15
|
+
// once an admin exists, all further accounts must be created by an admin.
|
|
16
|
+
onboardingRouter.post("/", (req, res) => {
|
|
17
|
+
if (hasAnyUser()) {
|
|
18
|
+
res.status(409).json({ error: "already_onboarded" });
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
const parsed = onboardingSchema.safeParse(req.body);
|
|
22
|
+
if (!parsed.success) {
|
|
23
|
+
res.status(400).json({ error: "invalid_input", details: parsed.error.flatten() });
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
const user = createUser({ ...parsed.data, role: "admin" });
|
|
27
|
+
req.session.userId = user.id;
|
|
28
|
+
res.status(201).json({ id: user.id, email: user.email, displayName: user.display_name, role: user.role });
|
|
29
|
+
});
|
|
30
|
+
//# sourceMappingURL=onboarding.js.map
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { Router } from "express";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { requireSession } from "../auth/middleware.js";
|
|
4
|
+
import { assertPublicHttpUrl } from "../feeds/ssrfGuard.js";
|
|
5
|
+
import { hasDevice, removeDevice, saveDevice, sendToUser, vapidPublicKey } from "../push.js";
|
|
6
|
+
export const pushRouter = Router();
|
|
7
|
+
pushRouter.use(requireSession);
|
|
8
|
+
const subscriptionSchema = z.object({
|
|
9
|
+
endpoint: z.string().url().max(2000).refine((value) => value.startsWith("https://"), "https only"),
|
|
10
|
+
keys: z.object({ p256dh: z.string().min(1).max(500), auth: z.string().min(1).max(500) }),
|
|
11
|
+
});
|
|
12
|
+
pushRouter.get("/key", (_req, res) => {
|
|
13
|
+
res.json({ publicKey: vapidPublicKey() });
|
|
14
|
+
});
|
|
15
|
+
pushRouter.post("/devices", async (req, res) => {
|
|
16
|
+
const parsed = subscriptionSchema.safeParse(req.body);
|
|
17
|
+
if (!parsed.success) {
|
|
18
|
+
res.status(400).json({ error: "invalid_input" });
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
try {
|
|
22
|
+
await assertPublicHttpUrl(parsed.data.endpoint);
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
res.status(400).json({ error: "invalid_input" });
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
saveDevice(req.user.id, parsed.data);
|
|
29
|
+
res.status(204).end();
|
|
30
|
+
});
|
|
31
|
+
pushRouter.post("/devices/check", (req, res) => {
|
|
32
|
+
const endpoint = typeof req.body?.endpoint === "string" ? req.body.endpoint : "";
|
|
33
|
+
res.json({ registered: hasDevice(req.user.id, endpoint) });
|
|
34
|
+
});
|
|
35
|
+
pushRouter.post("/devices/remove", (req, res) => {
|
|
36
|
+
const endpoint = typeof req.body?.endpoint === "string" ? req.body.endpoint : "";
|
|
37
|
+
removeDevice(req.user.id, endpoint);
|
|
38
|
+
res.status(204).end();
|
|
39
|
+
});
|
|
40
|
+
const TEST_TEXT = {
|
|
41
|
+
en: "Notifications work.",
|
|
42
|
+
de: "Benachrichtigungen funktionieren.",
|
|
43
|
+
ja: "通知は正常に動作しています。",
|
|
44
|
+
};
|
|
45
|
+
pushRouter.post("/test", async (req, res) => {
|
|
46
|
+
// The test message follows the language of the app on the device that asked for it.
|
|
47
|
+
const language = typeof req.body?.language === "string" ? req.body.language.slice(0, 2) : "en";
|
|
48
|
+
const delivered = await sendToUser(req.user.id, { title: "FeedKeeper", body: TEST_TEXT[language] ?? TEST_TEXT.en, url: "/items", tag: "test" });
|
|
49
|
+
res.json({ delivered });
|
|
50
|
+
});
|
|
51
|
+
//# sourceMappingURL=push.js.map
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { Router } from "express";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { requireSession, requireAdmin } from "../auth/middleware.js";
|
|
4
|
+
import { getRetentionSettings, updateRetentionSettings, getDatabaseStats, runCleanup, } from "../feeds/cleanup.js";
|
|
5
|
+
export const systemRouter = Router();
|
|
6
|
+
systemRouter.use(requireSession);
|
|
7
|
+
systemRouter.use(requireAdmin);
|
|
8
|
+
systemRouter.get("/retention", (_req, res) => {
|
|
9
|
+
const settings = getRetentionSettings();
|
|
10
|
+
const stats = getDatabaseStats();
|
|
11
|
+
res.json({ settings, stats });
|
|
12
|
+
});
|
|
13
|
+
const updateRetentionSchema = z.object({
|
|
14
|
+
retentionReadDays: z.number().int().min(0).max(3650).optional(),
|
|
15
|
+
retentionMaxDays: z.number().int().min(0).max(3650).optional(),
|
|
16
|
+
retentionMaxItemsPerFeed: z.number().int().min(0).max(100000).optional(),
|
|
17
|
+
autoCleanupEnabled: z.boolean().optional(),
|
|
18
|
+
});
|
|
19
|
+
systemRouter.patch("/retention", (req, res) => {
|
|
20
|
+
const parsed = updateRetentionSchema.safeParse(req.body);
|
|
21
|
+
if (!parsed.success) {
|
|
22
|
+
res.status(400).json({ error: "invalid_input", details: parsed.error.flatten() });
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
const settings = updateRetentionSettings(parsed.data);
|
|
26
|
+
const stats = getDatabaseStats();
|
|
27
|
+
res.json({ settings, stats });
|
|
28
|
+
});
|
|
29
|
+
systemRouter.post("/cleanup", (_req, res) => {
|
|
30
|
+
try {
|
|
31
|
+
const result = runCleanup();
|
|
32
|
+
const stats = getDatabaseStats();
|
|
33
|
+
res.json({ result, stats });
|
|
34
|
+
}
|
|
35
|
+
catch (error) {
|
|
36
|
+
res.status(500).json({
|
|
37
|
+
error: "cleanup_failed",
|
|
38
|
+
message: error instanceof Error ? error.message : String(error),
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
//# sourceMappingURL=system.js.map
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { Router } from "express";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { requireSession } from "../auth/middleware.js";
|
|
4
|
+
import { createPersonalAccessToken, deleteToken, listTokensForUser } from "../auth/tokens.js";
|
|
5
|
+
export const tokensRouter = Router();
|
|
6
|
+
tokensRouter.use(requireSession);
|
|
7
|
+
tokensRouter.get("/", (req, res) => {
|
|
8
|
+
res.json(listTokensForUser(req.user.id));
|
|
9
|
+
});
|
|
10
|
+
const createSchema = z.object({
|
|
11
|
+
name: z.string().trim().min(1).max(100),
|
|
12
|
+
scope: z.enum(["read", "write"]).default("read"),
|
|
13
|
+
});
|
|
14
|
+
tokensRouter.post("/", (req, res) => {
|
|
15
|
+
const parsed = createSchema.safeParse(req.body);
|
|
16
|
+
if (!parsed.success) {
|
|
17
|
+
res.status(400).json({ error: "invalid_input" });
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
// The plaintext token is only ever returned here, right after creation.
|
|
21
|
+
const { id, token } = createPersonalAccessToken(req.user.id, parsed.data.name, parsed.data.scope);
|
|
22
|
+
res.status(201).json({ id, token });
|
|
23
|
+
});
|
|
24
|
+
tokensRouter.delete("/:tokenId", (req, res) => {
|
|
25
|
+
deleteToken(req.user.id, Number(req.params.tokenId));
|
|
26
|
+
res.status(204).end();
|
|
27
|
+
});
|
|
28
|
+
//# sourceMappingURL=tokens.js.map
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import { Router } from "express";
|
|
2
|
+
import rateLimit from "express-rate-limit";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { createPairingCode, redeemPairingCode } from "../../auth/pairing.js";
|
|
5
|
+
import { requireSession, requireSessionOrDevice } from "../../auth/middleware.js";
|
|
6
|
+
import { deleteDevice, listDevicesForUser } from "../../auth/tokens.js";
|
|
7
|
+
import { findUserById } from "../../auth/users.js";
|
|
8
|
+
import { config } from "../../config.js";
|
|
9
|
+
import { getRetentionSettings } from "../../feeds/cleanup.js";
|
|
10
|
+
import { APP_VERSION } from "../../version.js";
|
|
11
|
+
import { ResyncRequired, currentSeq, listChanges } from "../../sync/changeLog.js";
|
|
12
|
+
import { applyMutations } from "../../sync/mutations.js";
|
|
13
|
+
import { getUserCapabilities, requireCapability } from "../../auth/capabilities.js";
|
|
14
|
+
import { resourcesRouter } from "./resources.js";
|
|
15
|
+
/** The API for native apps. See docs/design/native-api.md for the contract and what is still to come. */
|
|
16
|
+
export const v1Router = Router();
|
|
17
|
+
// Features grow with the implementation; clients look here instead of guessing from the version.
|
|
18
|
+
const FEATURES = [
|
|
19
|
+
"pairing",
|
|
20
|
+
"sync",
|
|
21
|
+
"mutations",
|
|
22
|
+
"subscriptions",
|
|
23
|
+
"folders",
|
|
24
|
+
"items",
|
|
25
|
+
"fulltext",
|
|
26
|
+
"content.revisions",
|
|
27
|
+
"content.prepare",
|
|
28
|
+
"images",
|
|
29
|
+
"muted-keywords",
|
|
30
|
+
"opml",
|
|
31
|
+
"retention",
|
|
32
|
+
"feed-icons",
|
|
33
|
+
"folder-icons",
|
|
34
|
+
"notes",
|
|
35
|
+
"edition",
|
|
36
|
+
"edition.automatic",
|
|
37
|
+
"edition.revisions",
|
|
38
|
+
"native-preferences",
|
|
39
|
+
"native-preferences.edition-size",
|
|
40
|
+
"edition.overview",
|
|
41
|
+
"search.fts",
|
|
42
|
+
"account-capabilities",
|
|
43
|
+
"mcp",
|
|
44
|
+
"inbox",
|
|
45
|
+
];
|
|
46
|
+
v1Router.get("/meta", (_req, res) => {
|
|
47
|
+
const retention = getRetentionSettings();
|
|
48
|
+
res.json({
|
|
49
|
+
apiVersion: 1,
|
|
50
|
+
serverVersion: APP_VERSION,
|
|
51
|
+
features: FEATURES,
|
|
52
|
+
limits: {
|
|
53
|
+
maxMutationsPerRequest: 200,
|
|
54
|
+
// Kept for clients that only know the maximum age.
|
|
55
|
+
retentionDays: retention.retentionMaxDays,
|
|
56
|
+
// Deletions caused by retention are not announced through /sync, so clients apply the same rules
|
|
57
|
+
// to their local copy. A value of 0 switches that rule off; saved articles are never removed.
|
|
58
|
+
retention: {
|
|
59
|
+
enabled: retention.autoCleanupEnabled,
|
|
60
|
+
readDays: retention.retentionReadDays,
|
|
61
|
+
maxDays: retention.retentionMaxDays,
|
|
62
|
+
maxItemsPerFeed: retention.retentionMaxItemsPerFeed,
|
|
63
|
+
protectActiveEdition: true,
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
minClientVersion: null,
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
// The web app creates the code; the app redeems it. Rate limited, as the code is the only secret.
|
|
70
|
+
v1Router.post("/pairing-codes", requireSession, (req, res) => {
|
|
71
|
+
const { code, expiresAt } = createPairingCode(req.user.id);
|
|
72
|
+
const pairingUrl = `feedkeeper://pair?server=${encodeURIComponent(config.publicUrl)}&code=${encodeURIComponent(code)}`;
|
|
73
|
+
res.status(201).json({ code, expiresAt, pairingUrl });
|
|
74
|
+
});
|
|
75
|
+
const pairSchema = z.object({
|
|
76
|
+
code: z.string().min(8).max(40),
|
|
77
|
+
deviceName: z.string().trim().min(1).max(100),
|
|
78
|
+
platform: z.string().trim().min(1).max(40),
|
|
79
|
+
appVersion: z.string().trim().max(40).optional(),
|
|
80
|
+
});
|
|
81
|
+
v1Router.post("/devices/pair", rateLimit({ windowMs: 60 * 1000, limit: 10, standardHeaders: true, legacyHeaders: false }), (req, res) => {
|
|
82
|
+
const parsed = pairSchema.safeParse(req.body);
|
|
83
|
+
if (!parsed.success) {
|
|
84
|
+
res.status(400).json({ error: "invalid_input" });
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
const paired = redeemPairingCode(parsed.data.code, { name: parsed.data.deviceName, platform: parsed.data.platform, appVersion: parsed.data.appVersion ?? null });
|
|
88
|
+
if (!paired) {
|
|
89
|
+
res.status(400).json({ error: "invalid_or_expired_code" });
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
const user = findUserById(paired.userId);
|
|
93
|
+
// The token is shown here once; only its hash is stored.
|
|
94
|
+
res.status(201).json({
|
|
95
|
+
deviceId: paired.deviceId,
|
|
96
|
+
token: paired.token,
|
|
97
|
+
user: { id: user.id, email: user.email, displayName: user.display_name },
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
v1Router.use(requireSessionOrDevice);
|
|
101
|
+
// A read-only token may look at everything but change nothing.
|
|
102
|
+
v1Router.use((req, res, next) => {
|
|
103
|
+
if (req.method !== "GET" && req.tokenScope === "read") {
|
|
104
|
+
res.status(403).json({ error: "read_only_token" });
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
next();
|
|
108
|
+
});
|
|
109
|
+
v1Router.get("/me", (req, res) => {
|
|
110
|
+
res.setHeader("Cache-Control", "private, no-store");
|
|
111
|
+
const { id, email, display_name, role } = req.user;
|
|
112
|
+
res.json({
|
|
113
|
+
id,
|
|
114
|
+
email,
|
|
115
|
+
displayName: display_name,
|
|
116
|
+
role,
|
|
117
|
+
scope: req.tokenScope ?? "write",
|
|
118
|
+
deviceId: req.tokenKind === "device" ? req.tokenId : null,
|
|
119
|
+
capabilities: getUserCapabilities(id),
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
v1Router.get("/devices", (req, res) => {
|
|
123
|
+
res.json(listDevicesForUser(req.user.id).map((device) => ({
|
|
124
|
+
id: device.id,
|
|
125
|
+
name: device.name,
|
|
126
|
+
platform: device.platform,
|
|
127
|
+
appVersion: device.app_version,
|
|
128
|
+
createdAt: device.created_at,
|
|
129
|
+
lastUsedAt: device.last_used_at,
|
|
130
|
+
current: req.tokenKind === "device" && req.tokenId === device.id,
|
|
131
|
+
})));
|
|
132
|
+
});
|
|
133
|
+
v1Router.delete("/devices/:deviceId", (req, res) => {
|
|
134
|
+
if (!deleteDevice(req.user.id, Number(req.params.deviceId))) {
|
|
135
|
+
res.status(404).json({ error: "not_found" });
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
res.status(204).end();
|
|
139
|
+
});
|
|
140
|
+
const syncQuery = z.object({
|
|
141
|
+
since: z.coerce.number().int().min(0).default(0),
|
|
142
|
+
limit: z.coerce.number().int().min(1).max(500).default(200),
|
|
143
|
+
});
|
|
144
|
+
// Changes to the user's own data (articles' state, subscriptions, folders, keywords) since `since`.
|
|
145
|
+
// since=0 delivers the complete state, as the log holds one entry per object.
|
|
146
|
+
v1Router.get("/sync", requireCapability("sync"), (req, res) => {
|
|
147
|
+
const parsed = syncQuery.safeParse(req.query);
|
|
148
|
+
if (!parsed.success) {
|
|
149
|
+
res.status(400).json({ error: "invalid_input" });
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
try {
|
|
153
|
+
res.json(listChanges(req.user.id, parsed.data.since, parsed.data.limit));
|
|
154
|
+
}
|
|
155
|
+
catch (error) {
|
|
156
|
+
if (error instanceof ResyncRequired) {
|
|
157
|
+
res.status(410).json({ error: "resync_required", message: "Drop the local copy and sync again from since=0." });
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
throw error;
|
|
161
|
+
}
|
|
162
|
+
});
|
|
163
|
+
// Changes made offline; every one carries a client id, so retrying a request is safe.
|
|
164
|
+
v1Router.post("/mutations", (req, res) => {
|
|
165
|
+
if (req.tokenScope === "read") {
|
|
166
|
+
res.status(403).json({ error: "read_only_token" });
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
const mutations = req.body?.mutations;
|
|
170
|
+
if (!Array.isArray(mutations) || mutations.length === 0 || mutations.length > 200) {
|
|
171
|
+
res.status(400).json({ error: "invalid_input", message: "Send 1 to 200 mutations." });
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
const results = applyMutations(req.user.id, mutations);
|
|
175
|
+
res.json({ results, seq: currentSeq(req.user.id) });
|
|
176
|
+
});
|
|
177
|
+
v1Router.use(resourcesRouter);
|
|
178
|
+
//# sourceMappingURL=index.js.map
|