diff --git a/.gitea/README.md b/.gitea/README.md new file mode 100644 index 0000000..6b30e20 --- /dev/null +++ b/.gitea/README.md @@ -0,0 +1,21 @@ +# Gitea CI Workflows + +## security.yml + +Runs on push/PR to `master`. Three jobs: + +1. **dependency-audit** — `npm audit --audit-level=high` for budget and frontend +2. **secret-scanning** — checks for tracked .env/.db files and hardcoded secret patterns +3. **dockerfile-lint** — verifies all Dockerfiles have `USER` (non-root) and `HEALTHCHECK` + +## Prerequisites + +These workflows require a **Gitea Actions runner** to be configured. +Without a runner, the workflows are committed but will not execute. + +To set up a runner: +1. Go to Gitea → Site Administration → Runners +2. Register a runner (Docker-based or shell-based) +3. The workflows will automatically execute on the next push + +See: https://docs.gitea.com/usage/actions/overview diff --git a/docs/trust-model.md b/docs/trust-model.md index 458cb32..e7c01cb 100644 --- a/docs/trust-model.md +++ b/docs/trust-model.md @@ -10,19 +10,30 @@ All frontend requests go through: Browser → Pangolin → frontend-v2 (SvelteKi - Users authenticate via `/api/auth/login` with username/password (bcrypt) - Session stored as `platform_session` cookie (HttpOnly, Secure, SameSite=Lax) - All `(app)` routes require valid session (checked in `+layout.server.ts`) +- Registration is disabled (returns 403) ### Service-level auth -Each backend service has its own auth mechanism. The gateway injects credentials when proxying: -| Service | Auth Type | Injected By Gateway | Validated Against | -|---------|-----------|--------------------|--------------------| -| Trips | Bearer token | `Authorization: Bearer {token}` | `/api/trips` (protected endpoint) | -| Fitness | Bearer token | `Authorization: Bearer {token}` | `/api/user` (protected endpoint) | -| Reader | API key | `X-Auth-Token: {key}` | `/v1/feeds/counters` | -| Inventory | API key | `X-API-Key: {key}` | `/summary` | -| Budget | API key | `X-API-Key: {key}` | `/summary` | -| Books (Shelfmark) | None (proxied) | — | Gateway auth only | -| Music (Spotizerr) | None (proxied) | — | Gateway auth only | +Services fall into two categories: + +**Per-user token services** — each platform user has their own service credential: + +| Service | Auth Type | How Injected | Per-User Data? | +|---------|-----------|-------------|----------------| +| Trips | Bearer token | `Authorization: Bearer {token}` | No — all users see all trips | +| Fitness | Bearer token | `Authorization: Bearer {token}` | **Yes** — each user has own entries, goals, favorites | + +**Gateway-key services** — a single API key shared by all platform users: + +| Service | Auth Type | How Injected | Per-User Data? | +|---------|-----------|-------------|----------------| +| Inventory | API key | `X-API-Key: {key}` | No — single shared inventory | +| Budget | API key | `X-API-Key: {key}` | No — single shared budget | +| Reader | API key | `X-Auth-Token: {key}` | No — single shared feed reader | +| Books (Shelfmark) | None | Proxied through gateway | No — single shared download manager | +| Music (Spotizerr) | None | Proxied through gateway | No — single shared music downloader | + +**Important**: Gateway-key services do NOT have per-user data isolation. Any authenticated platform user can access all data in these services. This is by design — the household shares a single budget, inventory, reader, and media library. ### Frontend hooks auth (SvelteKit) - Immich proxy: validates `platform_session` cookie before proxying @@ -30,23 +41,36 @@ Each backend service has its own auth mechanism. The gateway injects credentials - Legacy trips Immich: validates `platform_session` cookie before proxying ## Service Connections -- Users connect services via Settings page -- Token validation uses a **protected endpoint**, not health checks +- Users connect per-user services (trips, fitness) via Settings page +- Token validation uses a **protected endpoint** per service type — not health checks - Unknown services cannot be connected (rejected with 400) - Tokens stored in `service_connections` table, per-user +## Per-User Navigation +- Each user sees only their configured apps in the navbar +- Configured via `hiddenByUser` map in `+layout.server.ts` +- Apps not in nav are still accessible via direct URL (not blocked) + ## Internal Network -- All services communicate on Docker internal network +- All services communicate on Docker internal network via plain HTTP - No service port is exposed to the host (except frontend-v2 via Pangolin) - Gateway is the single entry point for all API traffic +- No custom SSL context — all internal calls are plain HTTP ## TLS -- External HTTPS: default TLS verification (certificate + hostname) -- Internal services: `_internal_ssl_ctx` with verification disabled (Docker services don't have valid certs) -- Image proxy: default TLS verification + domain allowlist +- External HTTPS (OpenAI, SMTP2GO, Open Library): default TLS verification +- Internal services: plain HTTP (Docker network, no TLS needed) +- Image proxy: default TLS verification + domain allowlist + content-type validation ## Secrets - All secrets loaded from environment variables - No hardcoded credentials in code - `.env` files excluded from git - Admin credentials required via `ADMIN_USERNAME`/`ADMIN_PASSWORD` env vars +- Service API keys generated per service, stored in `.env` + +## Known Limitations +- Gateway-key services are shared — no per-user access control +- Books and Music services have no auth at all (rely on gateway session only) +- Shelfmark and Spotizerr accept any request from the Docker network +- Per-user nav hiding is cosmetic — direct URL access is not blocked diff --git a/services/budget/server.js b/services/budget/server.js index 3cc116a..46da659 100644 --- a/services/budget/server.js +++ b/services/budget/server.js @@ -201,11 +201,21 @@ app.get('/transactions', requireReady, async (req, res) => { } }); -// ---- Recent transactions across all accounts ------------------------------ +// ---- Recent transactions across all accounts (cached 30s) ----------------- +// NOTE: Actual Budget API requires per-account queries. There is no cross-account +// transaction endpoint. Fan-out across accounts is unavoidable. Cache mitigates +// repeated calls from dashboard + page load. + +let recentTxnCache = { data: null, limit: 0, expiresAt: 0 }; app.get('/transactions/recent', requireReady, async (_req, res) => { try { const limit = parseInt(_req.query.limit, 10) || 20; + + if (recentTxnCache.data && recentTxnCache.limit >= limit && Date.now() < recentTxnCache.expiresAt) { + return res.json(recentTxnCache.data.slice(0, limit)); + } + const accounts = await api.getAccounts(); const { payeeMap, accountMap, categoryMap } = await buildLookups(); @@ -221,9 +231,10 @@ app.get('/transactions/recent', requireReady, async (_req, res) => { } all.sort((a, b) => (b.date > a.date ? 1 : b.date < a.date ? -1 : 0)); - all = all.slice(0, limit); + const enriched = all.slice(0, Math.max(limit, 100)).map((t) => enrichTransaction(t, payeeMap, accountMap, categoryMap)); - res.json(all.map((t) => enrichTransaction(t, payeeMap, accountMap, categoryMap))); + recentTxnCache = { data: enriched, limit: Math.max(limit, 100), expiresAt: Date.now() + 30000 }; + res.json(enriched.slice(0, limit)); } catch (err) { console.error('[budget] GET /transactions/recent error:', err); res.status(500).json({ error: err.message }); @@ -359,10 +370,17 @@ app.post('/make-transfer', requireReady, async (req, res) => { } }); -// ---- Uncategorized count (total across all accounts) ---------------------- +// ---- Uncategorized count (cached 2 min) ------------------------------------ +// NOTE: Fans out across all accounts — Actual API constraint. + +let uncatCache = { count: 0, expiresAt: 0 }; app.get('/uncategorized-count', requireReady, async (_req, res) => { try { + if (Date.now() < uncatCache.expiresAt) { + return res.json({ count: uncatCache.count }); + } + const accounts = await api.getAccounts(); const startDate = '2000-01-01'; const endDate = new Date().toISOString().slice(0, 10); @@ -373,6 +391,7 @@ app.get('/uncategorized-count', requireReady, async (_req, res) => { const txns = await api.getTransactions(acct.id, startDate, endDate); total += txns.filter((t) => !t.category && !t.transfer_id && t.amount !== 0).length; } + uncatCache = { count: total, expiresAt: Date.now() + 120000 }; res.json({ count: total }); } catch (err) { console.error('[budget] GET /uncategorized-count error:', err); diff --git a/services/inventory/server.js b/services/inventory/server.js index fb196e5..83fcb5c 100755 --- a/services/inventory/server.js +++ b/services/inventory/server.js @@ -190,10 +190,6 @@ app.get('/search-records', async (req, res) => { }); // Test endpoint -app.get('/test', (req, res) => { - res.json({ message: 'Server is working!', timestamp: new Date() }); -}); - // Get single item details app.get('/item-details/:id', async (req, res) => { try {