Personal save-for-later and Miniflux e-reader proxy for Xteink X4 (wip)
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: constrain allowed miniflux users to allowed did list

+55 -9
+15
module.nix
··· 54 54 ''; 55 55 }; 56 56 57 + allowedDids = lib.mkOption { 58 + type = lib.types.listOf lib.types.str; 59 + default = [ ]; 60 + example = [ "did:plc:abc123" "did:plc:def456" ]; 61 + description = '' 62 + List of atproto DIDs allowed to use Miniflux sync. When empty, all 63 + authenticated users can sync. When non-empty, only these DIDs get 64 + access to feeds, sync, and RSS entries; everyone else can still use 65 + saves. 66 + ''; 67 + }; 68 + 57 69 publicUrl = lib.mkOption { 58 70 type = lib.types.nullOr lib.types.str; 59 71 default = null; ··· 89 101 } 90 102 // lib.optionalAttrs (cfg.publicUrl != null) { 91 103 NIGHTSHADE_PUBLIC_URL = cfg.publicUrl; 104 + } 105 + // lib.optionalAttrs (cfg.allowedDids != []) { 106 + NIGHTSHADE_ALLOWED_DIDS = lib.concatStringsSep "," cfg.allowedDids; 92 107 }; 93 108 serviceConfig = { 94 109 ExecStart = lib.getExe cfg.package;
+10
src/server/config.ts
··· 23 23 ? readTokenFile(process.env.MINIFLUX_TOKEN_FILE) 24 24 : ""), 25 25 }, 26 + allowedDids: new Set( 27 + (process.env.NIGHTSHADE_ALLOWED_DIDS ?? "") 28 + .split(",") 29 + .map((s) => s.trim()) 30 + .filter(Boolean), 31 + ), 26 32 }; 27 33 28 34 if (!config.miniflux.token) { ··· 43 49 export function publicBase(): string { 44 50 return config.publicUrl ?? `http://127.0.0.1:${config.port}`; 45 51 } 52 + 53 + export function minifluxAllowed(did: string): boolean { 54 + return config.allowedDids.size === 0 || config.allowedDids.has(did); 55 + }
+2 -2
src/server/index.ts
··· 5 5 import { Hono } from "hono"; 6 6 import { logger } from "hono/logger"; 7 7 8 - import { config, isLoopback, publicBase } from "./config.js"; 8 + import { config, isLoopback, minifluxAllowed, publicBase } from "./config.js"; 9 9 import { MinifluxClient } from "./miniflux.js"; 10 10 import { BodyCache } from "./body-cache.js"; 11 11 import { buildOAuth } from "./oauth.js"; ··· 52 52 setInterval( 53 53 async () => { 54 54 const session = await oauth.getActiveSession(); 55 - if (!session) return; 55 + if (!session || !minifluxAllowed(session.did)) return; 56 56 const syncer = new Syncer(config.dataDir, new AtprotoRepo(session), mf); 57 57 try { 58 58 const res = await syncer.run();
+12 -1
src/server/routes-api.ts
··· 4 4 import { Syncer } from "./sync.js"; 5 5 import { fetchAndExtract } from "./readability.js"; 6 6 import type { NightshadeOAuth } from "./oauth.js"; 7 - import { config } from "./config.js"; 7 + import { config, minifluxAllowed } from "./config.js"; 8 8 9 9 type Env = { Variables: { repo: AtprotoRepo } }; 10 10 ··· 72 72 // --- Feeds (atproto-canonical, mirrored to Miniflux) --- 73 73 app.get("/feeds", async (c) => { 74 74 const repo = c.get("repo"); 75 + if (!minifluxAllowed(repo.did)) 76 + return c.json({ error: "miniflux sync not enabled for this account" }, 403); 75 77 const [feeds, mfFeeds] = await Promise.all([ 76 78 repo.listFeeds(), 77 79 mf.listFeeds().catch(() => []), ··· 98 100 99 101 app.post("/feeds", async (c) => { 100 102 const repo = c.get("repo"); 103 + if (!minifluxAllowed(repo.did)) 104 + return c.json({ error: "miniflux sync not enabled for this account" }, 403); 101 105 const { url, title } = await c.req.json<{ url: string; title?: string }>(); 102 106 if (!url) return c.json({ error: "missing url" }, 400); 103 107 await repo.createFeed(url, title); ··· 111 115 112 116 app.delete("/feeds/:rkey", async (c) => { 113 117 const repo = c.get("repo"); 118 + if (!minifluxAllowed(repo.did)) 119 + return c.json({ error: "miniflux sync not enabled for this account" }, 403); 114 120 await repo.deleteFeed(c.req.param("rkey")); 115 121 try { 116 122 await makeSyncer(repo).run(); ··· 121 127 }); 122 128 123 129 app.post("/feeds/refresh", async (c) => { 130 + const repo = c.get("repo"); 131 + if (!minifluxAllowed(repo.did)) 132 + return c.json({ error: "miniflux sync not enabled for this account" }, 403); 124 133 await mf.refreshAll(); 125 134 return c.body(null, 204); 126 135 }); 127 136 128 137 app.post("/sync", async (c) => { 129 138 const repo = c.get("repo"); 139 + if (!minifluxAllowed(repo.did)) 140 + return c.json({ error: "miniflux sync not enabled for this account" }, 403); 130 141 const result = await makeSyncer(repo).run(); 131 142 return c.json(result); 132 143 });
+16 -6
src/server/routes-device.ts
··· 6 6 import type { NightshadeOAuth } from "./oauth.js"; 7 7 import { htmlToText } from "./readability.js"; 8 8 import { renderItem, renderList } from "./reader-format.js"; 9 + import { minifluxAllowed } from "./config.js"; 9 10 import type { UnifiedItem } from "../shared/types.js"; 10 11 11 12 export function deviceRoutes( ··· 28 29 const all = c.req.query("all") !== undefined; 29 30 const limit = Number(c.req.query("limit") ?? 50); 30 31 32 + const allowed = minifluxAllowed(repo.did); 31 33 const [entriesRes, saves] = await Promise.all([ 32 - mf.listEntries({ 33 - status: all ? undefined : "unread", 34 - limit, 35 - order: "published_at", 36 - direction: "desc", 37 - }), 34 + allowed 35 + ? mf.listEntries({ 36 + status: all ? undefined : "unread", 37 + limit, 38 + order: "published_at", 39 + direction: "desc", 40 + }) 41 + : { entries: [] }, 38 42 repo.listSaves(), 39 43 ]); 40 44 ··· 91 95 { "Content-Type": "text/plain; charset=utf-8" }, 92 96 ); 93 97 } 98 + const repo = await getRepo(); 99 + if (!repo || !minifluxAllowed(repo.did)) 100 + return c.text("not allowed\n", 403); 94 101 const entry = await mf.getEntry(Number(rawId)); 95 102 const body = entry.content 96 103 ? extractFromStoredHtml(entry.content) ··· 113 120 if (!repo) return c.text("not authenticated\n", 401); 114 121 await repo.markSaveRead(rawId.slice(1), true); 115 122 } else { 123 + const repo = await getRepo(); 124 + if (!repo || !minifluxAllowed(repo.did)) 125 + return c.text("not allowed\n", 403); 116 126 await mf.markEntries([Number(rawId)], "read"); 117 127 } 118 128 return c.text("ok\n");