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: working nix module

+215 -192
+1
.gitignore
··· 4 4 *.sqlite3-* 5 5 .env 6 6 .env.local 7 + result
+28 -73
README.md
··· 1 1 # nightshade 2 2 3 - Personal save-for-later + e-reader proxy. Sits alongside an existing 3 + Personal save-for-later and e-reader proxy. Runs next to a 4 4 [Miniflux](https://miniflux.app/) instance and adds: 5 5 6 - - a save-arbitrary-URL flow (fetch → readability → local SQLite) 7 - - a plain-text HTTP API for an e-ink device to paginate through unread items 8 - - a tiny Preact UI to subscribe to feeds, save URLs, and manage both lists 9 - 10 - RSS subscriptions and entries live in Miniflux. Nightshade only stores the 11 - one-off saved URLs. 12 - 13 - ## Stack 6 + - save-arbitrary-URL: fetches the page, runs readability, stores the text in SQLite 7 + - a plain-text HTTP API that paginates unread items for an e-ink device 8 + - a Preact UI for subscribing to feeds, saving URLs, and managing both 14 9 15 - - Node + TypeScript 16 - - Hono + `@hono/node-server` 17 - - `better-sqlite3` for local saves 18 - - `@mozilla/readability` + `linkedom` for article extraction 19 - - Preact + Vite for the frontend 20 - - pnpm 10 + Miniflux holds the RSS subscriptions and entries. Nightshade stores only the 11 + one-off saves. 21 12 22 13 ## Development 23 14 24 15 ```sh 25 16 pnpm install 26 17 cp .env.example .env # fill in MINIFLUX_URL + MINIFLUX_TOKEN 27 - pnpm dev # runs server (8787) and Vite (5173) in parallel 18 + pnpm dev # server on 8787, Vite on 5173 28 19 ``` 29 20 30 - The Vite dev server proxies `/api` and `/device` to the Node server on 8787. 31 - 32 - Production build: 33 - 34 - ```sh 35 - pnpm build # vite build → dist/public 36 - pnpm start # tsx src/server/index.ts, serves dist/public as SPA 37 - ``` 21 + Vite proxies `/api` and `/device` through to the Node server. 38 22 39 23 ## Environment 40 24 41 - | var | default | purpose | 42 - |---|---|---| 43 - | `MINIFLUX_URL` | — | required, base URL of your Miniflux instance | 44 - | `MINIFLUX_TOKEN` | — | required (or `MINIFLUX_TOKEN_FILE`), Miniflux API key | 45 - | `MINIFLUX_TOKEN_FILE` | — | path to file containing the token (systemd credential) | 46 - | `NIGHTSHADE_PORT` | `8787` | HTTP port | 47 - | `NIGHTSHADE_DB` | `./nightshade.sqlite3` | SQLite file path | 25 + | var | default | purpose | 26 + | --------------------- | ---------------------- | ------------------------------------------------------ | 27 + | `MINIFLUX_URL` | n/a | required, base URL of your Miniflux instance | 28 + | `MINIFLUX_TOKEN` | n/a | required (or `MINIFLUX_TOKEN_FILE`), Miniflux API key | 29 + | `MINIFLUX_TOKEN_FILE` | n/a | path to file containing the token (systemd credential) | 30 + | `NIGHTSHADE_PORT` | `8787` | HTTP port | 31 + | `NIGHTSHADE_DB` | `./nightshade.sqlite3` | SQLite file path | 48 32 49 33 ## HTTP API 50 34 51 35 ### Browser / management (JSON) 52 36 53 - - `GET /api/saves[?all=1]` — list saved items 54 - - `POST /api/saves` — save a URL (body `{url}`) 55 - - `DELETE /api/saves/:id` — remove a saved item 56 - - `POST /api/saves/:id/read` — mark read 57 - - `POST /api/saves/:id/unread` — mark unread 58 - - `GET /api/feeds` — list Miniflux subscriptions 59 - - `POST /api/feeds` — subscribe (body `{url, category_id?}`) 60 - - `DELETE /api/feeds/:id` — unsubscribe 61 - - `POST /api/feeds/refresh` — refresh all feeds 37 + - `GET /api/saves[?all=1]` — list saved items 38 + - `POST /api/saves` — save a URL (body `{url}`) 39 + - `DELETE /api/saves/:id` — remove a saved item 40 + - `POST /api/saves/:id/read` — mark read 41 + - `POST /api/saves/:id/unread` — mark unread 42 + - `GET /api/feeds` — list Miniflux subscriptions 43 + - `POST /api/feeds` — subscribe (body `{url, category_id?}`) 44 + - `DELETE /api/feeds/:id` — unsubscribe 45 + - `POST /api/feeds/refresh` — refresh all feeds 62 46 63 47 ### E-reader device (plain text) 64 48 65 - No auth, no TLS — LAN only. 49 + LAN only. No auth, no TLS. 66 50 67 - - `GET /device/list[?all=1&limit=N]` — merged list of unread items 68 - - `GET /device/item/:id[?page=N]` — one item, paginated (60-col wrap, 24 lines/page) 69 - - `POST /device/item/:id/read` — mark read 51 + - `GET /device/list[?all=1&limit=N]` — merged list of unread items 52 + - `GET /device/item/:id[?page=N]` — one item, paginated (60-col wrap, 24 lines/page) 53 + - `POST /device/item/:id/read` — mark read 70 54 71 55 IDs: Miniflux entries use the numeric entry id; saved items use `s<id>`. 72 56 ··· 96 80 --- 97 81 <wrapped body for this page> 98 82 ``` 99 - 100 - ## NixOS 101 - 102 - ```nix 103 - { 104 - inputs.nightshade.url = "path:/path/to/nightshade"; 105 - 106 - outputs = { nixpkgs, nightshade, ... }: { 107 - nixosConfigurations.myhost = nixpkgs.lib.nixosSystem { 108 - modules = [ 109 - nightshade.nixosModules.default 110 - { 111 - services.nightshade = { 112 - enable = true; 113 - minifluxUrl = "http://miniflux.lan:8080"; 114 - minifluxTokenFile = "/run/secrets/miniflux-token"; 115 - openFirewall = true; 116 - }; 117 - } 118 - ]; 119 - }; 120 - }; 121 - } 122 - ``` 123 - 124 - The flake package vendors `node_modules/` and `dist/` from the source tree. 125 - Run `pnpm install && pnpm build` before `nix build`. A proper pure-Nix build 126 - would need `pnpm2nix` or FOD'd installs, which isn't worth it for a personal 127 - tool.
+4 -119
flake.nix
··· 11 11 inherit system; 12 12 pkgs = import nixpkgs { inherit system; }; 13 13 }); 14 - 15 - mkPackage = pkgs: pkgs.stdenv.mkDerivation { 16 - pname = "nightshade"; 17 - version = "0.1.0"; 18 - src = ./.; 19 - 20 - nativeBuildInputs = [ pkgs.makeWrapper ]; 21 - buildInputs = [ pkgs.nodejs_22 ]; 22 - 23 - # Node deps (including better-sqlite3's compiled native addon) and the 24 - # Vite build must be produced before `nix build`: 25 - # pnpm install && pnpm build 26 - # This derivation vendors node_modules/ and dist/ from the source tree. 27 - dontBuild = true; 28 - 29 - installPhase = '' 30 - mkdir -p $out/share/nightshade 31 - cp -r \ 32 - package.json \ 33 - tsconfig.json \ 34 - src \ 35 - dist \ 36 - node_modules \ 37 - $out/share/nightshade/ 38 - 39 - mkdir -p $out/bin 40 - makeWrapper ${pkgs.nodejs_22}/bin/node $out/bin/nightshade \ 41 - --add-flags "--import=tsx" \ 42 - --add-flags "$out/share/nightshade/src/server/index.ts" \ 43 - --prefix NODE_PATH : "$out/share/nightshade/node_modules" 44 - ''; 45 - }; 46 14 in { 47 15 packages = forAllSystems ({ pkgs, ... }: { 48 - default = mkPackage pkgs; 49 - nightshade = mkPackage pkgs; 16 + nightshade = pkgs.callPackage ./package.nix { }; 17 + default = pkgs.callPackage ./package.nix { }; 50 18 }); 51 19 52 20 devShells = forAllSystems ({ pkgs, ... }: { ··· 55 23 }; 56 24 }); 57 25 58 - nixosModules.default = { config, lib, pkgs, ... }: 59 - let 60 - cfg = config.services.nightshade; 61 - pkg = self.packages.${pkgs.system}.nightshade; 62 - in { 63 - options.services.nightshade = { 64 - enable = lib.mkEnableOption "Nightshade e-reader proxy"; 65 - port = lib.mkOption { 66 - type = lib.types.port; 67 - default = 8787; 68 - }; 69 - dataDir = lib.mkOption { 70 - type = lib.types.path; 71 - default = "/var/lib/nightshade"; 72 - }; 73 - user = lib.mkOption { 74 - type = lib.types.str; 75 - default = "nightshade"; 76 - }; 77 - openFirewall = lib.mkOption { 78 - type = lib.types.bool; 79 - default = false; 80 - }; 81 - minifluxUrl = lib.mkOption { 82 - type = lib.types.str; 83 - example = "http://192.168.4.71:18800"; 84 - description = "Base URL of the Miniflux instance."; 85 - }; 86 - minifluxTokenFile = lib.mkOption { 87 - type = lib.types.path; 88 - description = '' 89 - Path to a file containing the Miniflux API token. 90 - Readable by the nightshade user. Not placed in the Nix store. 91 - ''; 92 - }; 93 - }; 94 - 95 - config = lib.mkIf cfg.enable { 96 - users.users.${cfg.user} = { 97 - isSystemUser = true; 98 - group = cfg.user; 99 - home = cfg.dataDir; 100 - createHome = true; 101 - }; 102 - users.groups.${cfg.user} = { }; 103 - 104 - networking.firewall.allowedTCPPorts = 105 - lib.mkIf cfg.openFirewall [ cfg.port ]; 106 - 107 - systemd.services.nightshade = { 108 - description = "Nightshade e-reader proxy"; 109 - wantedBy = [ "multi-user.target" ]; 110 - after = [ "network.target" ]; 111 - environment = { 112 - NIGHTSHADE_PORT = toString cfg.port; 113 - NIGHTSHADE_DB = "${cfg.dataDir}/nightshade.sqlite3"; 114 - MINIFLUX_URL = cfg.minifluxUrl; 115 - }; 116 - serviceConfig = { 117 - ExecStart = "${pkg}/bin/nightshade"; 118 - User = cfg.user; 119 - Group = cfg.user; 120 - WorkingDirectory = cfg.dataDir; 121 - Restart = "on-failure"; 122 - RestartSec = 5; 123 - LoadCredential = [ "miniflux_token:${cfg.minifluxTokenFile}" ]; 124 - Environment = [ 125 - "MINIFLUX_TOKEN_FILE=%d/miniflux_token" 126 - ]; 127 - 128 - NoNewPrivileges = true; 129 - PrivateTmp = true; 130 - ProtectSystem = "strict"; 131 - ProtectHome = true; 132 - ReadWritePaths = [ cfg.dataDir ]; 133 - ProtectKernelTunables = true; 134 - ProtectKernelModules = true; 135 - ProtectControlGroups = true; 136 - RestrictNamespaces = true; 137 - RestrictRealtime = true; 138 - LockPersonality = true; 139 - }; 140 - }; 141 - }; 142 - }; 26 + nixosModules.default = import ./module.nix { inherit self; }; 27 + nixosModules.nightshade = import ./module.nix { inherit self; }; 143 28 }; 144 29 }
+102
module.nix
··· 1 + { self }: 2 + { 3 + config, 4 + lib, 5 + pkgs, 6 + ... 7 + }: 8 + let 9 + cfg = config.services.nightshade; 10 + defaultPkg = self.packages.${pkgs.system}.nightshade; 11 + in 12 + { 13 + options.services.nightshade = { 14 + enable = lib.mkEnableOption "Nightshade e-reader proxy"; 15 + 16 + package = lib.mkOption { 17 + type = lib.types.package; 18 + default = defaultPkg; 19 + description = "The nightshade package to run."; 20 + }; 21 + 22 + port = lib.mkOption { 23 + type = lib.types.port; 24 + default = 8787; 25 + }; 26 + 27 + dataDir = lib.mkOption { 28 + type = lib.types.path; 29 + default = "/var/lib/nightshade"; 30 + }; 31 + 32 + user = lib.mkOption { 33 + type = lib.types.str; 34 + default = "nightshade"; 35 + }; 36 + 37 + openFirewall = lib.mkOption { 38 + type = lib.types.bool; 39 + default = false; 40 + }; 41 + 42 + minifluxUrl = lib.mkOption { 43 + type = lib.types.str; 44 + example = "http://192.168.4.71:18800"; 45 + description = "Base URL of the Miniflux instance."; 46 + }; 47 + 48 + minifluxTokenFile = lib.mkOption { 49 + type = lib.types.path; 50 + description = '' 51 + Path to a file containing the Miniflux API token. Must be readable by 52 + root at boot. Loaded via systemd credentials so it never enters the 53 + Nix store or the unit environment. 54 + ''; 55 + }; 56 + }; 57 + 58 + config = lib.mkIf cfg.enable { 59 + users.users.${cfg.user} = { 60 + isSystemUser = true; 61 + group = cfg.user; 62 + home = cfg.dataDir; 63 + createHome = true; 64 + }; 65 + users.groups.${cfg.user} = { }; 66 + 67 + networking.firewall.allowedTCPPorts = lib.mkIf cfg.openFirewall [ cfg.port ]; 68 + 69 + systemd.services.nightshade = { 70 + description = "Nightshade e-reader proxy"; 71 + wantedBy = [ "multi-user.target" ]; 72 + after = [ "network.target" ]; 73 + environment = { 74 + NIGHTSHADE_PORT = toString cfg.port; 75 + NIGHTSHADE_DB = "${cfg.dataDir}/nightshade.sqlite3"; 76 + MINIFLUX_URL = cfg.minifluxUrl; 77 + }; 78 + serviceConfig = { 79 + ExecStart = lib.getExe cfg.package; 80 + User = cfg.user; 81 + Group = cfg.user; 82 + WorkingDirectory = cfg.dataDir; 83 + Restart = "on-failure"; 84 + RestartSec = 5; 85 + LoadCredential = [ "miniflux_token:${cfg.minifluxTokenFile}" ]; 86 + Environment = [ "MINIFLUX_TOKEN_FILE=%d/miniflux_token" ]; 87 + 88 + NoNewPrivileges = true; 89 + PrivateTmp = true; 90 + ProtectSystem = "strict"; 91 + ProtectHome = true; 92 + ReadWritePaths = [ cfg.dataDir ]; 93 + ProtectKernelTunables = true; 94 + ProtectKernelModules = true; 95 + ProtectControlGroups = true; 96 + RestrictNamespaces = true; 97 + RestrictRealtime = true; 98 + LockPersonality = true; 99 + }; 100 + }; 101 + }; 102 + }
+80
package.nix
··· 1 + { 2 + lib, 3 + stdenv, 4 + nodejs_22, 5 + pnpm, 6 + pnpmConfigHook, 7 + fetchPnpmDeps, 8 + makeWrapper, 9 + python3, 10 + pkg-config, 11 + }: 12 + 13 + stdenv.mkDerivation (finalAttrs: { 14 + pname = "nightshade"; 15 + version = "0.1.0"; 16 + 17 + src = lib.fileset.toSource { 18 + root = ./.; 19 + fileset = lib.fileset.unions [ 20 + ./package.json 21 + ./pnpm-lock.yaml 22 + ./tsconfig.json 23 + ./vite.config.ts 24 + ./src 25 + ]; 26 + }; 27 + 28 + pnpmDeps = fetchPnpmDeps { 29 + inherit (finalAttrs) pname version src; 30 + fetcherVersion = 1; 31 + hash = "sha256-WJ59aHdbHLD0wUhhnt65hw1L7O1gErfVtUXUhgqkHvU="; 32 + }; 33 + 34 + nativeBuildInputs = [ 35 + nodejs_22 36 + pnpm 37 + pnpmConfigHook 38 + makeWrapper 39 + python3 40 + pkg-config 41 + ]; 42 + 43 + env = { 44 + npm_config_build_from_source = "true"; 45 + npm_config_nodedir = nodejs_22; 46 + }; 47 + 48 + buildPhase = '' 49 + runHook preBuild 50 + pnpm rebuild better-sqlite3 51 + pnpm run build 52 + runHook postBuild 53 + ''; 54 + 55 + installPhase = '' 56 + runHook preInstall 57 + 58 + mkdir -p $out/share/nightshade 59 + cp -r \ 60 + package.json \ 61 + tsconfig.json \ 62 + src \ 63 + dist \ 64 + node_modules \ 65 + $out/share/nightshade/ 66 + 67 + mkdir -p $out/bin 68 + makeWrapper ${nodejs_22}/bin/node $out/bin/nightshade \ 69 + --add-flags "--import=$out/share/nightshade/node_modules/tsx/dist/loader.mjs" \ 70 + --add-flags "$out/share/nightshade/src/server/index.ts" 71 + 72 + runHook postInstall 73 + ''; 74 + 75 + meta = { 76 + description = "Personal save-for-later + e-reader proxy for Miniflux"; 77 + mainProgram = "nightshade"; 78 + platforms = lib.platforms.unix; 79 + }; 80 + })