feedkeeper 0.23.0 → 0.24.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.
@@ -30,7 +30,7 @@
30
30
  <meta name="twitter:image" content="/logo.svg" />
31
31
  <!-- Inline so the splash paints before any script or stylesheet loads -->
32
32
  <style>html{background:#f8fafc}html[data-theme=dark]{background:#0b0f17}@media (prefers-color-scheme:dark){html:not([data-theme=light]){background:#0b0f17}}#splash{z-index:100;background:inherit;justify-content:center;align-items:center;transition:opacity .3s;display:flex;position:fixed;inset:0}#splash img{width:62px;height:62px;animation:.4s both splash-in}#splash.splash-hidden{opacity:0;pointer-events:none}@keyframes splash-in{0%{opacity:0;transform:scale(.92)}}@media (prefers-reduced-motion:reduce){#splash,#splash img{transition:none;animation:none}}</style>
33
- <script type="module" crossorigin src="/assets/index-xx6TyOwB.js"></script>
33
+ <script type="module" crossorigin src="/assets/index-BjhXf9OJ.js"></script>
34
34
  <link rel="stylesheet" crossorigin href="/assets/index-B4e2oqbo.css">
35
35
  </head>
36
36
  <body>
package/web/dist/sw.js CHANGED
@@ -1,6 +1,6 @@
1
1
  // FeedKeeper service worker. Built into dist/sw.js, where the placeholder below is
2
2
  // replaced with a hash of the build's file names so every release gets its own app cache.
3
- const BUILD_ID = "93f2f6a49c67";
3
+ const BUILD_ID = "3c5196c9a4ba";
4
4
  const STATIC_CACHE = `fk-static-${BUILD_ID}`;
5
5
  const API_CACHE = "fk-api";
6
6
  const SHELL = "/index.html";
@@ -15,7 +15,8 @@ const MAX_CACHED_IMAGES = 3000;
15
15
  // (tokens, users, settings, mutations) always goes to the server.
16
16
  const READABLE_API = [/^\/api\/onboarding\/status$/, /^\/api\/config$/, /^\/api\/auth\/me$/, /^\/api\/feeds$/, /^\/api\/folders$/, /^\/api\/items$/, /^\/api\/filters\/muted$/];
17
17
  const FEED_ICON = /^\/api\/feeds\/\d+\/icon$/;
18
- const IMMUTABLE_API = [/^\/api\/archive\/images\/\d+$/, /^\/api\/items\/\d+\/image$/, /^\/api\/auth\/me\/avatar$/];
18
+ const MUTABLE_IMAGE = /^\/api\/items\/\d+\/image$/;
19
+ const IMMUTABLE_API = [/^\/api\/archive\/images\/\d+$/, /^\/api\/auth\/me\/avatar$/];
19
20
  // Files next to index.html (theme script, manifest, icons) are the only other thing served from the cache. Any other
20
21
  // path answers per request and per signed-in user, so a stored copy could show one account another's data.
21
22
  const STATIC_FILE = /^\/[^/]+\.(?:js|css|svg|png|ico|webmanifest|txt|woff2?)$/;
@@ -56,6 +57,8 @@ self.addEventListener("fetch", (event) => {
56
57
  if (url.searchParams.has("offline")) return;
57
58
  if (FEED_ICON.test(url.pathname)) {
58
59
  event.respondWith(staleWhileRevalidate(API_CACHE, request));
60
+ } else if (MUTABLE_IMAGE.test(url.pathname)) {
61
+ event.respondWith(mutableImage(request));
59
62
  } else if (IMMUTABLE_API.some((pattern) => pattern.test(url.pathname))) {
60
63
  event.respondWith(cacheFirst(API_CACHE, request, MAX_CACHED_IMAGES));
61
64
  } else if (READABLE_API.some((pattern) => pattern.test(url.pathname))) {
@@ -114,6 +117,31 @@ async function cacheFirst(cacheName, request, limit = 0) {
114
117
  return response;
115
118
  }
116
119
 
120
+ async function mutableImage(request) {
121
+ const cache = await caches.open(API_CACHE);
122
+ const cached = await cache.match(request, MATCH);
123
+ const date = cached && Date.parse(cached.headers.get("date") ?? "");
124
+ const maxAge = Number(cached?.headers.get("cache-control")?.match(/max-age=(\d+)/)?.[1] ?? 0);
125
+ // Old immutable proxy responses must also expire at the new six-hour boundary.
126
+ if (cached && !cached.headers.get("cache-control")?.includes("no-store") && Date.now() - date < Math.min(maxAge, 21600) * 1000) return cached;
127
+ try {
128
+ const headers = new Headers(request.headers);
129
+ if (cached?.headers.get("etag")) headers.set("If-None-Match", cached.headers.get("etag"));
130
+ let response = await withTimeout(fetch(new Request(request, { headers, cache: "no-cache" })), NETWORK_TIMEOUT_MS);
131
+ if (response.status === 304 && cached) {
132
+ const refreshed = new Headers(cached.headers);
133
+ for (const name of ["date", "etag", "cache-control"]) if (response.headers.has(name)) refreshed.set(name, response.headers.get(name));
134
+ refreshed.set("date", response.headers.get("date") ?? new Date().toUTCString());
135
+ response = new Response(await cached.arrayBuffer(), { status: 200, headers: refreshed });
136
+ }
137
+ if (response.ok) {
138
+ if (response.headers.get("cache-control")?.includes("no-store")) await cache.delete(request);
139
+ else { await cache.put(request, response.clone()); await trim(cache, key => /\/api\/(archive\/images\/|items\/\d+\/image)/.test(key.url), MAX_CACHED_IMAGES); }
140
+ }
141
+ return response; // An online denial must never fall back to the previous account's bytes.
142
+ } catch { return cached ?? Response.error(); }
143
+ }
144
+
117
145
  async function networkFirst(cacheName, request, limit) {
118
146
  const cache = await caches.open(cacheName);
119
147
  try {
@@ -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
@@ -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
@@ -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