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,249 @@
|
|
|
1
|
+
import { Router } from "express";
|
|
2
|
+
import rateLimit from "express-rate-limit";
|
|
3
|
+
import { generateAuthenticationOptions, generateRegistrationOptions, verifyAuthenticationResponse, verifyRegistrationResponse } from "@simplewebauthn/server";
|
|
4
|
+
import { db } from "../db/index.js";
|
|
5
|
+
import { config } from "../config.js";
|
|
6
|
+
import { findUserById } from "./users.js";
|
|
7
|
+
import { verifyPassword } from "./password.js";
|
|
8
|
+
import { requireSessionOrDevice } from "./middleware.js";
|
|
9
|
+
import { discardSecurityChallenge, hasTotp, issueSecurityChallenge, newRecoveryCodes, readSecurityChallenge, revokeSecuritySessions, seal, securityAccount, securityBinding, securityVersion, totpFor, verifySecondFactor } from "./security.js";
|
|
10
|
+
export function createSecurityRouter(options) {
|
|
11
|
+
const router = Router();
|
|
12
|
+
const origin = new URL(options.publicUrl ?? config.publicUrl).origin;
|
|
13
|
+
const rpID = new URL(origin).hostname;
|
|
14
|
+
const passkeysEnabled = origin.startsWith("https:") || rpID === "localhost" || rpID === "127.0.0.1";
|
|
15
|
+
const channel = options.channel ?? "web";
|
|
16
|
+
const binding = (req) => securityBinding(req, `${channel}-login`);
|
|
17
|
+
const managementBinding = (req) => securityBinding(req, `${channel}-management`);
|
|
18
|
+
const invalid = (res) => { res.status(400).json({ error: "invalid_or_expired_code" }); };
|
|
19
|
+
const limiter = rateLimit({ windowMs: 5 * 60_000, limit: 120, standardHeaders: true, legacyHeaders: false, message: { error: "too_many_requests" } });
|
|
20
|
+
router.use((_req, res, next) => { res.setHeader("Cache-Control", "private, no-store"); next(); });
|
|
21
|
+
router.use((req, res, next) => {
|
|
22
|
+
if (req.method !== "GET" && (req.headers["sec-fetch-site"] === "cross-site" || (req.headers.origin && req.headers.origin !== origin))) {
|
|
23
|
+
res.status(403).json({ error: "invalid_origin" });
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
next();
|
|
27
|
+
});
|
|
28
|
+
router.use(limiter);
|
|
29
|
+
const management = [options.authenticate ?? requireSessionOrDevice, (req, res, next) => {
|
|
30
|
+
if (req.tokenScope === "read" || (req.tokenKind && req.tokenKind !== "device")) {
|
|
31
|
+
res.status(403).json({ error: "forbidden" });
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
next();
|
|
35
|
+
}];
|
|
36
|
+
const proof = (req) => {
|
|
37
|
+
const row = readSecurityChallenge(req.body?.reauthToken, "reauth", managementBinding(req), true);
|
|
38
|
+
return row?.user_id === req.user.id;
|
|
39
|
+
};
|
|
40
|
+
const issueProof = (req, res) => res.json({ reauthToken: issueSecurityChallenge("reauth", req.user.id, managementBinding(req), {}) });
|
|
41
|
+
const list = (userId) => db.prepare("SELECT id, name, created_at, last_used_at FROM passkeys WHERE user_id = ? ORDER BY created_at").all(userId);
|
|
42
|
+
router.get("/config", (_req, res) => res.json({ passkeys: passkeysEnabled, totp: true }));
|
|
43
|
+
router.get("/pending", (req, res) => res.json({ challengeId: req.session?.pendingMfa ?? null }));
|
|
44
|
+
router.post("/mfa", (req, res) => {
|
|
45
|
+
const row = readSecurityChallenge(req.body?.challengeId, "mfa", binding(req));
|
|
46
|
+
if (!row?.user_id || !verifySecondFactor(row.user_id, req.body?.code)) {
|
|
47
|
+
invalid(res);
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
discardSecurityChallenge(req.body.challengeId);
|
|
51
|
+
options.completeLogin(req, res, row.user_id, JSON.parse(row.payload));
|
|
52
|
+
});
|
|
53
|
+
router.post("/passkeys/options", async (req, res) => {
|
|
54
|
+
if (!passkeysEnabled) {
|
|
55
|
+
res.status(503).json({ error: "https_required" });
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
const context = options.loginContext?.(req) ?? (channel === "web" ? { remember: req.body?.remember === true } : null);
|
|
59
|
+
if (context === null) {
|
|
60
|
+
res.status(400).json({ error: "invalid_input" });
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
const publicKey = await generateAuthenticationOptions({ rpID, userVerification: "required" });
|
|
64
|
+
const challengeId = issueSecurityChallenge("passkey-login", null, binding(req), { challenge: publicKey.challenge, context });
|
|
65
|
+
res.json({ challengeId, publicKey });
|
|
66
|
+
});
|
|
67
|
+
async function assertion(req, purpose, keyBinding) {
|
|
68
|
+
const row = readSecurityChallenge(req.body?.challengeId, purpose, keyBinding, true);
|
|
69
|
+
if (!row)
|
|
70
|
+
return null;
|
|
71
|
+
const credential = req.body?.credential;
|
|
72
|
+
if (typeof credential?.id !== "string")
|
|
73
|
+
return null;
|
|
74
|
+
const saved = db.prepare("SELECT * FROM passkeys WHERE id = ?").get(credential.id);
|
|
75
|
+
if (!saved || (row.user_id && saved.user_id !== row.user_id))
|
|
76
|
+
return null;
|
|
77
|
+
const handle = credential.response?.userHandle;
|
|
78
|
+
// Discoverable login must identify the account. A scoped reauthentication may omit the handle.
|
|
79
|
+
if ((!row.user_id && !handle) || (handle != null && handle !== securityAccount(saved.user_id).user_handle))
|
|
80
|
+
return null;
|
|
81
|
+
const version = securityVersion(saved.user_id);
|
|
82
|
+
try {
|
|
83
|
+
const result = await verifyAuthenticationResponse({ response: credential, expectedChallenge: JSON.parse(row.payload).challenge, expectedOrigin: origin, expectedRPID: rpID, requireUserVerification: true,
|
|
84
|
+
credential: { id: saved.id, publicKey: new Uint8Array(saved.public_key), counter: saved.counter, transports: JSON.parse(saved.transports) } });
|
|
85
|
+
if (!result.verified || version !== securityVersion(saved.user_id))
|
|
86
|
+
return null;
|
|
87
|
+
// Avoid accepting two assertions against the same older counter after asynchronous verification.
|
|
88
|
+
const updated = db.prepare("UPDATE passkeys SET counter = ?, last_used_at = ? WHERE id = ? AND counter = ?").run(result.authenticationInfo.newCounter, new Date().toISOString(), saved.id, saved.counter);
|
|
89
|
+
if (!updated.changes)
|
|
90
|
+
return null;
|
|
91
|
+
return { userId: saved.user_id, payload: JSON.parse(row.payload) };
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
router.post("/passkeys/verify", async (req, res) => {
|
|
98
|
+
const result = await assertion(req, "passkey-login", binding(req));
|
|
99
|
+
if (!result) {
|
|
100
|
+
invalid(res);
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
options.completeLogin(req, res, result.userId, result.payload.context);
|
|
104
|
+
});
|
|
105
|
+
router.get("/", ...management, (req, res) => res.json({ totpEnabled: hasTotp(req.user.id), passkeysEnabled, emailReauthEnabled: options.emailReauthEnabled?.(req.user.id) === true, passkeys: list(req.user.id), recoveryCodesRemaining: db.prepare("SELECT COUNT(*) AS n FROM recovery_codes WHERE user_id = ?").get(req.user.id).n }));
|
|
106
|
+
router.post("/reauth", ...management, (req, res) => {
|
|
107
|
+
const user = findUserById(req.user.id);
|
|
108
|
+
if (typeof req.body?.password !== "string" || req.body.password.length > 1024 || !verifyPassword(req.body.password, user.password_hash) || (hasTotp(user.id) && !verifySecondFactor(user.id, req.body.code))) {
|
|
109
|
+
res.status(401).json({ error: "invalid_credentials" });
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
issueProof(req, res);
|
|
113
|
+
});
|
|
114
|
+
router.post("/reauth/passkey/options", ...management, async (req, res) => {
|
|
115
|
+
if (!passkeysEnabled) {
|
|
116
|
+
res.status(503).json({ error: "https_required" });
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
const publicKey = await generateAuthenticationOptions({ rpID, userVerification: "required", allowCredentials: list(req.user.id).map(key => ({ id: key.id })) });
|
|
120
|
+
res.json({ challengeId: issueSecurityChallenge("passkey-reauth", req.user.id, managementBinding(req), { challenge: publicKey.challenge }), publicKey });
|
|
121
|
+
});
|
|
122
|
+
router.post("/reauth/passkey/verify", ...management, async (req, res) => {
|
|
123
|
+
const result = await assertion(req, "passkey-reauth", managementBinding(req));
|
|
124
|
+
if (!result || result.userId !== req.user.id) {
|
|
125
|
+
invalid(res);
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
issueProof(req, res);
|
|
129
|
+
});
|
|
130
|
+
router.post("/passkeys/register/options", ...management, async (req, res) => {
|
|
131
|
+
if (!passkeysEnabled) {
|
|
132
|
+
res.status(503).json({ error: "https_required" });
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
if (!proof(req)) {
|
|
136
|
+
res.status(401).json({ error: "reauth_required" });
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
const user = findUserById(req.user.id);
|
|
140
|
+
if (list(user.id).length >= 20) {
|
|
141
|
+
res.status(400).json({ error: "passkey_limit" });
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
const publicKey = await generateRegistrationOptions({ rpID, rpName: "FeedKeeper", userName: user.email, userDisplayName: user.display_name, userID: Buffer.from(securityAccount(user.id).user_handle, "base64url"), attestationType: "none", excludeCredentials: list(user.id).map(key => ({ id: key.id })), authenticatorSelection: { residentKey: "required", userVerification: "required" } });
|
|
145
|
+
res.json({ challengeId: issueSecurityChallenge("passkey-register", user.id, managementBinding(req), { challenge: publicKey.challenge }), publicKey });
|
|
146
|
+
});
|
|
147
|
+
router.post("/passkeys/register/verify", ...management, async (req, res) => {
|
|
148
|
+
const row = readSecurityChallenge(req.body?.challengeId, "passkey-register", managementBinding(req), true);
|
|
149
|
+
if (row?.user_id !== req.user.id || typeof req.body?.name !== "string" || !req.body.name.trim() || req.body.name.length > 100) {
|
|
150
|
+
invalid(res);
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
try {
|
|
154
|
+
const result = await verifyRegistrationResponse({ response: req.body.credential, expectedChallenge: JSON.parse(row.payload).challenge, expectedOrigin: origin, expectedRPID: rpID, requireUserVerification: true });
|
|
155
|
+
if (!result.verified || !result.registrationInfo || row.version !== securityVersion(req.user.id)) {
|
|
156
|
+
invalid(res);
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
if (list(req.user.id).length >= 20) {
|
|
160
|
+
res.status(400).json({ error: "passkey_limit" });
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
const key = result.registrationInfo.credential;
|
|
164
|
+
const transports = key.transports ?? [];
|
|
165
|
+
db.prepare("INSERT INTO passkeys (id, user_id, public_key, counter, transports, name, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)").run(key.id, req.user.id, Buffer.from(key.publicKey), key.counter, JSON.stringify(transports), req.body.name.trim(), new Date().toISOString());
|
|
166
|
+
res.status(201).json({ passkeys: list(req.user.id) });
|
|
167
|
+
}
|
|
168
|
+
catch {
|
|
169
|
+
invalid(res);
|
|
170
|
+
}
|
|
171
|
+
});
|
|
172
|
+
router.delete("/passkeys/:id", ...management, (req, res) => {
|
|
173
|
+
if (!proof(req)) {
|
|
174
|
+
res.status(401).json({ error: "reauth_required" });
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
// A host may have passwordless accounts: never remove the final usable passkey through this endpoint.
|
|
178
|
+
if (list(req.user.id).length <= 1 && !(options.canRemoveLastPasskey?.(req.user.id) ?? true)) {
|
|
179
|
+
res.status(409).json({ error: "last_passkey" });
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
db.prepare("DELETE FROM passkeys WHERE id = ? AND user_id = ?").run(String(req.params.id), req.user.id);
|
|
183
|
+
res.status(204).end();
|
|
184
|
+
});
|
|
185
|
+
router.post("/totp/setup", ...management, (req, res) => {
|
|
186
|
+
if (!proof(req)) {
|
|
187
|
+
res.status(401).json({ error: "reauth_required" });
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
if (hasTotp(req.user.id)) {
|
|
191
|
+
res.status(409).json({ error: "totp_already_enabled" });
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
const totp = totpFor(undefined, req.user.email);
|
|
195
|
+
res.json({ challengeId: issueSecurityChallenge("totp-setup", req.user.id, managementBinding(req), { secret: seal(totp.secret.base32) }), secret: totp.secret.base32, uri: totp.toString() });
|
|
196
|
+
});
|
|
197
|
+
router.post("/totp/confirm", ...management, (req, res) => {
|
|
198
|
+
const row = readSecurityChallenge(req.body?.challengeId, "totp-setup", managementBinding(req));
|
|
199
|
+
if (row?.user_id !== req.user.id || typeof req.body?.code !== "string" || !/^\d{6}$/.test(req.body.code) || hasTotp(req.user.id)) {
|
|
200
|
+
invalid(res);
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
const result = db.transaction(() => {
|
|
204
|
+
db.prepare("UPDATE account_security SET totp_secret = ?, totp_last_step = -1 WHERE user_id = ?").run(JSON.parse(row.payload).secret, row.user_id);
|
|
205
|
+
if (!verifySecondFactor(row.user_id, req.body.code)) {
|
|
206
|
+
db.prepare("UPDATE account_security SET totp_secret = NULL WHERE user_id = ?").run(row.user_id);
|
|
207
|
+
return null;
|
|
208
|
+
}
|
|
209
|
+
const recoveryCodes = newRecoveryCodes(row.user_id);
|
|
210
|
+
revokeSecuritySessions(row.user_id);
|
|
211
|
+
// Keep this authenticated browser open so the recovery codes can be saved; other sessions are revoked.
|
|
212
|
+
if (!req.headers.authorization)
|
|
213
|
+
req.session.securityVersion = securityVersion(row.user_id);
|
|
214
|
+
return recoveryCodes;
|
|
215
|
+
})();
|
|
216
|
+
if (!result) {
|
|
217
|
+
invalid(res);
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
res.json({ recoveryCodes: result });
|
|
221
|
+
});
|
|
222
|
+
router.post("/totp/disable", ...management, (req, res) => {
|
|
223
|
+
if (!proof(req)) {
|
|
224
|
+
res.status(401).json({ error: "reauth_required" });
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
db.transaction(() => {
|
|
228
|
+
db.prepare("UPDATE account_security SET totp_secret = NULL, totp_last_step = -1 WHERE user_id = ?").run(req.user.id);
|
|
229
|
+
db.prepare("DELETE FROM recovery_codes WHERE user_id = ?").run(req.user.id);
|
|
230
|
+
revokeSecuritySessions(req.user.id);
|
|
231
|
+
if (!req.headers.authorization)
|
|
232
|
+
req.session.securityVersion = securityVersion(req.user.id);
|
|
233
|
+
})();
|
|
234
|
+
res.status(204).end();
|
|
235
|
+
});
|
|
236
|
+
router.post("/recovery-codes", ...management, (req, res) => {
|
|
237
|
+
if (!proof(req)) {
|
|
238
|
+
res.status(401).json({ error: "reauth_required" });
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
if (!hasTotp(req.user.id)) {
|
|
242
|
+
res.status(400).json({ error: "totp_required" });
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
res.json({ recoveryCodes: db.transaction(() => newRecoveryCodes(req.user.id))() });
|
|
246
|
+
});
|
|
247
|
+
return router;
|
|
248
|
+
}
|
|
249
|
+
//# sourceMappingURL=securityRoutes.js.map
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { randomBytes, createHash, timingSafeEqual } from "node:crypto";
|
|
2
|
+
import { db } from "../db/index.js";
|
|
3
|
+
import { resolveAccessToken } from "../oauth/store.js";
|
|
4
|
+
const TOKEN_PREFIX = "fk_";
|
|
5
|
+
const OAUTH_ACCESS_PREFIX = "fk_oat_";
|
|
6
|
+
function hashToken(token) {
|
|
7
|
+
return createHash("sha256").update(token).digest("hex");
|
|
8
|
+
}
|
|
9
|
+
// Returns the plaintext token once; only the hash is persisted.
|
|
10
|
+
export function createPersonalAccessToken(userId, name, scope, device) {
|
|
11
|
+
const secret = randomBytes(32).toString("base64url");
|
|
12
|
+
const token = `${TOKEN_PREFIX}${device ? "dev_" : ""}${secret}`;
|
|
13
|
+
const tokenHash = hashToken(token);
|
|
14
|
+
const tokenPrefix = token.slice(0, 10);
|
|
15
|
+
const result = db
|
|
16
|
+
.prepare(`INSERT INTO personal_access_tokens (user_id, name, token_hash, token_prefix, scope, kind, platform, app_version)
|
|
17
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
18
|
+
.run(userId, name, tokenHash, tokenPrefix, scope, device ? "device" : "api", device?.platform ?? null, device?.appVersion ?? null);
|
|
19
|
+
return { id: Number(result.lastInsertRowid), token };
|
|
20
|
+
}
|
|
21
|
+
export function resolveToken(token) {
|
|
22
|
+
if (!token.startsWith(TOKEN_PREFIX))
|
|
23
|
+
return null;
|
|
24
|
+
if (token.startsWith(OAUTH_ACCESS_PREFIX)) {
|
|
25
|
+
const grant = resolveAccessToken(token);
|
|
26
|
+
return grant ? { userId: grant.userId, scope: grant.scope, kind: "oauth", tokenId: grant.grantId } : null;
|
|
27
|
+
}
|
|
28
|
+
const tokenHash = hashToken(token);
|
|
29
|
+
const row = db
|
|
30
|
+
.prepare("SELECT * FROM personal_access_tokens WHERE token_hash = ?")
|
|
31
|
+
.get(tokenHash);
|
|
32
|
+
if (!row)
|
|
33
|
+
return null;
|
|
34
|
+
// Defense in depth: even though the lookup is by exact hash match, compare
|
|
35
|
+
// in constant time to avoid timing side channels on the hash comparison.
|
|
36
|
+
const stored = Buffer.from(row.token_hash, "hex");
|
|
37
|
+
const provided = Buffer.from(tokenHash, "hex");
|
|
38
|
+
if (stored.length !== provided.length || !timingSafeEqual(stored, provided)) {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
db.prepare("UPDATE personal_access_tokens SET last_used_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?").run(row.id);
|
|
42
|
+
return { userId: row.user_id, scope: row.scope, kind: row.kind, tokenId: row.id };
|
|
43
|
+
}
|
|
44
|
+
/** Hand-made tokens only; paired devices are listed separately. */
|
|
45
|
+
export function listTokensForUser(userId) {
|
|
46
|
+
return db
|
|
47
|
+
.prepare("SELECT * FROM personal_access_tokens WHERE user_id = ? AND kind = 'api' ORDER BY created_at DESC")
|
|
48
|
+
.all(userId)
|
|
49
|
+
.map(({ token_hash, ...rest }) => rest);
|
|
50
|
+
}
|
|
51
|
+
export function deleteToken(userId, tokenId) {
|
|
52
|
+
db.prepare("DELETE FROM personal_access_tokens WHERE id = ? AND user_id = ? AND kind = 'api'").run(tokenId, userId);
|
|
53
|
+
}
|
|
54
|
+
export function listDevicesForUser(userId) {
|
|
55
|
+
return db
|
|
56
|
+
.prepare("SELECT * FROM personal_access_tokens WHERE user_id = ? AND kind = 'device' ORDER BY created_at DESC")
|
|
57
|
+
.all(userId)
|
|
58
|
+
.map(({ token_hash, ...rest }) => rest);
|
|
59
|
+
}
|
|
60
|
+
/** Revokes a device; its token stops working immediately. Returns whether one was removed. */
|
|
61
|
+
export function deleteDevice(userId, deviceId) {
|
|
62
|
+
return db.prepare("DELETE FROM personal_access_tokens WHERE id = ? AND user_id = ? AND kind = 'device'").run(deviceId, userId).changes > 0;
|
|
63
|
+
}
|
|
64
|
+
//# sourceMappingURL=tokens.js.map
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { db } from "../db/index.js";
|
|
2
|
+
import { hashPassword } from "./password.js";
|
|
3
|
+
// Joins only the avatar timestamp; the image itself is loaded on demand.
|
|
4
|
+
const SELECT_USER = `SELECT users.*, user_avatars.updated_at AS avatar_updated_at
|
|
5
|
+
FROM users LEFT JOIN user_avatars ON user_avatars.user_id = users.id`;
|
|
6
|
+
export function toPublicUser(user) {
|
|
7
|
+
const { password_hash, ...rest } = user;
|
|
8
|
+
return rest;
|
|
9
|
+
}
|
|
10
|
+
/** Marks the introduction as seen; it stays marked once it is (the first time counts). */
|
|
11
|
+
export function dismissIntro(userId) {
|
|
12
|
+
db.prepare("UPDATE users SET intro_dismissed_at = COALESCE(intro_dismissed_at, strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) WHERE id = ?").run(userId);
|
|
13
|
+
}
|
|
14
|
+
export function findUserByEmail(email) {
|
|
15
|
+
return db.prepare(`${SELECT_USER} WHERE users.email = ?`).get(email.toLowerCase());
|
|
16
|
+
}
|
|
17
|
+
export function findUserById(id) {
|
|
18
|
+
return db.prepare(`${SELECT_USER} WHERE users.id = ?`).get(id);
|
|
19
|
+
}
|
|
20
|
+
export function createUser(input) {
|
|
21
|
+
const result = db
|
|
22
|
+
.prepare(`INSERT INTO users (email, password_hash, display_name, role)
|
|
23
|
+
VALUES (?, ?, ?, ?)`)
|
|
24
|
+
.run(input.email.toLowerCase(), hashPassword(input.password), input.displayName, input.role);
|
|
25
|
+
return findUserById(Number(result.lastInsertRowid));
|
|
26
|
+
}
|
|
27
|
+
export function updateUserPassword(userId, newPassword) {
|
|
28
|
+
db.prepare("UPDATE users SET password_hash = ? WHERE id = ?").run(hashPassword(newPassword), userId);
|
|
29
|
+
}
|
|
30
|
+
export function listUsers() {
|
|
31
|
+
return db
|
|
32
|
+
.prepare(`${SELECT_USER} ORDER BY users.created_at ASC`)
|
|
33
|
+
.all()
|
|
34
|
+
.map(toPublicUser);
|
|
35
|
+
}
|
|
36
|
+
export function updateDisplayName(userId, displayName) {
|
|
37
|
+
db.prepare("UPDATE users SET display_name = ? WHERE id = ?").run(displayName, userId);
|
|
38
|
+
}
|
|
39
|
+
export function getUserAvatar(userId) {
|
|
40
|
+
return db
|
|
41
|
+
.prepare("SELECT mime_type, data FROM user_avatars WHERE user_id = ?")
|
|
42
|
+
.get(userId);
|
|
43
|
+
}
|
|
44
|
+
export function setUserAvatar(userId, mimeType, data) {
|
|
45
|
+
db.prepare(`INSERT INTO user_avatars (user_id, mime_type, data) VALUES (?, ?, ?)
|
|
46
|
+
ON CONFLICT(user_id) DO UPDATE SET
|
|
47
|
+
mime_type = excluded.mime_type,
|
|
48
|
+
data = excluded.data,
|
|
49
|
+
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')`).run(userId, mimeType, data);
|
|
50
|
+
}
|
|
51
|
+
export function deleteUserAvatar(userId) {
|
|
52
|
+
db.prepare("DELETE FROM user_avatars WHERE user_id = ?").run(userId);
|
|
53
|
+
}
|
|
54
|
+
//# sourceMappingURL=users.js.map
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import Database from "better-sqlite3";
|
|
2
|
+
import { copyFileSync, cpSync, existsSync, mkdirSync, mkdtempSync, readdirSync, rmSync, statSync, unlinkSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { dirname, join, resolve } from "node:path";
|
|
5
|
+
import { config } from "./config.js";
|
|
6
|
+
function assertHealthy(database) {
|
|
7
|
+
const result = database.pragma("integrity_check", { simple: true });
|
|
8
|
+
if (result !== "ok")
|
|
9
|
+
throw new Error(`SQLite integrity check failed: ${String(result)}`);
|
|
10
|
+
}
|
|
11
|
+
// Images of saved articles live next to the backup file as "<backup>.archive".
|
|
12
|
+
const archiveCopyOf = (backupFile) => `${backupFile}.archive`;
|
|
13
|
+
function hasFiles(directory) {
|
|
14
|
+
return existsSync(directory) && readdirSync(directory).length > 0;
|
|
15
|
+
}
|
|
16
|
+
async function main() {
|
|
17
|
+
const [action, filename, flag] = process.argv.slice(2);
|
|
18
|
+
if (!(["backup", "restore"].includes(action) && filename)) {
|
|
19
|
+
throw new Error("Usage: npm run db:backup -- <destination> | npm run db:restore -- <backup> --force");
|
|
20
|
+
}
|
|
21
|
+
const databasePath = resolve(config.databasePath);
|
|
22
|
+
const filePath = resolve(filename);
|
|
23
|
+
if (databasePath === filePath)
|
|
24
|
+
throw new Error("Source and destination must differ");
|
|
25
|
+
if (action === "backup") {
|
|
26
|
+
if (!existsSync(databasePath))
|
|
27
|
+
throw new Error(`Database not found: ${databasePath}`);
|
|
28
|
+
if (existsSync(filePath))
|
|
29
|
+
throw new Error(`Backup already exists: ${filePath}`);
|
|
30
|
+
mkdirSync(dirname(filePath), { recursive: true, mode: 0o700 });
|
|
31
|
+
process.umask(0o077);
|
|
32
|
+
const { db } = await import("./db/index.js");
|
|
33
|
+
try {
|
|
34
|
+
await db.backup(filePath);
|
|
35
|
+
const saved = new Database(filePath, { readonly: true, fileMustExist: true });
|
|
36
|
+
try {
|
|
37
|
+
assertHealthy(saved);
|
|
38
|
+
}
|
|
39
|
+
finally {
|
|
40
|
+
saved.close();
|
|
41
|
+
}
|
|
42
|
+
console.log(`Backup verified: ${filePath}`);
|
|
43
|
+
const archivePath = resolve(config.archivePath);
|
|
44
|
+
if (hasFiles(archivePath)) {
|
|
45
|
+
cpSync(archivePath, archiveCopyOf(filePath), { recursive: true, errorOnExist: true, force: false });
|
|
46
|
+
console.log(`Archived images copied: ${archiveCopyOf(filePath)}`);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
catch (error) {
|
|
50
|
+
if (existsSync(filePath))
|
|
51
|
+
unlinkSync(filePath);
|
|
52
|
+
rmSync(archiveCopyOf(filePath), { recursive: true, force: true });
|
|
53
|
+
throw error;
|
|
54
|
+
}
|
|
55
|
+
finally {
|
|
56
|
+
db.close();
|
|
57
|
+
}
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
if (flag !== "--force")
|
|
61
|
+
throw new Error("Restore replaces the configured database. Stop FeedKeeper and pass --force.");
|
|
62
|
+
if (!existsSync(filePath) || !statSync(filePath).isFile())
|
|
63
|
+
throw new Error(`Backup file not found: ${filePath}`);
|
|
64
|
+
// A backup is a WAL-mode database, and SQLite needs to create a -shm file next to a database it opens, even for
|
|
65
|
+
// reading. The backup may sit where nothing can be created (a single file mounted into a container, a read-only
|
|
66
|
+
// volume), so it is opened from a copy in the temporary folder.
|
|
67
|
+
const scratch = mkdtempSync(join(tmpdir(), "feedkeeper-restore-"));
|
|
68
|
+
const copy = join(scratch, "backup.sqlite");
|
|
69
|
+
copyFileSync(filePath, copy);
|
|
70
|
+
const source = new Database(copy, { readonly: true, fileMustExist: true });
|
|
71
|
+
try {
|
|
72
|
+
assertHealthy(source);
|
|
73
|
+
mkdirSync(dirname(databasePath), { recursive: true, mode: 0o700 });
|
|
74
|
+
process.umask(0o077);
|
|
75
|
+
await source.backup(databasePath);
|
|
76
|
+
}
|
|
77
|
+
finally {
|
|
78
|
+
source.close();
|
|
79
|
+
rmSync(scratch, { recursive: true, force: true });
|
|
80
|
+
}
|
|
81
|
+
const restored = new Database(databasePath, { readonly: true, fileMustExist: true });
|
|
82
|
+
try {
|
|
83
|
+
assertHealthy(restored);
|
|
84
|
+
}
|
|
85
|
+
finally {
|
|
86
|
+
restored.close();
|
|
87
|
+
}
|
|
88
|
+
console.log(`Database restored and verified: ${databasePath}`);
|
|
89
|
+
const archiveBackup = archiveCopyOf(filePath);
|
|
90
|
+
if (existsSync(archiveBackup)) {
|
|
91
|
+
const archivePath = resolve(config.archivePath);
|
|
92
|
+
rmSync(archivePath, { recursive: true, force: true });
|
|
93
|
+
cpSync(archiveBackup, archivePath, { recursive: true });
|
|
94
|
+
console.log(`Archived images restored: ${archivePath}`);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
main().catch((error) => {
|
|
98
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
99
|
+
process.exitCode = 1;
|
|
100
|
+
});
|
|
101
|
+
//# sourceMappingURL=backup-cli.js.map
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { dirname, join, resolve } from "node:path";
|
|
5
|
+
export const HELP = `FeedKeeper: a self-hosted RSS reader and remote MCP server
|
|
6
|
+
|
|
7
|
+
Usage:
|
|
8
|
+
feedkeeper [options] Start the server
|
|
9
|
+
feedkeeper backup <file> Back up the database (see docs for restore)
|
|
10
|
+
feedkeeper restore <file> --force Restore the database from a backup
|
|
11
|
+
|
|
12
|
+
Options:
|
|
13
|
+
--port <number> Port to listen on (default: 3000, or PORT)
|
|
14
|
+
--public-url <url> Address people reach this instance at (default: http://localhost:<port>)
|
|
15
|
+
--data-dir <folder> Where the database, archive and session secret live
|
|
16
|
+
(default: the folder of DATABASE_PATH, otherwise ~/.feedkeeper)
|
|
17
|
+
-v, --version Print the version
|
|
18
|
+
-h, --help Print this help
|
|
19
|
+
|
|
20
|
+
A .env file in the current folder is read first; real environment variables win over it.
|
|
21
|
+
Without SESSION_SECRET, one is generated on first start and kept in the data folder.
|
|
22
|
+
All other settings are the environment variables of .env.example in the repository.`;
|
|
23
|
+
export class UsageError extends Error {
|
|
24
|
+
}
|
|
25
|
+
function takeValue(argv, index, flag) {
|
|
26
|
+
const argument = argv[index];
|
|
27
|
+
const inline = argument.indexOf("=");
|
|
28
|
+
if (inline !== -1)
|
|
29
|
+
return [argument.slice(inline + 1), index];
|
|
30
|
+
const next = argv[index + 1];
|
|
31
|
+
if (next === undefined || next.startsWith("-"))
|
|
32
|
+
throw new UsageError(`${flag} needs a value`);
|
|
33
|
+
return [next, index + 1];
|
|
34
|
+
}
|
|
35
|
+
/** Turns the command line into options, or throws a UsageError that says what is wrong. */
|
|
36
|
+
export function parseArgs(argv) {
|
|
37
|
+
const options = { action: "start", forwarded: [] };
|
|
38
|
+
for (let index = 0; index < argv.length; index++) {
|
|
39
|
+
const argument = argv[index];
|
|
40
|
+
const name = argument.split("=")[0];
|
|
41
|
+
if (argument === "-h" || argument === "--help")
|
|
42
|
+
return { ...options, action: "help" };
|
|
43
|
+
if (argument === "-v" || argument === "--version")
|
|
44
|
+
return { ...options, action: "version" };
|
|
45
|
+
if (name === "--port") {
|
|
46
|
+
const [value, next] = takeValue(argv, index, "--port");
|
|
47
|
+
const port = Number(value);
|
|
48
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535)
|
|
49
|
+
throw new UsageError(`--port must be a number from 1 to 65535, got "${value}"`);
|
|
50
|
+
options.port = port;
|
|
51
|
+
index = next;
|
|
52
|
+
}
|
|
53
|
+
else if (name === "--public-url") {
|
|
54
|
+
const [value, next] = takeValue(argv, index, "--public-url");
|
|
55
|
+
let url;
|
|
56
|
+
try {
|
|
57
|
+
url = new URL(value);
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
throw new UsageError(`--public-url must be an address such as https://feeds.example.org, got "${value}"`);
|
|
61
|
+
}
|
|
62
|
+
if (url.protocol !== "http:" && url.protocol !== "https:")
|
|
63
|
+
throw new UsageError(`--public-url must start with http:// or https://, got "${value}"`);
|
|
64
|
+
options.publicUrl = value.replace(/\/+$/, "");
|
|
65
|
+
index = next;
|
|
66
|
+
}
|
|
67
|
+
else if (name === "--data-dir") {
|
|
68
|
+
const [value, next] = takeValue(argv, index, "--data-dir");
|
|
69
|
+
options.dataDir = value;
|
|
70
|
+
index = next;
|
|
71
|
+
}
|
|
72
|
+
else if (options.action === "start" && (argument === "backup" || argument === "restore")) {
|
|
73
|
+
options.action = argument;
|
|
74
|
+
options.forwarded.push(argument);
|
|
75
|
+
}
|
|
76
|
+
else if (options.action !== "start" && (argument === "--force" || !argument.startsWith("-"))) {
|
|
77
|
+
// The file name and --force belong to the backup tool; options above are the only ones read here.
|
|
78
|
+
options.forwarded.push(argument);
|
|
79
|
+
}
|
|
80
|
+
else {
|
|
81
|
+
throw new UsageError(`Unknown ${argument.startsWith("-") ? "option" : "command"} "${argument}"`);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return options;
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Fills the settings the server needs and an operator should not have to think about on a first start: a data
|
|
88
|
+
* folder, the database path, the public address that follows the port, and a session secret. Values that are
|
|
89
|
+
* already set are never replaced, apart from the ones given as options.
|
|
90
|
+
*/
|
|
91
|
+
export function prepareEnvironment(options, env, home = homedir(), persist = options.action === "start") {
|
|
92
|
+
if (options.port !== undefined)
|
|
93
|
+
env.PORT = String(options.port);
|
|
94
|
+
if (options.publicUrl !== undefined)
|
|
95
|
+
env.PUBLIC_URL = options.publicUrl;
|
|
96
|
+
if (!env.PUBLIC_URL?.trim())
|
|
97
|
+
env.PUBLIC_URL = `http://localhost:${env.PORT?.trim() || 3000}`;
|
|
98
|
+
const configured = env.DATABASE_PATH?.trim();
|
|
99
|
+
const dataDir = resolve(options.dataDir ?? (env.FEEDKEEPER_DATA_DIR?.trim() || (configured ? dirname(resolve(configured)) : join(home, ".feedkeeper"))));
|
|
100
|
+
if (!configured || options.dataDir !== undefined)
|
|
101
|
+
env.DATABASE_PATH = join(dataDir, "feedkeeper.sqlite");
|
|
102
|
+
if (persist)
|
|
103
|
+
mkdirSync(dirname(resolve(env.DATABASE_PATH)), { recursive: true, mode: 0o700 });
|
|
104
|
+
let secretCreated = false;
|
|
105
|
+
let secretPath = null;
|
|
106
|
+
if (!env.SESSION_SECRET?.trim()) {
|
|
107
|
+
secretPath = join(dataDir, "session-secret");
|
|
108
|
+
const read = () => (existsSync(secretPath) ? readFileSync(secretPath, "utf8").trim() : "");
|
|
109
|
+
let secret = read();
|
|
110
|
+
if (!secret && !persist) {
|
|
111
|
+
// Backup and restore never sign anything, but the configuration insists on a secret: a throwaway one, not stored.
|
|
112
|
+
env.SESSION_SECRET = randomBytes(32).toString("hex");
|
|
113
|
+
return { dataDir, secretCreated: false, secretPath: null };
|
|
114
|
+
}
|
|
115
|
+
if (!secret) {
|
|
116
|
+
mkdirSync(dataDir, { recursive: true, mode: 0o700 });
|
|
117
|
+
try {
|
|
118
|
+
writeFileSync(secretPath, `${randomBytes(32).toString("hex")}\n`, { flag: "wx", mode: 0o600 });
|
|
119
|
+
secretCreated = true;
|
|
120
|
+
}
|
|
121
|
+
catch (error) {
|
|
122
|
+
// Another process created it in the meantime: use theirs.
|
|
123
|
+
if (error.code !== "EEXIST")
|
|
124
|
+
throw error;
|
|
125
|
+
}
|
|
126
|
+
secret = read();
|
|
127
|
+
}
|
|
128
|
+
if (!secret)
|
|
129
|
+
throw new Error(`Could not read or create the session secret at ${secretPath}`);
|
|
130
|
+
env.SESSION_SECRET = secret;
|
|
131
|
+
}
|
|
132
|
+
env.NODE_ENV ??= "production";
|
|
133
|
+
return { dataDir, secretCreated, secretPath };
|
|
134
|
+
}
|
|
135
|
+
//# sourceMappingURL=cli.js.map
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { isIP } from "node:net";
|
|
2
|
+
/**
|
|
3
|
+
* Takes the client address from a header set by a CDN, such as Cloudflare's CF-Connecting-IP.
|
|
4
|
+
*
|
|
5
|
+
* Some web servers in front of the app replace X-Forwarded-For with the address of the CDN edge
|
|
6
|
+
* that connected to them, so `trust proxy` alone finds only that edge. The header is used only when
|
|
7
|
+
* the request reached the app through the trusted proxies listed in TRUST_PROXY: `req.ip` must itself
|
|
8
|
+
* be one of them. A client that bypasses the CDN is not trusted, so its own header is ignored.
|
|
9
|
+
* Hop counts cannot tell a CDN edge from a client, so the header needs a TRUST_PROXY list.
|
|
10
|
+
*/
|
|
11
|
+
export function clientAddressFromHeader(header, trustProxy) {
|
|
12
|
+
const name = header?.trim().toLowerCase() || null;
|
|
13
|
+
const enabled = name !== null && Array.isArray(trustProxy);
|
|
14
|
+
return (req, _res, next) => {
|
|
15
|
+
if (enabled) {
|
|
16
|
+
const value = req.headers[name];
|
|
17
|
+
const address = typeof value === "string" ? value.trim() : "";
|
|
18
|
+
const trusted = req.app.get("trust proxy fn");
|
|
19
|
+
if (address && isIP(address) && req.ip && trusted(req.ip, 0)) {
|
|
20
|
+
Object.defineProperty(req, "ip", { value: address, configurable: true, enumerable: true });
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
next();
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
//# sourceMappingURL=clientAddress.js.map
|