Personal save-for-later and Miniflux e-reader proxy for Xteink X4 (wip)
README.md

nightshade #

Personal save-for-later and e-reader proxy, backed by atproto for data ownership and Miniflux for RSS polling.

  • feed subscriptions and saved URLs live on your atproto PDS as records (net.solanaceae.nightshade.feed, net.solanaceae.nightshade.save)
  • Nightshade syncs feeds two-way between atproto and Miniflux
  • the e-reader endpoint fetches saved-URL bodies on demand (no local storage), with a short in-memory TTL cache
  • Preact UI for atproto OAuth sign-in, feed management, and saving URLs

Data shape #

atproto holds feed subscriptions and saved URLs (metadata only, no bodies). Miniflux mirrors the feed list and does the actual polling, entry storage, and entry read-state. Nightshade keeps nothing of its own apart from OAuth session files and a small sync snapshot. Kill the process and your data is fine.

Development #

pnpm install
cp .env.example .env    # fill in MINIFLUX_URL + MINIFLUX_TOKEN
pnpm dev                # server on 8787, Vite on 5173

Leaving NIGHTSHADE_PUBLIC_URL unset runs atproto OAuth in loopback mode (http://localhost client_id) so you can sign in without a public URL.

Environment #

var default purpose
MINIFLUX_URL n/a required, base URL of your Miniflux instance
MINIFLUX_TOKEN n/a required (or MINIFLUX_TOKEN_FILE), Miniflux API key
MINIFLUX_TOKEN_FILE n/a path to file containing just the token value
NIGHTSHADE_PUBLIC_URL n/a unset for loopback OAuth; set to https://host.tld for production
NIGHTSHADE_PORT 8787 HTTP port
NIGHTSHADE_DATA_DIR ./data directory for OAuth state/session files and sync snapshot

HTTP API #

Auth #

  • GET /auth/status — { authenticated, did? }
  • POST /auth/login — body { handle }, returns { url } to redirect to
  • GET /auth/callback — OAuth redirect target
  • POST /auth/logout — revoke all local sessions

Management (JSON, requires session) #

  • GET /api/saves[?all=1] — list save records
  • POST /api/saves — body { url }; creates atproto record, fetches title
  • DELETE /api/saves/:rkey
  • POST /api/saves/:rkey/read / /unread
  • GET /api/feeds — list feed records
  • POST /api/feeds — body { url, title? }; creates atproto record, reconciles to Miniflux
  • DELETE /api/feeds/:rkey — deletes atproto record, reconciles to Miniflux
  • POST /api/feeds/refresh — refresh all Miniflux feeds now
  • POST /api/sync — trigger a reconciliation pass immediately

E-reader device (plain text, LAN-only, no auth/TLS) #

Save IDs are s<rkey>. RSS entry IDs are the numeric Miniflux entry id.

  • GET /device/list[?all=1&limit=N]
  • GET /device/item/:id[?page=N]
  • POST /device/item/:id/read

Sync behavior #

Two-way sync between atproto and Miniflux, driven by a last-seen snapshot in ${NIGHTSHADE_DATA_DIR}/sync-state.json. Runs on startup, after every feed-write through /api/feeds, and on a 5-minute timer.

  • Feed added on atproto → subscribed in Miniflux
  • Feed added via Miniflux UI or OPML import → record created on atproto
  • Feed removed from either side → removed from the other
  • Saves don't sync (Miniflux has no saves concept)