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,48 @@
|
|
|
1
|
+
import { contentMetadata } from "../../feeds/articleContent.js";
|
|
2
|
+
import { db } from "../../db/index.js";
|
|
3
|
+
import { serializeSubscription } from "../../sync/changeLog.js";
|
|
4
|
+
/** A subscription as the list shows it: the synced fields plus counts and polling health. */
|
|
5
|
+
export function serializeSubscriptionDetail(subscription) {
|
|
6
|
+
return {
|
|
7
|
+
...serializeSubscription(subscription),
|
|
8
|
+
folderName: subscription.folder_name,
|
|
9
|
+
unreadCount: subscription.unread_count,
|
|
10
|
+
lastPolledAt: subscription.last_polled_at,
|
|
11
|
+
lastSuccessAt: subscription.last_success_at,
|
|
12
|
+
lastError: subscription.last_error,
|
|
13
|
+
consecutiveErrors: subscription.consecutive_errors,
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
export function readingPositions(userId, itemIds) {
|
|
17
|
+
if (itemIds.length === 0)
|
|
18
|
+
return new Map();
|
|
19
|
+
const rows = db
|
|
20
|
+
.prepare(`SELECT item_id, position FROM item_progress WHERE user_id = ? AND item_id IN (${itemIds.map(() => "?").join(",")})`)
|
|
21
|
+
.all(userId, ...itemIds);
|
|
22
|
+
return new Map(rows.map((row) => [row.item_id, row.position]));
|
|
23
|
+
}
|
|
24
|
+
export function serializeItem(row, progress, withContent, hasNote = false) {
|
|
25
|
+
return {
|
|
26
|
+
id: row.id,
|
|
27
|
+
subscriptionId: row.feed_id,
|
|
28
|
+
subscriptionTitle: row.feed_title,
|
|
29
|
+
title: row.title,
|
|
30
|
+
url: row.link,
|
|
31
|
+
publishedAt: row.published_at,
|
|
32
|
+
addedAt: row.created_at,
|
|
33
|
+
snippet: row.content_snippet,
|
|
34
|
+
imageUrl: row.image_url,
|
|
35
|
+
hasFullText: Boolean(row.full_content_html),
|
|
36
|
+
content: contentMetadata(row),
|
|
37
|
+
...(withContent ? { contentHtml: row.content_html, fullTextHtml: row.full_content_html } : {}),
|
|
38
|
+
state: {
|
|
39
|
+
read: Boolean(row.read),
|
|
40
|
+
saved: Boolean(row.bookmarked),
|
|
41
|
+
savedAt: row.bookmarked_at ?? null,
|
|
42
|
+
archivedAt: row.archived_at ?? null,
|
|
43
|
+
progress: progress.get(row.id) ?? null,
|
|
44
|
+
hasNote,
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
//# sourceMappingURL=serialize.js.map
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { dirname, join, sep } from "node:path";
|
|
2
|
+
import { fileURLToPath } from "node:url";
|
|
3
|
+
import { existsSync } from "node:fs";
|
|
4
|
+
import express from "express";
|
|
5
|
+
import helmet from "helmet";
|
|
6
|
+
import cors from "cors";
|
|
7
|
+
import cookieSession from "cookie-session";
|
|
8
|
+
import rateLimit from "express-rate-limit";
|
|
9
|
+
import { config } from "./config.js";
|
|
10
|
+
import { runMigrations } from "./db/index.js";
|
|
11
|
+
import { apiRouter } from "./api/index.js";
|
|
12
|
+
import { mcpRouter } from "./mcp/http.js";
|
|
13
|
+
import { startPollingScheduler } from "./feeds/poller.js";
|
|
14
|
+
import { startCleanupScheduler } from "./feeds/cleanup.js";
|
|
15
|
+
import { repairEncodedText } from "./feeds/repository.js";
|
|
16
|
+
import { pruneArchive, scheduleMissingArchives } from "./feeds/archive.js";
|
|
17
|
+
import { startEditionScheduler } from "./native/editions.js";
|
|
18
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
19
|
+
export const DEFAULT_WEB_DIST = join(__dirname, "../../web/dist");
|
|
20
|
+
export function startCoreSchedulers() {
|
|
21
|
+
runMigrations();
|
|
22
|
+
const repairedItems = repairEncodedText();
|
|
23
|
+
if (repairedItems > 0)
|
|
24
|
+
console.log(`[db] decoded HTML entities in ${repairedItems} stored items`);
|
|
25
|
+
pruneArchive();
|
|
26
|
+
const pendingArchives = scheduleMissingArchives();
|
|
27
|
+
if (pendingArchives > 0)
|
|
28
|
+
console.log(`[archive] archiving ${pendingArchives} saved articles in the background`);
|
|
29
|
+
startPollingScheduler();
|
|
30
|
+
startCleanupScheduler();
|
|
31
|
+
startEditionScheduler();
|
|
32
|
+
}
|
|
33
|
+
export function createApp(options = {}) {
|
|
34
|
+
const app = express();
|
|
35
|
+
if (config.trustProxy) {
|
|
36
|
+
app.set("trust proxy", 1);
|
|
37
|
+
}
|
|
38
|
+
app.use(helmet({
|
|
39
|
+
contentSecurityPolicy: {
|
|
40
|
+
directives: {
|
|
41
|
+
...helmet.contentSecurityPolicy.getDefaultDirectives(),
|
|
42
|
+
"script-src": ["'self'"],
|
|
43
|
+
"img-src": ["'self'", "data:", "https:", "http:"],
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
}));
|
|
47
|
+
// Send X-Robots-Tag to ensure all HTTP endpoints disallow indexing even outside HTML
|
|
48
|
+
app.use((_req, res, next) => {
|
|
49
|
+
res.setHeader("X-Robots-Tag", "noindex, nofollow");
|
|
50
|
+
next();
|
|
51
|
+
});
|
|
52
|
+
app.use(cors({
|
|
53
|
+
origin: config.publicUrl,
|
|
54
|
+
credentials: true,
|
|
55
|
+
}));
|
|
56
|
+
app.use(express.json({ limit: "1mb" }));
|
|
57
|
+
app.use(express.text({ type: ["application/xml", "text/xml", "text/plain"], limit: "5mb" }));
|
|
58
|
+
app.use(cookieSession({
|
|
59
|
+
name: "feedkeeper.sid",
|
|
60
|
+
secret: config.sessionSecret,
|
|
61
|
+
maxAge: 30 * 24 * 60 * 60 * 1000,
|
|
62
|
+
httpOnly: true,
|
|
63
|
+
sameSite: "lax",
|
|
64
|
+
secure: config.publicUrl.startsWith("https://"),
|
|
65
|
+
}));
|
|
66
|
+
// Generic API rate limit as defense in depth on top of the stricter
|
|
67
|
+
// per-route limiter on /api/auth/login.
|
|
68
|
+
app.use("/api", rateLimit({
|
|
69
|
+
windowMs: 60 * 1000,
|
|
70
|
+
limit: 120,
|
|
71
|
+
standardHeaders: true,
|
|
72
|
+
legacyHeaders: false,
|
|
73
|
+
// Lists load many article images and feed icons at once; those routes have their own, higher limit.
|
|
74
|
+
skip: (req) => /^\/(v1\/)?(items\/\d+\/image|feeds\/\d+\/icon|subscriptions\/\d+\/icon|archive\/images\/\d+)$/.test(req.path),
|
|
75
|
+
}));
|
|
76
|
+
app.use("/api", apiRouter);
|
|
77
|
+
app.use("/mcp", mcpRouter);
|
|
78
|
+
if (options.configure) {
|
|
79
|
+
options.configure(app);
|
|
80
|
+
}
|
|
81
|
+
const webDist = options.webDist === undefined ? DEFAULT_WEB_DIST : options.webDist;
|
|
82
|
+
if (webDist && existsSync(webDist)) {
|
|
83
|
+
app.use(express.static(webDist, {
|
|
84
|
+
setHeaders(res, filePath) {
|
|
85
|
+
// Hashed build files never change; the service worker must always be re-checked.
|
|
86
|
+
if (filePath.includes(`${sep}assets${sep}`))
|
|
87
|
+
res.setHeader("Cache-Control", "public, max-age=31536000, immutable");
|
|
88
|
+
if (filePath.endsWith(`${sep}sw.js`))
|
|
89
|
+
res.setHeader("Cache-Control", "no-cache");
|
|
90
|
+
},
|
|
91
|
+
}));
|
|
92
|
+
app.get(/(.*)/, (req, res, next) => {
|
|
93
|
+
if (req.path.startsWith("/api") || req.path.startsWith("/mcp") || req.path.startsWith("/cloud"))
|
|
94
|
+
return next();
|
|
95
|
+
res.sendFile(join(webDist, "index.html"));
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
return app;
|
|
99
|
+
}
|
|
100
|
+
//# sourceMappingURL=app.js.map
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export const AVATAR_MAX_BYTES = 512 * 1024;
|
|
2
|
+
/** Identify an uploaded image by its signature instead of trusting the declared type. */
|
|
3
|
+
export function detectAvatarType(data) {
|
|
4
|
+
if (data.length >= 3 && data[0] === 0xff && data[1] === 0xd8 && data[2] === 0xff)
|
|
5
|
+
return "image/jpeg";
|
|
6
|
+
if (data.length >= 8 && [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a].every((byte, index) => data[index] === byte))
|
|
7
|
+
return "image/png";
|
|
8
|
+
const ascii = (start, end) => String.fromCharCode(...data.subarray(start, end));
|
|
9
|
+
if (data.length >= 12 && ascii(0, 4) === "RIFF" && ascii(8, 12) === "WEBP")
|
|
10
|
+
return "image/webp";
|
|
11
|
+
return null;
|
|
12
|
+
}
|
|
13
|
+
//# sourceMappingURL=avatar.js.map
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
class DefaultCapabilitiesProvider {
|
|
2
|
+
getCapabilitiesForUser(_userId) {
|
|
3
|
+
return {
|
|
4
|
+
type: "selfhosted",
|
|
5
|
+
features: {
|
|
6
|
+
mcp: true,
|
|
7
|
+
sync: true,
|
|
8
|
+
notes: true,
|
|
9
|
+
editions: true,
|
|
10
|
+
fulltext: true,
|
|
11
|
+
"search.fts": true,
|
|
12
|
+
inbox: true,
|
|
13
|
+
},
|
|
14
|
+
manageUrl: null,
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
let activeCapabilitiesProvider = new DefaultCapabilitiesProvider();
|
|
19
|
+
export function setCapabilitiesProvider(provider) {
|
|
20
|
+
activeCapabilitiesProvider = provider;
|
|
21
|
+
}
|
|
22
|
+
export function getCapabilitiesProvider() {
|
|
23
|
+
return activeCapabilitiesProvider;
|
|
24
|
+
}
|
|
25
|
+
export function getUserCapabilities(userId) {
|
|
26
|
+
return activeCapabilitiesProvider.getCapabilitiesForUser(userId);
|
|
27
|
+
}
|
|
28
|
+
/** The advertised feature map is also the authority for enforcement. Missing keys deny access. */
|
|
29
|
+
export function hasUserCapability(userId, capability) {
|
|
30
|
+
return getUserCapabilities(userId).features[capability] === true;
|
|
31
|
+
}
|
|
32
|
+
export function capabilityDenial(userId, capability) {
|
|
33
|
+
return {
|
|
34
|
+
error: "capability_not_available",
|
|
35
|
+
capability,
|
|
36
|
+
manageUrl: getUserCapabilities(userId).manageUrl ?? null,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
export function requireCapability(capability) {
|
|
40
|
+
return (req, res, next) => {
|
|
41
|
+
const userId = req.user?.id;
|
|
42
|
+
if (!userId) {
|
|
43
|
+
res.status(401).json({ error: "not_authenticated" });
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
if (!hasUserCapability(userId, capability)) {
|
|
47
|
+
res.status(403).json(capabilityDenial(userId, capability));
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
next();
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
//# sourceMappingURL=capabilities.js.map
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { resolveToken } from "./tokens.js";
|
|
2
|
+
import { findUserById, toPublicUser } from "./users.js";
|
|
3
|
+
import { sessionIsSecure } from "./security.js";
|
|
4
|
+
import { RESOURCE_METADATA_URL } from "../oauth/constants.js";
|
|
5
|
+
// Web UI auth: cookie session set at login.
|
|
6
|
+
export function requireSession(req, res, next) {
|
|
7
|
+
const userId = req.session?.userId;
|
|
8
|
+
if (!userId || !sessionIsSecure(req, userId)) {
|
|
9
|
+
res.status(401).json({ error: "not_authenticated" });
|
|
10
|
+
return;
|
|
11
|
+
}
|
|
12
|
+
const user = findUserById(userId);
|
|
13
|
+
if (!user) {
|
|
14
|
+
res.status(401).json({ error: "not_authenticated" });
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
req.user = toPublicUser(user);
|
|
18
|
+
next();
|
|
19
|
+
}
|
|
20
|
+
function bearerToken(req) {
|
|
21
|
+
const header = req.headers.authorization;
|
|
22
|
+
return header?.startsWith("Bearer ") ? header.slice("Bearer ".length) : undefined;
|
|
23
|
+
}
|
|
24
|
+
// MCP auth: Bearer personal access token or OAuth access token (not a paired device), scopes the request to its owner.
|
|
25
|
+
export function requireBearerToken(req, res, next) {
|
|
26
|
+
authenticateBearer(["api", "oauth"], RESOURCE_METADATA_URL)(req, res, next);
|
|
27
|
+
}
|
|
28
|
+
// Native app API: a paired device token or a hand-made token; the web UI may call it with its session.
|
|
29
|
+
export function requireSessionOrDevice(req, res, next) {
|
|
30
|
+
if (bearerToken(req))
|
|
31
|
+
authenticateBearer(["api", "device"])(req, res, next);
|
|
32
|
+
else
|
|
33
|
+
requireSession(req, res, next);
|
|
34
|
+
}
|
|
35
|
+
/** `resourceMetadata` adds the RFC 9728 challenge that tells OAuth clients where to sign in. */
|
|
36
|
+
export function authenticateBearer(kinds, resourceMetadata) {
|
|
37
|
+
return (req, res, next) => {
|
|
38
|
+
bearerAuth(req, res, next, kinds, resourceMetadata);
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
function bearerAuth(req, res, next, kinds, resourceMetadata) {
|
|
42
|
+
const reject = (error) => {
|
|
43
|
+
if (resourceMetadata) {
|
|
44
|
+
const detail = error === "invalid_token" ? `error="invalid_token", ` : "";
|
|
45
|
+
res.setHeader("WWW-Authenticate", `Bearer ${detail}resource_metadata="${resourceMetadata}"`);
|
|
46
|
+
}
|
|
47
|
+
res.status(401).json({ error });
|
|
48
|
+
};
|
|
49
|
+
const token = bearerToken(req);
|
|
50
|
+
if (!token) {
|
|
51
|
+
reject("missing_token");
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
const resolved = resolveToken(token);
|
|
55
|
+
if (!resolved || !kinds.includes(resolved.kind)) {
|
|
56
|
+
reject("invalid_token");
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
const user = findUserById(resolved.userId);
|
|
60
|
+
if (!user) {
|
|
61
|
+
reject("invalid_token");
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
req.user = toPublicUser(user);
|
|
65
|
+
req.tokenScope = resolved.scope;
|
|
66
|
+
req.tokenId = resolved.tokenId;
|
|
67
|
+
req.tokenKind = resolved.kind;
|
|
68
|
+
next();
|
|
69
|
+
}
|
|
70
|
+
export function requireAdmin(req, res, next) {
|
|
71
|
+
if (req.user?.role !== "admin") {
|
|
72
|
+
res.status(403).json({ error: "forbidden" });
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
next();
|
|
76
|
+
}
|
|
77
|
+
//# sourceMappingURL=middleware.js.map
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
2
|
+
import { db } from "../db/index.js";
|
|
3
|
+
import { createPersonalAccessToken } from "./tokens.js";
|
|
4
|
+
const CODE_TTL_MS = 10 * 60 * 1000;
|
|
5
|
+
// Without 0, O, 1, I and L, so a code can be read out or typed by hand.
|
|
6
|
+
const ALPHABET = "ABCDEFGHJKMNPQRSTUVWXYZ23456789";
|
|
7
|
+
const CODE_LENGTH = 12;
|
|
8
|
+
function normalize(code) {
|
|
9
|
+
return code.toUpperCase().replace(/[^A-Z0-9]/g, "");
|
|
10
|
+
}
|
|
11
|
+
function hashCode(code) {
|
|
12
|
+
return createHash("sha256").update(normalize(code)).digest("hex");
|
|
13
|
+
}
|
|
14
|
+
/** A fresh single-use code for the user (about 60 bits); it replaces any code they still had. */
|
|
15
|
+
export function createPairingCode(userId) {
|
|
16
|
+
const bytes = randomBytes(CODE_LENGTH);
|
|
17
|
+
const raw = Array.from(bytes, (byte) => ALPHABET[byte % ALPHABET.length]).join("");
|
|
18
|
+
const code = raw.match(/.{4}/g).join("-");
|
|
19
|
+
const expiresAt = new Date(Date.now() + CODE_TTL_MS).toISOString();
|
|
20
|
+
db.prepare(`INSERT INTO pairing_codes (user_id, code_hash, expires_at) VALUES (?, ?, ?)
|
|
21
|
+
ON CONFLICT(user_id) DO UPDATE SET code_hash = excluded.code_hash, expires_at = excluded.expires_at, created_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')`).run(userId, hashCode(code), expiresAt);
|
|
22
|
+
return { code, expiresAt };
|
|
23
|
+
}
|
|
24
|
+
/** Exchange a valid code for a device token. A code works once; expired and unknown codes give null. */
|
|
25
|
+
export function redeemPairingCode(code, device) {
|
|
26
|
+
return db.transaction(() => {
|
|
27
|
+
db.prepare("DELETE FROM pairing_codes WHERE expires_at <= ?").run(new Date().toISOString());
|
|
28
|
+
const row = db.prepare("SELECT user_id FROM pairing_codes WHERE code_hash = ?").get(hashCode(code));
|
|
29
|
+
if (!row)
|
|
30
|
+
return null;
|
|
31
|
+
db.prepare("DELETE FROM pairing_codes WHERE user_id = ?").run(row.user_id);
|
|
32
|
+
const { id, token } = createPersonalAccessToken(row.user_id, device.name, "write", { platform: device.platform, appVersion: device.appVersion });
|
|
33
|
+
return { userId: row.user_id, deviceId: id, token };
|
|
34
|
+
})();
|
|
35
|
+
}
|
|
36
|
+
//# sourceMappingURL=pairing.js.map
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import bcrypt from "bcryptjs";
|
|
2
|
+
const SALT_ROUNDS = process.env.NODE_ENV === "test" ? 4 : 12;
|
|
3
|
+
export function hashPassword(plain) {
|
|
4
|
+
return bcrypt.hashSync(plain, SALT_ROUNDS);
|
|
5
|
+
}
|
|
6
|
+
export function verifyPassword(plain, hash) {
|
|
7
|
+
return bcrypt.compareSync(plain, hash);
|
|
8
|
+
}
|
|
9
|
+
//# sourceMappingURL=password.js.map
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
const DEFAULT_SELFHOSTED_FEATURES = [
|
|
2
|
+
"sync",
|
|
3
|
+
"notes",
|
|
4
|
+
"edition",
|
|
5
|
+
"edition.automatic",
|
|
6
|
+
"edition.revisions",
|
|
7
|
+
"native-preferences",
|
|
8
|
+
"search.fts",
|
|
9
|
+
"mcp.remote",
|
|
10
|
+
"feed-icons",
|
|
11
|
+
"folder-icons",
|
|
12
|
+
"fulltext",
|
|
13
|
+
];
|
|
14
|
+
class DefaultPlanProvider {
|
|
15
|
+
getPlanForUser(_userId) {
|
|
16
|
+
return {
|
|
17
|
+
tier: "selfhosted",
|
|
18
|
+
status: "active",
|
|
19
|
+
features: DEFAULT_SELFHOSTED_FEATURES,
|
|
20
|
+
manageUrl: null,
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
hasFeature(_userId, _feature) {
|
|
24
|
+
// In self-hosted mode, all features are permitted
|
|
25
|
+
return true;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
let activePlanProvider = new DefaultPlanProvider();
|
|
29
|
+
export function setPlanProvider(provider) {
|
|
30
|
+
activePlanProvider = provider;
|
|
31
|
+
}
|
|
32
|
+
export function getPlanProvider() {
|
|
33
|
+
return activePlanProvider;
|
|
34
|
+
}
|
|
35
|
+
export function getUserPlan(userId) {
|
|
36
|
+
return activePlanProvider.getPlanForUser(userId);
|
|
37
|
+
}
|
|
38
|
+
export function requirePlanFeature(feature) {
|
|
39
|
+
return (req, res, next) => {
|
|
40
|
+
const userId = req.user?.id;
|
|
41
|
+
if (!userId) {
|
|
42
|
+
res.status(401).json({ error: "not_authenticated" });
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
if (!activePlanProvider.hasFeature(userId, feature)) {
|
|
46
|
+
const plan = activePlanProvider.getPlanForUser(userId);
|
|
47
|
+
res.status(403).json({
|
|
48
|
+
error: "feature_not_included",
|
|
49
|
+
feature,
|
|
50
|
+
tier: plan.tier,
|
|
51
|
+
manageUrl: plan.manageUrl ?? null,
|
|
52
|
+
});
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
next();
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
//# sourceMappingURL=plan.js.map
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { createCipheriv, createDecipheriv, createHash, randomBytes } from "node:crypto";
|
|
2
|
+
import { TOTP, Secret } from "otpauth";
|
|
3
|
+
import { db } from "../db/index.js";
|
|
4
|
+
import { config } from "../config.js";
|
|
5
|
+
export const opaque = () => randomBytes(32).toString("base64url");
|
|
6
|
+
const hash = (text) => createHash("sha256").update(text).digest("hex");
|
|
7
|
+
const encryptionKey = () => createHash("sha256").update(`feedkeeper-totp:${config.sessionSecret}`).digest();
|
|
8
|
+
export function seal(text) {
|
|
9
|
+
const iv = randomBytes(12);
|
|
10
|
+
const cipher = createCipheriv("aes-256-gcm", encryptionKey(), iv);
|
|
11
|
+
const ciphertext = Buffer.concat([cipher.update(text, "utf8"), cipher.final()]);
|
|
12
|
+
return Buffer.concat([iv, cipher.getAuthTag(), ciphertext]).toString("base64url");
|
|
13
|
+
}
|
|
14
|
+
function unseal(text) {
|
|
15
|
+
const data = Buffer.from(text, "base64url");
|
|
16
|
+
const decipher = createDecipheriv("aes-256-gcm", encryptionKey(), data.subarray(0, 12));
|
|
17
|
+
decipher.setAuthTag(data.subarray(12, 28));
|
|
18
|
+
return Buffer.concat([decipher.update(data.subarray(28)), decipher.final()]).toString("utf8");
|
|
19
|
+
}
|
|
20
|
+
export function securityAccount(userId) {
|
|
21
|
+
db.prepare("INSERT OR IGNORE INTO account_security (user_id, user_handle) VALUES (?, ?)").run(userId, opaque());
|
|
22
|
+
return db.prepare("SELECT * FROM account_security WHERE user_id = ?").get(userId);
|
|
23
|
+
}
|
|
24
|
+
export function securityVersion(userId) {
|
|
25
|
+
return db.prepare("SELECT version FROM account_security WHERE user_id = ?").get(userId)?.version ?? 0;
|
|
26
|
+
}
|
|
27
|
+
export function sessionIsSecure(req, userId) {
|
|
28
|
+
return (req.session?.securityVersion ?? 0) === securityVersion(userId);
|
|
29
|
+
}
|
|
30
|
+
export function hasTotp(userId) { return Boolean(securityAccount(userId).totp_secret); }
|
|
31
|
+
export function securityBinding(req, channel) {
|
|
32
|
+
if (channel === "native-login")
|
|
33
|
+
return channel;
|
|
34
|
+
if (req.headers.authorization)
|
|
35
|
+
return hash(`${channel}:${req.headers.authorization}`);
|
|
36
|
+
req.session ??= {};
|
|
37
|
+
req.session.securityBinding ??= opaque();
|
|
38
|
+
return hash(`${channel}:${req.session.securityBinding}`);
|
|
39
|
+
}
|
|
40
|
+
export function issueSecurityChallenge(purpose, userId, binding, payload, ttl = 5 * 60_000) {
|
|
41
|
+
db.prepare("DELETE FROM security_challenges WHERE expires_at <= ?").run(Date.now());
|
|
42
|
+
const id = opaque();
|
|
43
|
+
db.prepare("INSERT INTO security_challenges (id_hash, purpose, user_id, binding, version, payload, expires_at) VALUES (?, ?, ?, ?, ?, ?, ?)")
|
|
44
|
+
.run(hash(id), purpose, userId, binding, userId ? securityVersion(userId) : 0, JSON.stringify(payload), Date.now() + ttl);
|
|
45
|
+
return id;
|
|
46
|
+
}
|
|
47
|
+
/** Count attempts before checking the factor, including requests that end in an error. */
|
|
48
|
+
export function readSecurityChallenge(id, purpose, binding, consume = false) {
|
|
49
|
+
if (typeof id !== "string" || !/^[\w-]{43}$/.test(id))
|
|
50
|
+
return null;
|
|
51
|
+
return db.transaction(() => {
|
|
52
|
+
const row = db.prepare("SELECT * FROM security_challenges WHERE id_hash = ?").get(hash(id));
|
|
53
|
+
if (!row || row.purpose !== purpose || row.binding !== binding || row.expires_at <= Date.now() || row.attempts >= 15 || (row.user_id && row.version !== securityVersion(row.user_id)))
|
|
54
|
+
return null;
|
|
55
|
+
if (consume)
|
|
56
|
+
db.prepare("DELETE FROM security_challenges WHERE id_hash = ?").run(hash(id));
|
|
57
|
+
else
|
|
58
|
+
db.prepare("UPDATE security_challenges SET attempts = attempts + 1 WHERE id_hash = ?").run(hash(id));
|
|
59
|
+
return row;
|
|
60
|
+
})();
|
|
61
|
+
}
|
|
62
|
+
export function discardSecurityChallenge(id) { db.prepare("DELETE FROM security_challenges WHERE id_hash = ?").run(hash(id)); }
|
|
63
|
+
export function totpFor(secret, label = "FeedKeeper") {
|
|
64
|
+
return new TOTP({ issuer: "FeedKeeper", label, algorithm: "SHA1", digits: 6, period: 30, secret: secret ? Secret.fromBase32(secret) : new Secret({ size: 20 }) });
|
|
65
|
+
}
|
|
66
|
+
export function verifySecondFactor(userId, code) {
|
|
67
|
+
if (typeof code !== "string" || code.length > 80)
|
|
68
|
+
return false;
|
|
69
|
+
return db.transaction(() => {
|
|
70
|
+
const account = securityAccount(userId);
|
|
71
|
+
if (!account.totp_secret)
|
|
72
|
+
return false;
|
|
73
|
+
if (/^\d{6}$/.test(code)) {
|
|
74
|
+
let delta;
|
|
75
|
+
try {
|
|
76
|
+
delta = totpFor(unseal(account.totp_secret)).validate({ token: code, window: 1 });
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
if (delta === null)
|
|
82
|
+
return false;
|
|
83
|
+
const step = Math.floor(Date.now() / 30_000) + delta;
|
|
84
|
+
return db.prepare("UPDATE account_security SET totp_last_step = ? WHERE user_id = ? AND totp_last_step < ?").run(step, userId, step).changes === 1;
|
|
85
|
+
}
|
|
86
|
+
const normalized = code.toUpperCase().replace(/[ -]/g, "");
|
|
87
|
+
if (!/^[A-F0-9]{24}$/.test(normalized))
|
|
88
|
+
return false;
|
|
89
|
+
return db.prepare("DELETE FROM recovery_codes WHERE user_id = ? AND code_hash = ?").run(userId, hash(normalized)).changes === 1;
|
|
90
|
+
})();
|
|
91
|
+
}
|
|
92
|
+
export function newRecoveryCodes(userId) {
|
|
93
|
+
db.prepare("DELETE FROM recovery_codes WHERE user_id = ?").run(userId);
|
|
94
|
+
const codes = Array.from({ length: 10 }, () => randomBytes(12).toString("hex").toUpperCase());
|
|
95
|
+
for (const code of codes)
|
|
96
|
+
db.prepare("INSERT INTO recovery_codes (user_id, code_hash) VALUES (?, ?)").run(userId, hash(code));
|
|
97
|
+
return codes.map(code => code.match(/.{6}/g).join("-"));
|
|
98
|
+
}
|
|
99
|
+
/** Enabling MFA invalidates older password-only sessions, tokens, pairing codes and pending logins. */
|
|
100
|
+
export function revokeSecuritySessions(userId) {
|
|
101
|
+
db.prepare("UPDATE account_security SET version = version + 1 WHERE user_id = ?").run(userId);
|
|
102
|
+
db.prepare("DELETE FROM personal_access_tokens WHERE user_id = ?").run(userId);
|
|
103
|
+
db.prepare("DELETE FROM pairing_codes WHERE user_id = ?").run(userId);
|
|
104
|
+
db.prepare("DELETE FROM security_challenges WHERE user_id = ?").run(userId);
|
|
105
|
+
db.prepare("DELETE FROM oauth_grants WHERE user_id = ?").run(userId);
|
|
106
|
+
db.prepare("DELETE FROM oauth_codes WHERE user_id = ?").run(userId);
|
|
107
|
+
}
|
|
108
|
+
export function beginMfa(req, userId, channel, context) {
|
|
109
|
+
if (!hasTotp(userId))
|
|
110
|
+
return null;
|
|
111
|
+
const challengeId = issueSecurityChallenge("mfa", userId, securityBinding(req, `${channel}-login`), context);
|
|
112
|
+
if (channel === "web") {
|
|
113
|
+
req.session.pendingMfa = challengeId;
|
|
114
|
+
}
|
|
115
|
+
return { error: "mfa_required", challengeId, methods: ["totp", "recovery"], expiresAt: new Date(Date.now() + 5 * 60_000).toISOString() };
|
|
116
|
+
}
|
|
117
|
+
//# sourceMappingURL=security.js.map
|