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,370 @@
|
|
|
1
|
+
import { createHash, createHmac, timingSafeEqual } from "node:crypto";
|
|
2
|
+
import cors from "cors";
|
|
3
|
+
import express, { Router } from "express";
|
|
4
|
+
import rateLimit from "express-rate-limit";
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
import { config } from "../config.js";
|
|
7
|
+
import { getUserCapabilities, hasUserCapability } from "../auth/capabilities.js";
|
|
8
|
+
import { hasTotp, securityVersion, sessionIsSecure } from "../auth/security.js";
|
|
9
|
+
import { verifyPasswordOrDummy } from "../auth/password.js";
|
|
10
|
+
import { findUserByEmail, findUserById } from "../auth/users.js";
|
|
11
|
+
import { ISSUER, MCP_RESOURCE } from "./constants.js";
|
|
12
|
+
import { getOAuthHost } from "./host.js";
|
|
13
|
+
import { renderConsent, renderError, renderLogin } from "./pages.js";
|
|
14
|
+
import { consumeAuthorizationCode, createAuthorizationCode, findClient, issueGrant, registerClient, revokeByToken, rotateGrant, } from "./store.js";
|
|
15
|
+
const issuer = ISSUER;
|
|
16
|
+
const SCOPES = ["mcp:read", "mcp:write"];
|
|
17
|
+
const PKCE_PATTERN = /^[A-Za-z0-9\-._~]{43,128}$/;
|
|
18
|
+
const noStore = (res) => res.setHeader("Cache-Control", "no-store").setHeader("Pragma", "no-cache");
|
|
19
|
+
function oauthError(res, status, error, description) {
|
|
20
|
+
noStore(res);
|
|
21
|
+
res.status(status).json({ error, ...(description ? { error_description: description } : {}) });
|
|
22
|
+
}
|
|
23
|
+
function sameResource(value) {
|
|
24
|
+
return typeof value === "string" && value.replace(/\/+$/, "") === MCP_RESOURCE;
|
|
25
|
+
}
|
|
26
|
+
const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "[::1]"]);
|
|
27
|
+
/** https anywhere (optionally limited to OAUTH_ALLOWED_REDIRECT_HOSTS) or plain http on this machine. */
|
|
28
|
+
function validRedirectUri(value) {
|
|
29
|
+
let url;
|
|
30
|
+
try {
|
|
31
|
+
url = new URL(value);
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
if (url.hash || url.username || url.password)
|
|
37
|
+
return false;
|
|
38
|
+
if (url.protocol === "http:")
|
|
39
|
+
return LOOPBACK_HOSTS.has(url.hostname);
|
|
40
|
+
if (url.protocol !== "https:")
|
|
41
|
+
return false;
|
|
42
|
+
const allowed = config.oauthAllowedRedirectHosts;
|
|
43
|
+
return allowed.length === 0 || allowed.includes(url.hostname.toLowerCase());
|
|
44
|
+
}
|
|
45
|
+
// ---------------------------------------------------------------------------------------------
|
|
46
|
+
// Public endpoints: metadata, registration, token, revocation. Called by connector backends and
|
|
47
|
+
// browser tools without our cookies, so they get open CORS and no session.
|
|
48
|
+
// ---------------------------------------------------------------------------------------------
|
|
49
|
+
export const oauthPublicRouter = Router();
|
|
50
|
+
const openCors = cors({ origin: "*", credentials: false, methods: ["GET", "POST", "OPTIONS"] });
|
|
51
|
+
oauthPublicRouter.use(["/.well-known", "/oauth/register", "/oauth/token", "/oauth/revoke"], openCors);
|
|
52
|
+
const formBodies = [express.urlencoded({ extended: false, limit: "20kb" }), express.json({ limit: "20kb" })];
|
|
53
|
+
oauthPublicRouter.get(["/.well-known/oauth-protected-resource", "/.well-known/oauth-protected-resource/mcp"], (_req, res) => {
|
|
54
|
+
res.json({
|
|
55
|
+
resource: MCP_RESOURCE,
|
|
56
|
+
authorization_servers: [issuer],
|
|
57
|
+
scopes_supported: SCOPES,
|
|
58
|
+
bearer_methods_supported: ["header"],
|
|
59
|
+
resource_name: "FeedKeeper MCP",
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
oauthPublicRouter.get(["/.well-known/oauth-authorization-server", "/.well-known/openid-configuration"], (_req, res) => {
|
|
63
|
+
res.json({
|
|
64
|
+
issuer,
|
|
65
|
+
authorization_endpoint: `${issuer}/oauth/authorize`,
|
|
66
|
+
token_endpoint: `${issuer}/oauth/token`,
|
|
67
|
+
registration_endpoint: `${issuer}/oauth/register`,
|
|
68
|
+
revocation_endpoint: `${issuer}/oauth/revoke`,
|
|
69
|
+
response_types_supported: ["code"],
|
|
70
|
+
grant_types_supported: ["authorization_code", "refresh_token"],
|
|
71
|
+
code_challenge_methods_supported: ["S256"],
|
|
72
|
+
token_endpoint_auth_methods_supported: ["none"],
|
|
73
|
+
revocation_endpoint_auth_methods_supported: ["none"],
|
|
74
|
+
scopes_supported: SCOPES,
|
|
75
|
+
authorization_response_iss_parameter_supported: true,
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
const registerLimiter = rateLimit({ windowMs: 60 * 60 * 1000, limit: 30, standardHeaders: true, legacyHeaders: false });
|
|
79
|
+
const tokenLimiter = rateLimit({ windowMs: 60 * 1000, limit: 60, standardHeaders: true, legacyHeaders: false });
|
|
80
|
+
const registerSchema = z.object({
|
|
81
|
+
client_name: z.string().trim().min(1).max(100).optional(),
|
|
82
|
+
redirect_uris: z.array(z.string().max(2000)).min(1).max(10),
|
|
83
|
+
token_endpoint_auth_method: z.literal("none").optional(),
|
|
84
|
+
grant_types: z.array(z.enum(["authorization_code", "refresh_token"])).optional(),
|
|
85
|
+
response_types: z.array(z.literal("code")).optional(),
|
|
86
|
+
});
|
|
87
|
+
oauthPublicRouter.post("/oauth/register", registerLimiter, formBodies[1], (req, res) => {
|
|
88
|
+
const parsed = registerSchema.safeParse(req.body);
|
|
89
|
+
if (!parsed.success) {
|
|
90
|
+
oauthError(res, 400, "invalid_client_metadata", "Public clients (token_endpoint_auth_method none) with 1-10 redirect_uris are supported.");
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
if (!parsed.data.redirect_uris.every(validRedirectUri)) {
|
|
94
|
+
oauthError(res, 400, "invalid_redirect_uri", "Redirect URIs must be https, or http on localhost, without credentials or fragment.");
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
const client = registerClient(parsed.data.client_name ?? "MCP client", [...new Set(parsed.data.redirect_uris)]);
|
|
98
|
+
if (!client) {
|
|
99
|
+
oauthError(res, 503, "temporarily_unavailable", "Too many registered clients.");
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
noStore(res);
|
|
103
|
+
res.status(201).json({
|
|
104
|
+
client_id: client.client_id,
|
|
105
|
+
client_name: client.client_name,
|
|
106
|
+
redirect_uris: client.redirect_uris,
|
|
107
|
+
token_endpoint_auth_method: "none",
|
|
108
|
+
grant_types: ["authorization_code", "refresh_token"],
|
|
109
|
+
response_types: ["code"],
|
|
110
|
+
client_id_issued_at: Math.floor(Date.parse(client.created_at) / 1000),
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
function basicClientId(req) {
|
|
114
|
+
const header = req.headers.authorization;
|
|
115
|
+
if (!header?.startsWith("Basic "))
|
|
116
|
+
return null;
|
|
117
|
+
const [id] = Buffer.from(header.slice(6), "base64").toString("utf8").split(":");
|
|
118
|
+
if (!id)
|
|
119
|
+
return null;
|
|
120
|
+
try {
|
|
121
|
+
return decodeURIComponent(id);
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
// A malformed escape is a client that does not exist, not a server error.
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
const str = (value) => (typeof value === "string" ? value : "");
|
|
129
|
+
function tokenResponse(res, tokens, scope) {
|
|
130
|
+
noStore(res);
|
|
131
|
+
res.json({
|
|
132
|
+
access_token: tokens.accessToken,
|
|
133
|
+
token_type: "Bearer",
|
|
134
|
+
expires_in: tokens.expiresIn,
|
|
135
|
+
refresh_token: tokens.refreshToken,
|
|
136
|
+
scope: scope === "write" ? "mcp:read mcp:write" : "mcp:read",
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
function pkceMatches(verifier, challenge) {
|
|
140
|
+
if (!PKCE_PATTERN.test(verifier))
|
|
141
|
+
return false;
|
|
142
|
+
const computed = Buffer.from(createHash("sha256").update(verifier).digest("base64url"));
|
|
143
|
+
const stored = Buffer.from(challenge);
|
|
144
|
+
return computed.length === stored.length && timingSafeEqual(computed, stored);
|
|
145
|
+
}
|
|
146
|
+
oauthPublicRouter.post("/oauth/token", tokenLimiter, ...formBodies, (req, res) => {
|
|
147
|
+
const body = (req.body ?? {});
|
|
148
|
+
const clientId = str(body.client_id) || basicClientId(req) || "";
|
|
149
|
+
const client = clientId ? findClient(clientId) : null;
|
|
150
|
+
if (!client) {
|
|
151
|
+
oauthError(res, 401, "invalid_client");
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
if (body.resource !== undefined && !sameResource(body.resource)) {
|
|
155
|
+
oauthError(res, 400, "invalid_target", `The only resource is ${MCP_RESOURCE}.`);
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
const grantType = str(body.grant_type);
|
|
159
|
+
if (grantType === "authorization_code") {
|
|
160
|
+
// The code is spent by the lookup, so a failed attempt cannot be retried with a corrected verifier.
|
|
161
|
+
const code = consumeAuthorizationCode(str(body.code));
|
|
162
|
+
if (!code || code.client_id !== client.client_id || code.redirect_uri !== str(body.redirect_uri) || !pkceMatches(str(body.code_verifier), code.code_challenge)) {
|
|
163
|
+
oauthError(res, 400, "invalid_grant");
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
tokenResponse(res, issueGrant(code.user_id, client.client_id, code.scope), code.scope);
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
if (grantType === "refresh_token") {
|
|
170
|
+
const result = rotateGrant(str(body.refresh_token), client.client_id);
|
|
171
|
+
if (!result.ok) {
|
|
172
|
+
oauthError(res, 400, "invalid_grant");
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
tokenResponse(res, result.tokens, result.scope);
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
oauthError(res, 400, "unsupported_grant_type");
|
|
179
|
+
});
|
|
180
|
+
oauthPublicRouter.post("/oauth/revoke", tokenLimiter, ...formBodies, (req, res) => {
|
|
181
|
+
const body = (req.body ?? {});
|
|
182
|
+
// RFC 7009: the answer never reveals whether the token existed.
|
|
183
|
+
revokeByToken(str(body.token), str(body.client_id) || basicClientId(req));
|
|
184
|
+
noStore(res);
|
|
185
|
+
res.status(200).json({});
|
|
186
|
+
});
|
|
187
|
+
// ---------------------------------------------------------------------------------------------
|
|
188
|
+
// Authorization: runs in the user's browser with the cookie session.
|
|
189
|
+
// ---------------------------------------------------------------------------------------------
|
|
190
|
+
export const oauthAuthorizeRouter = Router();
|
|
191
|
+
const publicOrigin = new URL(issuer).origin;
|
|
192
|
+
const loginLimiter = rateLimit({ windowMs: 15 * 60 * 1000, limit: 10, standardHeaders: true, legacyHeaders: false });
|
|
193
|
+
const formOnly = express.urlencoded({ extended: false, limit: "20kb" });
|
|
194
|
+
/**
|
|
195
|
+
* The consent and login forms only ever post from our own pages. Browsers state that themselves in
|
|
196
|
+
* Sec-Fetch-Site; Origin is the fallback for older ones. With `Referrer-Policy: no-referrer` (helmet's
|
|
197
|
+
* default) a same-origin form post carries `Origin: null`, so a null Origin alone proves nothing.
|
|
198
|
+
*/
|
|
199
|
+
function sameOriginOnly(req, res, next) {
|
|
200
|
+
const site = req.headers["sec-fetch-site"];
|
|
201
|
+
const origin = req.headers.origin;
|
|
202
|
+
const foreign = site !== undefined ? site !== "same-origin" : origin !== undefined && origin !== publicOrigin;
|
|
203
|
+
if (foreign) {
|
|
204
|
+
page(res, 403, renderError(req, "forbidden"));
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
next();
|
|
208
|
+
}
|
|
209
|
+
const LOOPBACK_FORM_ACTIONS = "http://localhost:* http://127.0.0.1:* http://[::1]:*";
|
|
210
|
+
/** Pages are never cached or framed. Consent may submit on to the client's redirect address, which form-action also covers after a redirect. */
|
|
211
|
+
function page(res, status, html, allowRedirects = false) {
|
|
212
|
+
noStore(res);
|
|
213
|
+
// The forms check where a post came from; give them an Origin to check.
|
|
214
|
+
res.setHeader("Referrer-Policy", "same-origin");
|
|
215
|
+
res.setHeader("Content-Security-Policy", `default-src 'none'; style-src 'unsafe-inline'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'${allowRedirects ? ` https: ${LOOPBACK_FORM_ACTIONS}` : ""}`);
|
|
216
|
+
res.status(status).type("html").send(html);
|
|
217
|
+
}
|
|
218
|
+
function parseAuthorization(params) {
|
|
219
|
+
const client = findClient(str(params.client_id));
|
|
220
|
+
if (!client)
|
|
221
|
+
return { kind: "fatal", message: "unknownClient" };
|
|
222
|
+
const redirectUri = str(params.redirect_uri);
|
|
223
|
+
// Unlike every other problem, these two must never bounce back to an unverified address.
|
|
224
|
+
if (!client.redirect_uris.includes(redirectUri))
|
|
225
|
+
return { kind: "fatal", message: "badRedirect" };
|
|
226
|
+
const state = str(params.state);
|
|
227
|
+
const fail = (error) => ({ kind: "redirect", redirectUri, state, error });
|
|
228
|
+
if (str(params.response_type) !== "code")
|
|
229
|
+
return fail("unsupported_response_type");
|
|
230
|
+
const codeChallenge = str(params.code_challenge);
|
|
231
|
+
if (!PKCE_PATTERN.test(codeChallenge) || str(params.code_challenge_method) !== "S256")
|
|
232
|
+
return fail("invalid_request");
|
|
233
|
+
if (params.resource !== undefined && !sameResource(params.resource))
|
|
234
|
+
return fail("invalid_target");
|
|
235
|
+
// Unknown scopes (openid, offline_access, ...) are ignored; the user picks the final level.
|
|
236
|
+
const scopes = str(params.scope).split(/\s+/);
|
|
237
|
+
const requested = scopes.includes("mcp:write") ? "write" : scopes.includes("mcp:read") ? "read" : null;
|
|
238
|
+
return { kind: "ok", client, redirectUri, state, codeChallenge, requested, resource: MCP_RESOURCE };
|
|
239
|
+
}
|
|
240
|
+
function redirectBack(res, redirectUri, values) {
|
|
241
|
+
const url = new URL(redirectUri);
|
|
242
|
+
for (const [key, value] of Object.entries({ ...values, iss: issuer }))
|
|
243
|
+
if (value)
|
|
244
|
+
url.searchParams.set(key, value);
|
|
245
|
+
noStore(res);
|
|
246
|
+
res.redirect(303, url.toString());
|
|
247
|
+
}
|
|
248
|
+
function csrfToken(userId, a) {
|
|
249
|
+
const material = ["oauth-consent", userId, a.client.client_id, a.redirectUri, a.state, a.codeChallenge, a.requested ?? "", a.resource].join("\n");
|
|
250
|
+
return createHmac("sha256", config.sessionSecret).update(material).digest("hex");
|
|
251
|
+
}
|
|
252
|
+
function csrfValid(expected, given) {
|
|
253
|
+
const a = Buffer.from(expected);
|
|
254
|
+
const b = Buffer.from(given);
|
|
255
|
+
return a.length === b.length && timingSafeEqual(a, b);
|
|
256
|
+
}
|
|
257
|
+
/** The signed-in user, or null once the host or the login page has taken over the response. */
|
|
258
|
+
function resolveOwner(req, res, client) {
|
|
259
|
+
const host = getOAuthHost();
|
|
260
|
+
if (host)
|
|
261
|
+
return host.resolveResourceOwner(req, res);
|
|
262
|
+
const userId = req.session?.userId;
|
|
263
|
+
if (userId && findUserById(userId) && sessionIsSecure(req, userId))
|
|
264
|
+
return userId;
|
|
265
|
+
page(res, 200, renderLogin(req, { clientName: client.client_name, returnTo: req.originalUrl }));
|
|
266
|
+
return null;
|
|
267
|
+
}
|
|
268
|
+
function mayUseMcp(req, res, userId) {
|
|
269
|
+
if (hasUserCapability(userId, "mcp"))
|
|
270
|
+
return true;
|
|
271
|
+
page(res, 403, renderError(req, "noAccess", getUserCapabilities(userId).manageUrl));
|
|
272
|
+
return false;
|
|
273
|
+
}
|
|
274
|
+
oauthAuthorizeRouter.get("/oauth/authorize", (req, res) => {
|
|
275
|
+
const parsed = parseAuthorization(req.query);
|
|
276
|
+
if (parsed.kind === "fatal") {
|
|
277
|
+
page(res, 400, renderError(req, parsed.message));
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
if (parsed.kind === "redirect") {
|
|
281
|
+
redirectBack(res, parsed.redirectUri, { error: parsed.error, state: parsed.state });
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
const userId = resolveOwner(req, res, parsed.client);
|
|
285
|
+
if (userId === null || !mayUseMcp(req, res, userId))
|
|
286
|
+
return;
|
|
287
|
+
const user = findUserById(userId);
|
|
288
|
+
page(res, 200, renderConsent(req, {
|
|
289
|
+
clientName: parsed.client.client_name,
|
|
290
|
+
redirectHost: new URL(parsed.redirectUri).host,
|
|
291
|
+
userName: user.display_name || user.email,
|
|
292
|
+
preselect: parsed.requested === "write" ? "write" : "read",
|
|
293
|
+
allowWrite: parsed.requested !== "read",
|
|
294
|
+
fields: {
|
|
295
|
+
client_id: parsed.client.client_id,
|
|
296
|
+
redirect_uri: parsed.redirectUri,
|
|
297
|
+
state: parsed.state,
|
|
298
|
+
code_challenge: parsed.codeChallenge,
|
|
299
|
+
code_challenge_method: "S256",
|
|
300
|
+
response_type: "code",
|
|
301
|
+
scope: parsed.requested === "write" ? "mcp:read mcp:write" : parsed.requested === "read" ? "mcp:read" : "",
|
|
302
|
+
resource: parsed.resource,
|
|
303
|
+
csrf: csrfToken(userId, parsed),
|
|
304
|
+
},
|
|
305
|
+
}), true);
|
|
306
|
+
});
|
|
307
|
+
const loginSchema = z.object({ email: z.string().email(), password: z.string().min(1), return_to: z.string().max(4000) });
|
|
308
|
+
oauthAuthorizeRouter.post("/oauth/authorize/login", sameOriginOnly, loginLimiter, formOnly, (req, res) => {
|
|
309
|
+
const parsed = loginSchema.safeParse(req.body);
|
|
310
|
+
// Only our own authorize URL is a valid destination; anything else would be an open redirect.
|
|
311
|
+
if (!parsed.success || !parsed.data.return_to.startsWith("/oauth/authorize?")) {
|
|
312
|
+
page(res, 400, renderError(req, "forbidden"));
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
const returnTo = parsed.data.return_to;
|
|
316
|
+
const clientId = new URL(returnTo, issuer).searchParams.get("client_id") ?? "";
|
|
317
|
+
const client = findClient(clientId);
|
|
318
|
+
if (!client) {
|
|
319
|
+
page(res, 400, renderError(req, "unknownClient"));
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
const user = findUserByEmail(parsed.data.email);
|
|
323
|
+
const passwordMatches = verifyPasswordOrDummy(parsed.data.password, user?.password_hash);
|
|
324
|
+
if (!user || !passwordMatches) {
|
|
325
|
+
page(res, 401, renderLogin(req, { clientName: client.client_name, returnTo, failed: true }));
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
if (hasTotp(user.id)) {
|
|
329
|
+
res.redirect(303, `/login?next=${encodeURIComponent(returnTo)}`);
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
req.session = { userId: user.id, securityVersion: securityVersion(user.id) };
|
|
333
|
+
res.redirect(303, returnTo);
|
|
334
|
+
});
|
|
335
|
+
oauthAuthorizeRouter.post("/oauth/authorize/decision", sameOriginOnly, formOnly, (req, res) => {
|
|
336
|
+
const body = (req.body ?? {});
|
|
337
|
+
const parsed = parseAuthorization(body);
|
|
338
|
+
if (parsed.kind === "fatal") {
|
|
339
|
+
page(res, 400, renderError(req, parsed.message));
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
if (parsed.kind === "redirect") {
|
|
343
|
+
redirectBack(res, parsed.redirectUri, { error: parsed.error, state: parsed.state });
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
346
|
+
const userId = resolveOwner(req, res, parsed.client);
|
|
347
|
+
if (userId === null)
|
|
348
|
+
return;
|
|
349
|
+
// Every field of the consent page is bound into the token, so a forged or altered form fails here.
|
|
350
|
+
if (!csrfValid(csrfToken(userId, parsed), str(body.csrf))) {
|
|
351
|
+
page(res, 403, renderError(req, "forbidden"));
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
if (!mayUseMcp(req, res, userId))
|
|
355
|
+
return;
|
|
356
|
+
if (str(body.decision) !== "allow") {
|
|
357
|
+
redirectBack(res, parsed.redirectUri, { error: "access_denied", state: parsed.state });
|
|
358
|
+
return;
|
|
359
|
+
}
|
|
360
|
+
const scope = str(body.grant_scope) === "write" && parsed.requested !== "read" ? "write" : "read";
|
|
361
|
+
const code = createAuthorizationCode({
|
|
362
|
+
clientId: parsed.client.client_id,
|
|
363
|
+
userId,
|
|
364
|
+
redirectUri: parsed.redirectUri,
|
|
365
|
+
codeChallenge: parsed.codeChallenge,
|
|
366
|
+
scope,
|
|
367
|
+
});
|
|
368
|
+
redirectBack(res, parsed.redirectUri, { code, state: parsed.state });
|
|
369
|
+
});
|
|
370
|
+
//# sourceMappingURL=routes.js.map
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
2
|
+
import { db } from "../db/index.js";
|
|
3
|
+
export const ACCESS_TTL_MS = 60 * 60 * 1000;
|
|
4
|
+
export const REFRESH_TTL_MS = 30 * 24 * 60 * 60 * 1000;
|
|
5
|
+
export const CODE_TTL_MS = 60 * 1000;
|
|
6
|
+
/** Clients nobody ever authorized are dropped after this long. */
|
|
7
|
+
const UNUSED_CLIENT_TTL_MS = 30 * 24 * 60 * 60 * 1000;
|
|
8
|
+
const MAX_CLIENTS = 5000;
|
|
9
|
+
function hash(value) {
|
|
10
|
+
return createHash("sha256").update(value).digest("hex");
|
|
11
|
+
}
|
|
12
|
+
function newSecret(prefix) {
|
|
13
|
+
return `${prefix}${randomBytes(32).toString("base64url")}`;
|
|
14
|
+
}
|
|
15
|
+
const now = () => new Date().toISOString();
|
|
16
|
+
const inMs = (ms) => new Date(Date.now() + ms).toISOString();
|
|
17
|
+
export function registerClient(name, redirectUris) {
|
|
18
|
+
return db.transaction(() => {
|
|
19
|
+
db.prepare(`DELETE FROM oauth_clients WHERE created_at < ? AND NOT EXISTS (SELECT 1 FROM oauth_grants WHERE oauth_grants.client_id = oauth_clients.client_id)`).run(new Date(Date.now() - UNUSED_CLIENT_TTL_MS).toISOString());
|
|
20
|
+
const count = db.prepare("SELECT COUNT(*) AS n FROM oauth_clients").get().n;
|
|
21
|
+
if (count >= MAX_CLIENTS)
|
|
22
|
+
return null;
|
|
23
|
+
const clientId = `fkc_${randomBytes(16).toString("base64url")}`;
|
|
24
|
+
db.prepare("INSERT INTO oauth_clients (client_id, client_name, redirect_uris) VALUES (?, ?, ?)").run(clientId, name, JSON.stringify(redirectUris));
|
|
25
|
+
return findClient(clientId);
|
|
26
|
+
})();
|
|
27
|
+
}
|
|
28
|
+
export function findClient(clientId) {
|
|
29
|
+
const row = db.prepare("SELECT * FROM oauth_clients WHERE client_id = ?").get(clientId);
|
|
30
|
+
return row ? { ...row, redirect_uris: JSON.parse(row.redirect_uris) } : null;
|
|
31
|
+
}
|
|
32
|
+
export function createAuthorizationCode(input) {
|
|
33
|
+
const code = newSecret("fkcode_");
|
|
34
|
+
db.prepare("DELETE FROM oauth_codes WHERE expires_at <= ?").run(now());
|
|
35
|
+
db.prepare(`INSERT INTO oauth_codes (code_hash, client_id, user_id, redirect_uri, code_challenge, scope, expires_at) VALUES (?, ?, ?, ?, ?, ?, ?)`).run(hash(code), input.clientId, input.userId, input.redirectUri, input.codeChallenge, input.scope, inMs(CODE_TTL_MS));
|
|
36
|
+
return code;
|
|
37
|
+
}
|
|
38
|
+
/** A code works once: it is deleted whether or not the caller's checks pass afterwards. */
|
|
39
|
+
export function consumeAuthorizationCode(code) {
|
|
40
|
+
return db.transaction(() => {
|
|
41
|
+
const row = db
|
|
42
|
+
.prepare("SELECT * FROM oauth_codes WHERE code_hash = ?")
|
|
43
|
+
.get(hash(code));
|
|
44
|
+
if (!row)
|
|
45
|
+
return null;
|
|
46
|
+
db.prepare("DELETE FROM oauth_codes WHERE code_hash = ?").run(hash(code));
|
|
47
|
+
return row.expires_at > now() ? row : null;
|
|
48
|
+
})();
|
|
49
|
+
}
|
|
50
|
+
export function issueGrant(userId, clientId, scope) {
|
|
51
|
+
const accessToken = newSecret("fk_oat_");
|
|
52
|
+
const refreshToken = newSecret("fk_ort_");
|
|
53
|
+
db.prepare(`INSERT INTO oauth_grants (user_id, client_id, scope, access_hash, access_expires_at, refresh_hash, refresh_expires_at)
|
|
54
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)`).run(userId, clientId, scope, hash(accessToken), inMs(ACCESS_TTL_MS), hash(refreshToken), inMs(REFRESH_TTL_MS));
|
|
55
|
+
return { accessToken, refreshToken, expiresIn: ACCESS_TTL_MS / 1000 };
|
|
56
|
+
}
|
|
57
|
+
/** Swaps in a new token pair. A refresh token that was already replaced revokes the whole grant. */
|
|
58
|
+
export function rotateGrant(refreshToken, clientId) {
|
|
59
|
+
return db.transaction(() => {
|
|
60
|
+
const refreshHash = hash(refreshToken);
|
|
61
|
+
const reused = db.prepare("SELECT * FROM oauth_grants WHERE previous_refresh_hash = ?").get(refreshHash);
|
|
62
|
+
if (reused) {
|
|
63
|
+
db.prepare("DELETE FROM oauth_grants WHERE id = ?").run(reused.id);
|
|
64
|
+
return { ok: false };
|
|
65
|
+
}
|
|
66
|
+
const grant = db.prepare("SELECT * FROM oauth_grants WHERE refresh_hash = ?").get(refreshHash);
|
|
67
|
+
if (!grant || grant.client_id !== clientId || grant.refresh_expires_at <= now())
|
|
68
|
+
return { ok: false };
|
|
69
|
+
const accessToken = newSecret("fk_oat_");
|
|
70
|
+
const newRefresh = newSecret("fk_ort_");
|
|
71
|
+
db.prepare(`UPDATE oauth_grants SET access_hash = ?, access_expires_at = ?, refresh_hash = ?, previous_refresh_hash = ?, refresh_expires_at = ? WHERE id = ?`).run(hash(accessToken), inMs(ACCESS_TTL_MS), hash(newRefresh), refreshHash, inMs(REFRESH_TTL_MS), grant.id);
|
|
72
|
+
return { ok: true, scope: grant.scope, tokens: { accessToken, refreshToken: newRefresh, expiresIn: ACCESS_TTL_MS / 1000 } };
|
|
73
|
+
})();
|
|
74
|
+
}
|
|
75
|
+
export function resolveAccessToken(token) {
|
|
76
|
+
const row = db
|
|
77
|
+
.prepare("SELECT * FROM oauth_grants WHERE access_hash = ?")
|
|
78
|
+
.get(hash(token));
|
|
79
|
+
if (!row || row.access_expires_at <= now())
|
|
80
|
+
return null;
|
|
81
|
+
db.prepare("UPDATE oauth_grants SET last_used_at = ? WHERE id = ?").run(now(), row.id);
|
|
82
|
+
return { userId: row.user_id, scope: row.scope, grantId: row.id };
|
|
83
|
+
}
|
|
84
|
+
/** Revokes by either token. Returns whether a grant was removed. */
|
|
85
|
+
export function revokeByToken(token, clientId) {
|
|
86
|
+
const tokenHash = hash(token);
|
|
87
|
+
const grant = db
|
|
88
|
+
.prepare("SELECT id, client_id FROM oauth_grants WHERE access_hash = ? OR refresh_hash = ?")
|
|
89
|
+
.get(tokenHash, tokenHash);
|
|
90
|
+
if (!grant || (clientId && grant.client_id !== clientId))
|
|
91
|
+
return false;
|
|
92
|
+
return db.prepare("DELETE FROM oauth_grants WHERE id = ?").run(grant.id).changes > 0;
|
|
93
|
+
}
|
|
94
|
+
export function listGrantsForUser(userId) {
|
|
95
|
+
return db
|
|
96
|
+
.prepare(`SELECT g.id, c.client_name, g.scope, g.created_at, g.last_used_at
|
|
97
|
+
FROM oauth_grants g JOIN oauth_clients c ON c.client_id = g.client_id
|
|
98
|
+
WHERE g.user_id = ? ORDER BY g.created_at DESC`)
|
|
99
|
+
.all(userId);
|
|
100
|
+
}
|
|
101
|
+
export function deleteGrant(userId, grantId) {
|
|
102
|
+
return db.prepare("DELETE FROM oauth_grants WHERE id = ? AND user_id = ?").run(grantId, userId).changes > 0;
|
|
103
|
+
}
|
|
104
|
+
//# sourceMappingURL=store.js.map
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import webpush from "web-push";
|
|
2
|
+
import { db } from "./db/index.js";
|
|
3
|
+
import { assertPublicHttpUrl, createPublicHttpsAgent } from "./feeds/ssrfGuard.js";
|
|
4
|
+
const MAX_DEVICES_PER_USER = 10;
|
|
5
|
+
const VAPID_SUBJECT = "https://github.com/visualfusion/feedkeeper";
|
|
6
|
+
let vapid = null;
|
|
7
|
+
/** The server's own push keys, created on first use and kept in the database. */
|
|
8
|
+
function vapidKeys() {
|
|
9
|
+
if (vapid)
|
|
10
|
+
return vapid;
|
|
11
|
+
const read = (key) => db.prepare("SELECT value FROM system_settings WHERE key = ?").get(key)?.value;
|
|
12
|
+
let publicKey = read("vapid_public_key");
|
|
13
|
+
let privateKey = read("vapid_private_key");
|
|
14
|
+
if (!publicKey || !privateKey) {
|
|
15
|
+
({ publicKey, privateKey } = webpush.generateVAPIDKeys());
|
|
16
|
+
const save = db.prepare("INSERT INTO system_settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value");
|
|
17
|
+
db.transaction(() => {
|
|
18
|
+
save.run("vapid_public_key", publicKey);
|
|
19
|
+
save.run("vapid_private_key", privateKey);
|
|
20
|
+
})();
|
|
21
|
+
}
|
|
22
|
+
vapid = { publicKey, privateKey };
|
|
23
|
+
return vapid;
|
|
24
|
+
}
|
|
25
|
+
export function vapidPublicKey() {
|
|
26
|
+
return vapidKeys().publicKey;
|
|
27
|
+
}
|
|
28
|
+
const defaultSender = async (target, payload) => {
|
|
29
|
+
// An absolute deadline includes DNS, connection and response, not just socket inactivity.
|
|
30
|
+
// Destroying this delivery's agent also closes a stalled request before the next one starts.
|
|
31
|
+
const agent = createPublicHttpsAgent();
|
|
32
|
+
let expired = false;
|
|
33
|
+
let timer;
|
|
34
|
+
const deadline = new Promise((_resolve, reject) => {
|
|
35
|
+
timer = setTimeout(() => {
|
|
36
|
+
expired = true;
|
|
37
|
+
agent.destroy();
|
|
38
|
+
reject(new DOMException("Push deadline exceeded", "TimeoutError"));
|
|
39
|
+
}, 10_000);
|
|
40
|
+
});
|
|
41
|
+
try {
|
|
42
|
+
await Promise.race([(async () => {
|
|
43
|
+
// Browser-supplied endpoints retain preflight and socket-level SSRF protection.
|
|
44
|
+
await assertPublicHttpUrl(target.endpoint);
|
|
45
|
+
if (expired)
|
|
46
|
+
return;
|
|
47
|
+
const { publicKey, privateKey } = vapidKeys();
|
|
48
|
+
await webpush.sendNotification(target, payload, { TTL: 60 * 60, timeout: 10_000, agent, vapidDetails: { subject: VAPID_SUBJECT, publicKey, privateKey } });
|
|
49
|
+
})(), deadline]);
|
|
50
|
+
}
|
|
51
|
+
finally {
|
|
52
|
+
clearTimeout(timer);
|
|
53
|
+
agent.destroy();
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
let sender = defaultSender;
|
|
57
|
+
/** Replace the function that talks to the push service (tests). */
|
|
58
|
+
export function setPushSender(next) {
|
|
59
|
+
sender = next ?? defaultSender;
|
|
60
|
+
}
|
|
61
|
+
/** One attempt. The durable outbox owns recipient cursors and bounded retries. */
|
|
62
|
+
export function sendPushTarget(target, payload) {
|
|
63
|
+
return sender(target, JSON.stringify(payload));
|
|
64
|
+
}
|
|
65
|
+
export function saveDevice(userId, target) {
|
|
66
|
+
db.transaction(() => {
|
|
67
|
+
db.prepare(`INSERT INTO push_devices (user_id, endpoint, p256dh, auth) VALUES (?, ?, ?, ?)
|
|
68
|
+
ON CONFLICT(endpoint) DO UPDATE SET user_id = excluded.user_id, p256dh = excluded.p256dh, auth = excluded.auth`).run(userId, target.endpoint, target.keys.p256dh, target.keys.auth);
|
|
69
|
+
// Keep the newest devices only.
|
|
70
|
+
db.prepare(`DELETE FROM push_devices WHERE user_id = ? AND id NOT IN (SELECT id FROM push_devices WHERE user_id = ? ORDER BY id DESC LIMIT ?)`).run(userId, userId, MAX_DEVICES_PER_USER);
|
|
71
|
+
})();
|
|
72
|
+
}
|
|
73
|
+
export function removeDevice(userId, endpoint) {
|
|
74
|
+
db.prepare("DELETE FROM push_devices WHERE user_id = ? AND endpoint = ?").run(userId, endpoint);
|
|
75
|
+
}
|
|
76
|
+
export function hasDevice(userId, endpoint) {
|
|
77
|
+
return Boolean(db.prepare("SELECT 1 FROM push_devices WHERE user_id = ? AND endpoint = ?").get(userId, endpoint));
|
|
78
|
+
}
|
|
79
|
+
function devicesOf(userId) {
|
|
80
|
+
return db
|
|
81
|
+
.prepare("SELECT endpoint, p256dh, auth FROM push_devices WHERE user_id = ?")
|
|
82
|
+
.all(userId)
|
|
83
|
+
.map((row) => ({ endpoint: row.endpoint, keys: { p256dh: row.p256dh, auth: row.auth } }));
|
|
84
|
+
}
|
|
85
|
+
/** Deliver to every device of the user; devices the push service no longer knows are forgotten. */
|
|
86
|
+
export async function sendToUser(userId, payload, signal) {
|
|
87
|
+
const body = JSON.stringify(payload);
|
|
88
|
+
let delivered = 0;
|
|
89
|
+
for (const target of devicesOf(userId)) {
|
|
90
|
+
if (signal?.aborted)
|
|
91
|
+
break;
|
|
92
|
+
try {
|
|
93
|
+
await sender(target, body);
|
|
94
|
+
delivered++;
|
|
95
|
+
}
|
|
96
|
+
catch (error) {
|
|
97
|
+
const status = error.statusCode;
|
|
98
|
+
if (status === 404 || status === 410)
|
|
99
|
+
db.prepare("DELETE FROM push_devices WHERE endpoint = ?").run(target.endpoint);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return delivered;
|
|
103
|
+
}
|
|
104
|
+
function unreadTotal(userId) {
|
|
105
|
+
return db
|
|
106
|
+
.prepare(`SELECT COUNT(*) AS n FROM items i JOIN subscriptions s ON s.feed_id = i.feed_id AND s.user_id = ?
|
|
107
|
+
WHERE s.badge = 1 AND NOT EXISTS (SELECT 1 FROM item_reads r WHERE r.item_id = i.id AND r.user_id = ?)`)
|
|
108
|
+
.get(userId, userId).n;
|
|
109
|
+
}
|
|
110
|
+
/** A bounded article snapshot is selected by the outbox's committed upper item ID. */
|
|
111
|
+
export function newItemsPushPayload(feed, userId, label, latest) {
|
|
112
|
+
const muted = db.prepare("SELECT keyword FROM user_muted_keywords WHERE user_id = ?")
|
|
113
|
+
.all(userId).map(row => row.keyword.toLowerCase());
|
|
114
|
+
const fresh = latest.filter(item => {
|
|
115
|
+
const text = `${item.title ?? ""} ${item.content_snippet ?? ""}`.toLowerCase();
|
|
116
|
+
return !muted.some(keyword => text.includes(keyword));
|
|
117
|
+
});
|
|
118
|
+
if (!fresh.length)
|
|
119
|
+
return null;
|
|
120
|
+
const first = fresh[0].title?.trim() || feed.url;
|
|
121
|
+
return {
|
|
122
|
+
title: label ?? feed.title ?? new URL(feed.url).hostname,
|
|
123
|
+
body: fresh.length > 1 ? `${first} (+${fresh.length - 1})` : first,
|
|
124
|
+
url: fresh.length === 1 ? `/items?feed=${feed.id}&article=${fresh[0].id}` : `/items?feed=${feed.id}`,
|
|
125
|
+
tag: `feed-${feed.id}`,
|
|
126
|
+
unread: unreadTotal(userId),
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
/** Tell the people who asked for it that a feed has new articles. */
|
|
130
|
+
export async function notifyNewItems(feed, newItems, signal) {
|
|
131
|
+
const subscribers = db
|
|
132
|
+
.prepare("SELECT user_id, NULLIF(TRIM(label), '') AS label FROM subscriptions WHERE feed_id = ? AND notify = 1")
|
|
133
|
+
.all(feed.id);
|
|
134
|
+
if (subscribers.length === 0)
|
|
135
|
+
return;
|
|
136
|
+
const latest = db
|
|
137
|
+
.prepare("SELECT id, title, content_snippet FROM items WHERE feed_id = ? ORDER BY id DESC LIMIT ?")
|
|
138
|
+
.all(feed.id, Math.min(newItems, 10));
|
|
139
|
+
for (const subscriber of subscribers) {
|
|
140
|
+
if (signal?.aborted)
|
|
141
|
+
break;
|
|
142
|
+
const payload = newItemsPushPayload(feed, subscriber.user_id, subscriber.label, latest);
|
|
143
|
+
if (payload)
|
|
144
|
+
await sendToUser(subscriber.user_id, payload, signal);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
//# sourceMappingURL=push.js.map
|