feedkeeper 0.23.0 → 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/package.json +1 -1
- package/server/dist/api/auth.js +8 -3
- package/server/dist/auth/password.js +12 -0
- package/server/dist/auth/security.js +9 -0
- package/server/dist/db/migrations/0032_normalize_published_at.sql +12 -0
- package/server/dist/feeds/cleanup.js +3 -3
- package/server/dist/feeds/dates.js +21 -0
- package/server/dist/feeds/poller.js +2 -1
- package/server/dist/feeds/repository.js +25 -12
- package/server/dist/oauth/routes.js +12 -3
- package/server/package.json +1 -1
- package/server/dist/app.js +0 -100
- package/server/dist/auth/plan.js +0 -58
- package/server/dist/index.js +0 -15
package/package.json
CHANGED
package/server/dist/api/auth.js
CHANGED
|
@@ -5,9 +5,9 @@ import { config } from "../config.js";
|
|
|
5
5
|
import { requireSession, requireAdmin } from "../auth/middleware.js";
|
|
6
6
|
import { createUser, deleteUserAvatar, findUserByEmail, findUserById, getUserAvatar, listUsers, setUserAvatar, toPublicUser, updateDisplayName, dismissIntro, updateUserPassword, } from "../auth/users.js";
|
|
7
7
|
import { AVATAR_MAX_BYTES, detectAvatarType } from "../auth/avatar.js";
|
|
8
|
-
import { verifyPassword } from "../auth/password.js";
|
|
8
|
+
import { verifyPassword, verifyPasswordOrDummy } from "../auth/password.js";
|
|
9
9
|
import { getUserCapabilities } from "../auth/capabilities.js";
|
|
10
|
-
import { beginMfa, securityVersion } from "../auth/security.js";
|
|
10
|
+
import { beginMfa, revokeBrowserSessions, securityVersion } from "../auth/security.js";
|
|
11
11
|
import { createSecurityRouter } from "../auth/securityRoutes.js";
|
|
12
12
|
export const authRouter = Router();
|
|
13
13
|
authRouter.use("/security", createSecurityRouter({ completeLogin: (req, res, userId) => {
|
|
@@ -31,7 +31,9 @@ authRouter.post("/login", loginLimiter, (req, res) => {
|
|
|
31
31
|
return;
|
|
32
32
|
}
|
|
33
33
|
const user = findUserByEmail(parsed.data.email);
|
|
34
|
-
|
|
34
|
+
// Always check a password, so an unknown address answers as slowly as a wrong password.
|
|
35
|
+
const passwordMatches = verifyPasswordOrDummy(parsed.data.password, user?.password_hash);
|
|
36
|
+
if (!user || !passwordMatches) {
|
|
35
37
|
res.status(401).json({ error: "invalid_credentials" });
|
|
36
38
|
return;
|
|
37
39
|
}
|
|
@@ -110,6 +112,9 @@ authRouter.post("/change-password", requireSession, loginLimiter, (req, res) =>
|
|
|
110
112
|
return;
|
|
111
113
|
}
|
|
112
114
|
updateUserPassword(user.id, parsed.data.newPassword);
|
|
115
|
+
// Other browsers that were signed in with the old password are signed out; this one stays.
|
|
116
|
+
revokeBrowserSessions(user.id);
|
|
117
|
+
req.session = { userId: user.id, securityVersion: securityVersion(user.id) };
|
|
113
118
|
res.status(204).end();
|
|
114
119
|
});
|
|
115
120
|
const createUserSchema = z.object({
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
1
2
|
import bcrypt from "bcryptjs";
|
|
2
3
|
const SALT_ROUNDS = process.env.NODE_ENV === "test" ? 4 : 12;
|
|
3
4
|
export function hashPassword(plain) {
|
|
@@ -6,4 +7,15 @@ export function hashPassword(plain) {
|
|
|
6
7
|
export function verifyPassword(plain, hash) {
|
|
7
8
|
return bcrypt.compareSync(plain, hash);
|
|
8
9
|
}
|
|
10
|
+
// A hash nobody knows the password for. Checking it costs what checking a real one costs.
|
|
11
|
+
let unknownAccountHash;
|
|
12
|
+
/**
|
|
13
|
+
* Like verifyPassword for a sign-in, where the account may not exist: an unknown address takes as long as a known
|
|
14
|
+
* one, so the response time does not tell whether an account is registered.
|
|
15
|
+
*/
|
|
16
|
+
export function verifyPasswordOrDummy(plain, hash) {
|
|
17
|
+
unknownAccountHash ??= bcrypt.hashSync(randomBytes(16).toString("hex"), SALT_ROUNDS);
|
|
18
|
+
const matches = bcrypt.compareSync(plain, hash ?? unknownAccountHash);
|
|
19
|
+
return hash !== undefined && matches;
|
|
20
|
+
}
|
|
9
21
|
//# sourceMappingURL=password.js.map
|
|
@@ -96,6 +96,15 @@ export function newRecoveryCodes(userId) {
|
|
|
96
96
|
db.prepare("INSERT INTO recovery_codes (user_id, code_hash) VALUES (?, ?)").run(userId, hash(code));
|
|
97
97
|
return codes.map(code => code.match(/.{6}/g).join("-"));
|
|
98
98
|
}
|
|
99
|
+
/**
|
|
100
|
+
* A new password ends every browser session and pending sign-in that knew the old one. Access tokens, paired
|
|
101
|
+
* devices and connected apps are the person's own and keep working; they can be removed one by one.
|
|
102
|
+
*/
|
|
103
|
+
export function revokeBrowserSessions(userId) {
|
|
104
|
+
securityAccount(userId);
|
|
105
|
+
db.prepare("UPDATE account_security SET version = version + 1 WHERE user_id = ?").run(userId);
|
|
106
|
+
db.prepare("DELETE FROM security_challenges WHERE user_id = ?").run(userId);
|
|
107
|
+
}
|
|
99
108
|
/** Enabling MFA invalidates older password-only sessions, tokens, pairing codes and pending logins. */
|
|
100
109
|
export function revokeSecuritySessions(userId) {
|
|
101
110
|
db.prepare("UPDATE account_security SET version = version + 1 WHERE user_id = ?").run(userId);
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
-- Migration 0032: publish dates that are not dates.
|
|
2
|
+
--
|
|
3
|
+
-- Entries whose feed gave a date that could not be read ("gestern", localised weekday names, an empty string) had
|
|
4
|
+
-- the raw text stored. Compared as text it sorted above every real date and never aged. Dates before 1991 and
|
|
5
|
+
-- dates more than a day ahead are placeholders (1970-01-01, year 9999). All of them become NULL, which makes the
|
|
6
|
+
-- app use the time it stored the article.
|
|
7
|
+
UPDATE items SET published_at = NULL
|
|
8
|
+
WHERE published_at IS NOT NULL AND julianday(published_at) IS NULL;
|
|
9
|
+
|
|
10
|
+
UPDATE items SET published_at = NULL
|
|
11
|
+
WHERE published_at IS NOT NULL
|
|
12
|
+
AND (julianday(published_at) < julianday('1991-01-01') OR julianday(published_at) > julianday('now', '+1 day'));
|
|
@@ -99,7 +99,7 @@ export function runCleanup(custom) {
|
|
|
99
99
|
WHERE s.feed_id = i.feed_id
|
|
100
100
|
AND NOT EXISTS (SELECT 1 FROM item_reads r WHERE r.item_id = i.id AND r.user_id = s.user_id)
|
|
101
101
|
)
|
|
102
|
-
AND COALESCE(i.published_at, i.created_at) <=
|
|
102
|
+
AND COALESCE(julianday(i.published_at), julianday(i.created_at)) <= julianday('now', '-' || ? || ' days')
|
|
103
103
|
)`).run(settings.retentionReadDays);
|
|
104
104
|
deletedReadItems = res.changes;
|
|
105
105
|
}
|
|
@@ -111,7 +111,7 @@ export function runCleanup(custom) {
|
|
|
111
111
|
AND feed_id NOT IN (SELECT id FROM feeds WHERE is_system_inbox = 1)
|
|
112
112
|
AND NOT EXISTS (SELECT 1 FROM editions e, json_each(e.item_ids) selected
|
|
113
113
|
WHERE selected.value = items.id AND e.status = 'active' AND julianday(e.expires_at) > julianday('now'))
|
|
114
|
-
AND COALESCE(published_at, created_at) <=
|
|
114
|
+
AND COALESCE(julianday(published_at), julianday(created_at)) <= julianday('now', '-' || ? || ' days')`).run(settings.retentionMaxDays);
|
|
115
115
|
deletedOldItems = res.changes;
|
|
116
116
|
}
|
|
117
117
|
// 3. Delete items exceeding retentionMaxItemsPerFeed (excluding bookmarked items and items with notes)
|
|
@@ -126,7 +126,7 @@ export function runCleanup(custom) {
|
|
|
126
126
|
SELECT id FROM (
|
|
127
127
|
SELECT id, ROW_NUMBER() OVER (
|
|
128
128
|
PARTITION BY feed_id
|
|
129
|
-
ORDER BY COALESCE(published_at, created_at) DESC, id DESC
|
|
129
|
+
ORDER BY COALESCE(julianday(published_at), julianday(created_at)) DESC, id DESC
|
|
130
130
|
) as rn
|
|
131
131
|
FROM items
|
|
132
132
|
)
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// Nothing on the web is older than this, so an earlier date is a placeholder such as 1970-01-01.
|
|
2
|
+
const EARLIEST_PLAUSIBLE = Date.UTC(1991, 0, 1);
|
|
3
|
+
// Feeds may announce an article a little ahead of time or in another zone, but not by years.
|
|
4
|
+
const MAX_AHEAD_MS = 24 * 60 * 60 * 1000;
|
|
5
|
+
/**
|
|
6
|
+
* The publish date of a feed entry as an ISO timestamp, or null when the feed gives none we can trust: missing,
|
|
7
|
+
* not a date ("gestern", localised weekday names), a placeholder in the past or a date far in the future. Null makes
|
|
8
|
+
* the app use the time it stored the article, so the article lists, ages and is cleaned up like any other.
|
|
9
|
+
*/
|
|
10
|
+
export function normalizePublishedAt(value, now = Date.now()) {
|
|
11
|
+
if (typeof value !== "string")
|
|
12
|
+
return null;
|
|
13
|
+
const text = value.trim();
|
|
14
|
+
if (!text)
|
|
15
|
+
return null;
|
|
16
|
+
const time = Date.parse(text);
|
|
17
|
+
if (!Number.isFinite(time) || time < EARLIEST_PLAUSIBLE || time > now + MAX_AHEAD_MS)
|
|
18
|
+
return null;
|
|
19
|
+
return new Date(time).toISOString();
|
|
20
|
+
}
|
|
21
|
+
//# sourceMappingURL=dates.js.map
|
|
@@ -9,6 +9,7 @@ import { findFeedById, listSubscriberIds, updateFeedAfterPoll, updateFeedIcon, u
|
|
|
9
9
|
import { db } from "../db/index.js";
|
|
10
10
|
import { configurePollHostCooldowns, PollQueue } from "./pollQueue.js";
|
|
11
11
|
import { feedRequests, PollDeferredError, sourceHost } from "./requestGate.js";
|
|
12
|
+
import { normalizePublishedAt } from "./dates.js";
|
|
12
13
|
configurePollHostCooldowns();
|
|
13
14
|
function mediaAttribute(value, key) {
|
|
14
15
|
if (typeof value !== "object" || value === null)
|
|
@@ -103,7 +104,7 @@ async function pollAttempt(feed, signal) {
|
|
|
103
104
|
title: plainTitle(item.title), link: item.link,
|
|
104
105
|
contentSnippet: decodeEntities(item.contentSnippet ?? item.content) ?? undefined,
|
|
105
106
|
contentHtml: item.contentEncoded ?? item["content:encoded"] ?? item.content ?? null,
|
|
106
|
-
publishedAt: item.isoDate ?? item.pubDate, imageUrl: extractImageUrl(item),
|
|
107
|
+
publishedAt: normalizePublishedAt(item.isoDate ?? item.pubDate) ?? undefined, imageUrl: extractImageUrl(item),
|
|
107
108
|
}));
|
|
108
109
|
// No await in this transaction. Items, health/validators and job completion become visible together.
|
|
109
110
|
const newItems = db.transaction(() => {
|
|
@@ -234,7 +234,7 @@ export function listItemsForUser(userId, opts = {}) {
|
|
|
234
234
|
const limit = Math.min(opts.limit ?? 50, 200);
|
|
235
235
|
const offset = Math.max(opts.offset ?? 0, 0);
|
|
236
236
|
params.push(limit, offset);
|
|
237
|
-
const orderBy = opts.sortByAdded ? "i.created_at DESC, i.id DESC" : opts.bookmarkedOnly ? "b.created_at DESC, i.id DESC" : "i.published_at DESC, i.created_at DESC, i.id DESC";
|
|
237
|
+
const orderBy = opts.sortByAdded ? "i.created_at DESC, i.id DESC" : opts.bookmarkedOnly ? "b.created_at DESC, i.id DESC" : "COALESCE(julianday(i.published_at), julianday(i.created_at)) DESC, i.created_at DESC, i.id DESC";
|
|
238
238
|
// Page IDs before loading HTML. User/feed and user/item keys make these joins unique.
|
|
239
239
|
// Keep selection and loading in one statement so they share the same read snapshot.
|
|
240
240
|
return db
|
|
@@ -583,8 +583,17 @@ export function formatFtsQuery(raw) {
|
|
|
583
583
|
return "";
|
|
584
584
|
return sanitized.join(" ");
|
|
585
585
|
}
|
|
586
|
+
/**
|
|
587
|
+
* True when the query has letters or digits that the full-text index cannot be asked for as typed (Japanese,
|
|
588
|
+
* Cyrillic, Greek, ...). `formatFtsQuery` drops them, so a query such as "RSS 新聞" would silently search for "RSS"
|
|
589
|
+
* alone; such queries are matched word by word as substrings instead. Quoted phrases are passed through as typed.
|
|
590
|
+
*/
|
|
591
|
+
export function needsSubstringSearch(raw) {
|
|
592
|
+
const outsideQuotes = raw.replace(/"[^"]*"/g, " ");
|
|
593
|
+
return /(?![a-zA-Z0-9_\u00C0-\u024F\u1E00-\u1EFF])[\p{L}\p{N}]/u.test(outsideQuotes);
|
|
594
|
+
}
|
|
586
595
|
export function searchItemsForUser(userId, opts) {
|
|
587
|
-
const fts = formatFtsQuery(opts.query);
|
|
596
|
+
const fts = needsSubstringSearch(opts.query) ? "" : formatFtsQuery(opts.query);
|
|
588
597
|
const conditions = ["(s.id IS NOT NULL OR b.item_id IS NOT NULL OR n.user_id IS NOT NULL)"];
|
|
589
598
|
const params = [];
|
|
590
599
|
if (opts.feedId) {
|
|
@@ -610,14 +619,18 @@ export function searchItemsForUser(userId, opts) {
|
|
|
610
619
|
params.push(fts, userId, opts.query);
|
|
611
620
|
}
|
|
612
621
|
else {
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
622
|
+
// Every word has to occur somewhere in the article or in the person's note.
|
|
623
|
+
const words = opts.query.trim().split(/\s+/).filter(Boolean).slice(0, 8);
|
|
624
|
+
for (const word of words) {
|
|
625
|
+
conditions.push(`(
|
|
626
|
+
INSTR(LOWER(COALESCE(i.title, '')), LOWER(?)) > 0
|
|
627
|
+
OR INSTR(LOWER(COALESCE(i.content_snippet, '')), LOWER(?)) > 0
|
|
628
|
+
OR INSTR(LOWER(COALESCE(i.content_html, '')), LOWER(?)) > 0
|
|
629
|
+
OR INSTR(LOWER(COALESCE(i.full_content_html, '')), LOWER(?)) > 0
|
|
630
|
+
OR i.id IN (SELECT item_id FROM item_notes WHERE user_id = ? AND INSTR(LOWER(content), LOWER(?)) > 0)
|
|
631
|
+
)`);
|
|
632
|
+
params.push(word, word, word, word, userId, word);
|
|
633
|
+
}
|
|
621
634
|
}
|
|
622
635
|
// Count total matches
|
|
623
636
|
const totalRow = db.prepare(`
|
|
@@ -633,9 +646,9 @@ export function searchItemsForUser(userId, opts) {
|
|
|
633
646
|
const total = totalRow?.count ?? 0;
|
|
634
647
|
const limit = Math.min(opts.limit ?? 30, 100);
|
|
635
648
|
const offset = Math.max(opts.offset ?? 0, 0);
|
|
636
|
-
let orderBy = "i.published_at DESC, i.created_at DESC, i.id DESC";
|
|
649
|
+
let orderBy = "COALESCE(julianday(i.published_at), julianday(i.created_at)) DESC, i.created_at DESC, i.id DESC";
|
|
637
650
|
if (opts.sort !== "date" && fts) {
|
|
638
|
-
orderBy = "COALESCE(fts.rank, 0) ASC, i.published_at DESC";
|
|
651
|
+
orderBy = "COALESCE(fts.rank, 0) ASC, COALESCE(julianday(i.published_at), julianday(i.created_at)) DESC";
|
|
639
652
|
}
|
|
640
653
|
const querySql = `
|
|
641
654
|
SELECT DISTINCT i.*,
|
|
@@ -6,7 +6,7 @@ import { z } from "zod";
|
|
|
6
6
|
import { config } from "../config.js";
|
|
7
7
|
import { getUserCapabilities, hasUserCapability } from "../auth/capabilities.js";
|
|
8
8
|
import { hasTotp, securityVersion, sessionIsSecure } from "../auth/security.js";
|
|
9
|
-
import {
|
|
9
|
+
import { verifyPasswordOrDummy } from "../auth/password.js";
|
|
10
10
|
import { findUserByEmail, findUserById } from "../auth/users.js";
|
|
11
11
|
import { ISSUER, MCP_RESOURCE } from "./constants.js";
|
|
12
12
|
import { getOAuthHost } from "./host.js";
|
|
@@ -115,7 +115,15 @@ function basicClientId(req) {
|
|
|
115
115
|
if (!header?.startsWith("Basic "))
|
|
116
116
|
return null;
|
|
117
117
|
const [id] = Buffer.from(header.slice(6), "base64").toString("utf8").split(":");
|
|
118
|
-
|
|
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
|
+
}
|
|
119
127
|
}
|
|
120
128
|
const str = (value) => (typeof value === "string" ? value : "");
|
|
121
129
|
function tokenResponse(res, tokens, scope) {
|
|
@@ -312,7 +320,8 @@ oauthAuthorizeRouter.post("/oauth/authorize/login", sameOriginOnly, loginLimiter
|
|
|
312
320
|
return;
|
|
313
321
|
}
|
|
314
322
|
const user = findUserByEmail(parsed.data.email);
|
|
315
|
-
|
|
323
|
+
const passwordMatches = verifyPasswordOrDummy(parsed.data.password, user?.password_hash);
|
|
324
|
+
if (!user || !passwordMatches) {
|
|
316
325
|
page(res, 401, renderLogin(req, { clientName: client.client_name, returnTo, failed: true }));
|
|
317
326
|
return;
|
|
318
327
|
}
|
package/server/package.json
CHANGED
package/server/dist/app.js
DELETED
|
@@ -1,100 +0,0 @@
|
|
|
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
|
package/server/dist/auth/plan.js
DELETED
|
@@ -1,58 +0,0 @@
|
|
|
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
|
package/server/dist/index.js
DELETED
|
@@ -1,15 +0,0 @@
|
|
|
1
|
-
export { createApp, startCoreSchedulers, DEFAULT_WEB_DIST } from "./app.js";
|
|
2
|
-
export * from "./auth/capabilities.js";
|
|
3
|
-
export * from "./auth/tokens.js";
|
|
4
|
-
export * from "./auth/middleware.js";
|
|
5
|
-
export * from "./auth/users.js";
|
|
6
|
-
export * from "./config.js";
|
|
7
|
-
export * from "./db/index.js";
|
|
8
|
-
export { apiRouter } from "./api/index.js";
|
|
9
|
-
export { mcpRouter } from "./mcp/http.js";
|
|
10
|
-
export { startPollingScheduler } from "./feeds/poller.js";
|
|
11
|
-
export { startCleanupScheduler } from "./feeds/cleanup.js";
|
|
12
|
-
export { repairEncodedText } from "./feeds/repository.js";
|
|
13
|
-
export { pruneArchive, scheduleMissingArchives } from "./feeds/archive.js";
|
|
14
|
-
export { startEditionScheduler } from "./native/editions.js";
|
|
15
|
-
//# sourceMappingURL=index.js.map
|