feat: production hardening + password reset, metrics, signed webhooks
Lint & Test / lint-and-check (push) Failing after 5m5s
Lint & Test / test (push) Has been skipped
Lint & Test / build (push) Has been skipped
Lint & Test / docker-build (push) Has been skipped
Lint & Test / audit (push) Has been skipped

Security hardening (CRITICAL/HIGH from production-readiness audit):
- Require strong JWT_SECRET + separate INTEGRATION_ENCRYPTION_KEY at boot;
  refuse placeholder defaults. Integration key now derived via HKDF.
- SSRF guard (src/lib/server/utils/safeFetch.ts): DNS-resolves and rejects
  RFC1918/loopback/link-local/IPv4-mapped IPv6/decimal-IP/cloud-metadata.
  Manual redirect handling re-validates each 3xx Location hop. Applied to
  healthcheck, RSS, calendar, metric, system-stats, camera, notifications,
  discovery, apps/preview, and all integration clients.
- API tokens, session refresh tokens, invite tokens, password-reset tokens
  switched from bcrypt to sha256 with @unique indexed lookup (O(1) instead
  of O(N) bcrypt-compares; eliminates a trivial DoS).
- Refresh-token reuse detection via Session.previousTokenHash.
- Permission checks on App PATCH/DELETE and Widget/Section endpoints.
- /api/integrations/alerts now requires auth.
- SVG uploads sanitized through DOMPurify (svg profile, scheme allow-list).
- Custom CSS sanitizer + selector scoping (decodes CSS unicode escapes
  before pattern match, drops forbidden at-rules incl. @import without
  whitespace, strips dangerous url() args). Scoped to .custom-css-scope.
- Backup restore validates SQLite magic header, takes a safety snapshot,
  uses atomic rename, re-applies pragmas.
- SQLite WAL + busy_timeout + foreign_keys + synchronous=NORMAL at startup.
- Healthcheck scheduler was dead code; wired in hooks.server.ts with
  HMR-safe singleton, concurrency cap, overlap prevention, retention jobs
  for AppClick/Notification/AuditLog. Composite indexes added on hot paths.
- Security headers (CSP, HSTS-on-https, X-Frame-Options, Permissions-Policy)
  emitted on every response.
- Account-enumeration mitigation on login (dummy bcrypt on no-user/oauth
  branches) + rate limiting on login/register/onboarding/refresh/invite/
  password-reset.
- OAuth callback sanitizes IdP error_description before echoing.

New features:
- Custom +error.svelte pages (root + boards + admin) via shared
  ErrorState component. Inverted hierarchy (status as label, title as hero).
- /forgot-password + /reset-password + admin-mediated /admin/password-resets
  page. SHA256 tokens, 24h TTL, all sessions revoked on apply.
- /invite page for manual invite-token redemption.
- /api/metrics Prometheus exposition with optional METRICS_TOKEN bearer
  auth. Counters for login/healthcheck/notification/integration; gauges
  for users/boards/apps + per-status app counts.
- Webhook HMAC-SHA256 signing for HTTP notification channels (optional
  shared secret + configurable signature header, default X-Signature-256).
- PATCH /api/users/me/password for self-service password change.
- Persistent uploads at /app/data/uploads with served-from-volume handler
  at /uploads/[...path]. SVGs served with CSP: sandbox.
- /api/health does a DB ping; returns 503 on disconnect.
- Public /status filtered to guest-accessible-board apps when unauthenticated.
- Audit log coverage: LOGIN_SUCCESS/FAILED, LOGOUT, OAUTH_LOGIN,
  OAUTH_USER_PROVISIONED, SESSION_REVOKED, API_TOKEN_*, INVITE_*,
  APP_UPDATED, PASSWORD_CHANGED, PASSWORD_RESET_*.

Performance:
- Board page: removed double findAll() over-fetch; include links + appTags
  in board query; widgets lazy-loaded via dynamic imports (marked,
  DOMPurify, hls.js, integration renderers).
- uptimeService.getAllAppsUptime: single batched query instead of N+1.
- 30s in-memory user-locals cache; invalidated on user mutation.
- pruneOldStatuses: single window-function DELETE instead of N+1.

Code quality:
- Typed error classes (NotFoundError, PermissionError, RateLimitError,
  IntegrationError) with toHttpError mapper.
- Locals.user shape exposes avatarUrl and narrows role via guard.
- App input types derived from Zod schemas via z.infer.
- 274 tests passing (up from 212); 62 new tests covering SSRF guard,
  CSS sanitizer, SVG sanitizer, rate limiter.

CI / Docker / config:
- Test workflow adds build, docker-build, audit jobs. Release workflow
  uses buildx multi-arch (amd64+arm64) with provenance + SBOM.
- Dockerfile uses tini, multi-stage prune, persistent uploads dir, single
  prisma migrate deploy (no destructive db push fallback).
- docker-compose: JWT_SECRET + INTEGRATION_ENCRYPTION_KEY required at
  startup, log rotation, resource limits.
- README documents breaking-change upgrade path.

Bug fixes from UI/UX review:
- ~55 missing i18n keys added to en/ru (auth flows, error pages, admin
  nav, register invite banner, settings.card_style).
- Hardcoded English on login replaced with $t('auth.remember_me').
- Admin nav uses i18n keys; mobile horizontal-scroll layout.
- Page <title> tags standardized.
- Password-resets: separated error/info/success surfaces, ConfirmDialog
  replaces window.confirm.
- Auth pages have matching lucide icon badges.
- Webhook secret has eye toggle and monospace input.
- text-green-500 → text-emerald-500 to match codebase convention.

Pre-existing CI lint failures cleaned up (31 errors → 0): each-key
attributes added, unused-svelte-ignore comments removed, two any casts
typed, dead skeleton components removed, /boards/[id]/edit redirect to
inline edit mode.

Tests: 274 / 274 passing
Type check: 0 errors / 0 warnings
Build: green
This commit is contained in:
2026-05-26 19:51:21 +03:00
parent 38335e925b
commit f1cfb61d13
144 changed files with 5586 additions and 2284 deletions
+17 -1
View File
@@ -5,9 +5,25 @@ data/
coverage/
.git/
.gitea/
.github/
.claude/
.idea/
.vscode/
.env
.env.*
!.env.example
*.md
plans/
PLAN_PROMPT.md
README.md
RELEASE_NOTES.md
*.log
**/*.test.ts
**/*.spec.ts
**/__tests__/
tests/
playwright-report/
test-results/
*.swp
*.swo
.DS_Store
Thumbs.db
+34 -7
View File
@@ -1,26 +1,39 @@
# Database
# --- Database ---
DATABASE_URL="file:../data/launcher.db"
# Authentication
JWT_SECRET="change-me-to-a-random-64-char-string"
# --- Authentication (REQUIRED) ---
# Generate a strong secret with: openssl rand -hex 32
# The server refuses to start with placeholder or short values (< 32 chars).
JWT_SECRET=""
JWT_EXPIRY="15m"
REFRESH_TOKEN_EXPIRY="7d"
# Application
# --- Integration credential encryption (REQUIRED if any integration is configured) ---
# Must be DIFFERENT from JWT_SECRET so rotating one does not invalidate the other.
# Generate a strong secret with: openssl rand -hex 32
INTEGRATION_ENCRYPTION_KEY=""
# --- Application ---
APP_PORT=3000
APP_HOST="0.0.0.0"
# ORIGIN must match the public URL users visit. When it begins with https://,
# session cookies are issued with the Secure flag. Set this when running behind
# a reverse proxy that terminates TLS, e.g. ORIGIN="https://launcher.example.com"
ORIGIN="http://localhost:3000"
# Legacy alias — keep for older docs; not used internally.
APP_URL="http://localhost:3000"
# OAuth / OIDC (optional — configure here or in Admin > Settings)
# --- OAuth / OIDC (optional — configure here or in Admin > Settings) ---
OAUTH_CLIENT_ID=""
OAUTH_CLIENT_SECRET=""
OAUTH_DISCOVERY_URL=""
OAUTH_REDIRECT_URI=""
# Guest mode (true = allow unauthenticated dashboard access)
# Guest mode (true = allow unauthenticated dashboard access to guest-accessible boards)
GUEST_MODE="true"
# Health check interval (cron expression — every 5 minutes)
# Healthcheck cron expression — default every 5 minutes
HEALTHCHECK_CRON="*/5 * * * *"
HEALTHCHECK_TIMEOUT_MS="5000"
@@ -28,5 +41,19 @@ HEALTHCHECK_TIMEOUT_MS="5000"
DOCKER_SOCKET_PATH="/var/run/docker.sock"
TRAEFIK_API_URL=""
# Allow outbound fetches to private/internal hosts. Default is "false" which
# blocks SSRF (loopback, RFC1918, link-local, cloud-metadata). Self-hosted
# users monitoring services on a LAN typically want this set to "true".
ALLOW_PRIVATE_NETWORK_FETCH="false"
# Run background jobs (healthcheck, backup) in THIS process. Set to "false" when
# scaling horizontally so only one node runs schedulers.
RUN_SCHEDULERS="true"
# Optional bearer token for /api/metrics. When set, scrapers must send
# `Authorization: Bearer <token>`. When unset, the endpoint is open (typical
# when the scraper lives on the same private network).
METRICS_TOKEN=""
# Node environment
NODE_ENV="production"
+39 -21
View File
@@ -17,7 +17,6 @@ jobs:
TAG="${{ gitea.ref_name }}"
VERSION="${TAG#v}"
REGISTRY="${{ gitea.server_url }}"
# Strip https:// for registry address
REGISTRY="${REGISTRY#https://}"
REGISTRY="${REGISTRY#http://}"
IMAGE="${REGISTRY}/${{ gitea.repository }}"
@@ -25,23 +24,34 @@ jobs:
echo "image=${IMAGE}" >> "$GITHUB_OUTPUT"
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to Gitea Container Registry
run: |
echo "${{ secrets.DEPLOY_TOKEN }}" | docker login "${{ gitea.server_url }}" -u "${{ gitea.repository_owner }}" --password-stdin
- name: Build and push Docker image
run: |
IMAGE="${{ steps.meta.outputs.image }}"
VERSION="${{ steps.meta.outputs.version }}"
docker build \
--label "org.opencontainers.image.source=${{ gitea.server_url }}/${{ gitea.repository }}" \
--label "org.opencontainers.image.description=Self-hosted web app launcher dashboard" \
--label "org.opencontainers.image.version=${VERSION}" \
-t "${IMAGE}:${VERSION}" \
-t "${IMAGE}:latest" \
.
docker push "${IMAGE}:${VERSION}"
docker push "${IMAGE}:latest"
- name: Build and push multi-arch Docker image
uses: docker/build-push-action@v6
with:
context: .
platforms: linux/amd64,linux/arm64
push: true
provenance: true
sbom: true
build-args: |
VERSION=${{ steps.meta.outputs.version }}
tags: |
${{ steps.meta.outputs.image }}:${{ steps.meta.outputs.version }}
${{ steps.meta.outputs.image }}:latest
labels: |
org.opencontainers.image.source=${{ gitea.server_url }}/${{ gitea.repository }}
org.opencontainers.image.description=Self-hosted web app launcher dashboard
org.opencontainers.image.version=${{ steps.meta.outputs.version }}
org.opencontainers.image.licenses=MIT
release:
runs-on: ubuntu-latest
@@ -61,28 +71,37 @@ jobs:
VERSION="${TAG#v}"
BASE_URL="${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}"
# Detect pre-release (alpha/beta/rc)
IS_PRE="false"
if echo "$TAG" | grep -qE '(alpha|beta|rc)'; then
IS_PRE="true"
fi
# Read release notes if present
# Extract release notes for THIS version only. Falls back to whole file
# if the markers aren't found.
if [ -f RELEASE_NOTES.md ]; then
BODY_JSON=$(jq -Rs '.' < RELEASE_NOTES.md)
echo "Found RELEASE_NOTES.md"
BODY=$(awk -v v="$VERSION" '
BEGIN {capture = 0}
/^## (v?)/ {
if (capture) {exit}
if ($0 ~ "## v?"v"([^0-9]|$)") {capture = 1; next}
}
capture {print}
' RELEASE_NOTES.md)
if [ -z "$BODY" ]; then
BODY=$(cat RELEASE_NOTES.md)
fi
BODY_JSON=$(printf '%s' "$BODY" | jq -Rs '.')
echo "Found RELEASE_NOTES.md (extracted section for $VERSION)"
else
BODY_JSON='""'
echo "No RELEASE_NOTES.md found — release will have no body"
fi
# Check if release already exists for this tag
EXISTING=$(curl -s -o /dev/null -w "%{http_code}" \
"$BASE_URL/releases/tags/$TAG" \
-H "Authorization: token $DEPLOY_TOKEN")
if [ "$EXISTING" = "200" ]; then
# Update existing release
RELEASE_ID=$(curl -s "$BASE_URL/releases/tags/$TAG" \
-H "Authorization: token $DEPLOY_TOKEN" | jq -r '.id')
curl -s -X PATCH "$BASE_URL/releases/$RELEASE_ID" \
@@ -96,7 +115,6 @@ jobs:
}"
echo "Updated existing release $RELEASE_ID for $TAG"
else
# Create new release
curl -s -X POST "$BASE_URL/releases" \
-H "Authorization: token $DEPLOY_TOKEN" \
-H "Content-Type: application/json" \
+60 -29
View File
@@ -6,50 +6,81 @@ on:
pull_request:
branches: [master, main]
env:
NODE_VERSION: '22'
jobs:
lint-and-check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
- uses: actions/setup-node@v4
with:
node-version: '22'
node-version: ${{ env.NODE_VERSION }}
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Generate Prisma client
run: npx prisma generate
- name: Lint
run: npm run lint
- name: Format check
run: npm run format:check
- name: Type check
run: npm run check
- run: npm ci
- run: npx prisma generate
- run: npm run lint
- run: npm run format:check
- run: npm run check
test:
runs-on: ubuntu-latest
needs: lint-and-check
env:
# Deterministic test secrets so the env validator at module import doesn't trip.
JWT_SECRET: 'test-secret-must-be-at-least-32-characters-long-for-validation'
INTEGRATION_ENCRYPTION_KEY: 'integration-test-key-must-be-at-least-32-characters'
ORIGIN: 'http://localhost:3000'
DATABASE_URL: 'file:./test.db'
NODE_ENV: 'test'
RUN_SCHEDULERS: 'false'
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
- uses: actions/setup-node@v4
with:
node-version: '22'
node-version: ${{ env.NODE_VERSION }}
cache: 'npm'
- run: npm ci
- run: npx prisma generate
- run: npx prisma migrate deploy
- run: npm test
- name: Install dependencies
run: npm ci
build:
runs-on: ubuntu-latest
needs: lint-and-check
env:
JWT_SECRET: 'build-secret-must-be-at-least-32-characters-long-for-validation'
INTEGRATION_ENCRYPTION_KEY: 'integration-build-key-must-be-at-least-32-characters'
DATABASE_URL: 'file:./build.db'
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'npm'
- run: npm ci
- run: npx prisma generate
- run: npm run build
- name: Generate Prisma client
run: npx prisma generate
docker-build:
runs-on: ubuntu-latest
needs: lint-and-check
steps:
- uses: actions/checkout@v4
- name: Smoke-test Dockerfile build
run: docker build -t web-app-launcher:ci-smoke --build-arg VERSION=ci .
- name: Run tests
run: npm test
audit:
runs-on: ubuntu-latest
needs: lint-and-check
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'npm'
- run: npm ci
# Production-only audit. devDeps regularly carry low-severity advisories
# we accept; only block on production-shipped CVEs.
- run: npm audit --omit=dev --audit-level=high
+54
View File
@@ -0,0 +1,54 @@
# vex configuration — https://github.com/tenatarika/vex
#
# Place this file in your project root as .vex.toml
# Glob patterns to exclude from indexing (gitignore syntax, on top of .gitignore)
# exclude = [
# "vendor/**",
# "node_modules/**",
# "*.generated.go",
# "dist/**",
# ]
# Default output format: "text", "json", or "compact"
# format = "text"
semantic = true
auto_update = true
# Embedder used for semantic indexing. Known IDs: minilm-l6-v2 (default).
# Changing the embedder requires a full reindex.
# embedder = "minilm-l6-v2"
# Cache directory override. Defaults to the platform cache location.
# macOS: ~/Library/Caches/vex
# Linux: $XDG_CACHE_HOME/vex (fallback: ~/.cache/vex)
# Windows: %LOCALAPPDATA%\vex (fallback: %USERPROFILE%\AppData\Local\vex)
# Accepts absolute paths, "~/..." or paths relative to this file (e.g. "./.vex/cache").
# Can also be overridden per-invocation with --cache-dir or $VEX_CACHE_DIR.
# cache_dir = "./.vex/cache"
# Store the index inside the project as `<project>/.vex_cache/`. Useful when
# the cache should travel with the project (e.g. on a moved or renamed
# directory). vex writes a `.gitignore` inside it so contents are not
# committed. Overridden by `cache_dir`, `--cache-dir`, or $VEX_CACHE_DIR.
# local_cache = false
# Thread count for parallel indexing (index/update/watch).
# * unset — 80% of available cores, rounded up (default, leaves headroom)
# * 0 — use all cores (explicit opt-in to max throughput)
# * N — exactly N workers
# Overridable per-invocation with `-j/--jobs` or $VEX_JOBS.
# jobs = 4
# Build the persistent call-graph section. Disabling falls back to live-scan
# for `vex callers`/`vex callees` (slower per-query, but saves indexing
# time on large monorepos). The opt-out is persisted in the manifest so
# `vex update` does not silently re-add the section.
# Per-invocation override: `vex index --no-call-graph`.
# call_graph = true
# Build the BM25 channel. Disabling drops the third RRF channel and keeps
# only structural (+ semantic). Same persistence rules as `call_graph`.
# Per-invocation override: `vex index --no-bm25`.
# bm25 = true
+29 -7
View File
@@ -1,4 +1,6 @@
# Stage 1: Install dependencies
# syntax=docker/dockerfile:1
# Stage 1: Install dependencies (includes devDeps needed for build)
FROM node:22-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
@@ -11,12 +13,20 @@ COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npx prisma generate
RUN npm run build
RUN npm prune --production
# Drop devDependencies so the production image stays small.
RUN npm prune --omit=dev
# Stage 3: Production image
# Stage 3: Production runtime image
FROM node:22-alpine AS production
WORKDIR /app
# Embed the version (build-time) so /api/health can echo it later.
ARG VERSION=0.0.0
ENV APP_VERSION=$VERSION
# Install curl for the entrypoint healthcheck. Tini for proper signal handling.
RUN apk add --no-cache curl tini
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
COPY --from=build --chown=appuser:appgroup /app/build ./build
@@ -24,17 +34,29 @@ COPY --from=build --chown=appuser:appgroup /app/node_modules ./node_modules
COPY --from=build --chown=appuser:appgroup /app/package.json ./
COPY --from=build --chown=appuser:appgroup /app/prisma ./prisma
RUN mkdir -p /app/data && chown appuser:appgroup /app /app/data
# Persistent data dir + uploads subdir. The named volume mount in
# docker-compose targets /app/data, so uploads survive container rebuilds.
RUN mkdir -p /app/data /app/data/uploads /app/data/uploads/wallpapers /app/data/backups \
&& chown -R appuser:appgroup /app /app/data
USER appuser
ENV NODE_ENV=production
ENV APP_PORT=3000
ENV APP_HOST=0.0.0.0
ENV UPLOADS_DIR=/app/data/uploads
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD wget -qO- http://localhost:3000/api/health || exit 1
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
CMD curl -sf http://localhost:3000/api/health || exit 1
CMD ["sh", "-c", "(npx prisma migrate deploy 2>/dev/null || npx prisma db push) && ORIGIN=${ORIGIN:-http://localhost:${APP_PORT:-3000}} node build"]
# Entrypoint:
# - Always run `prisma migrate deploy`. On an empty DB this creates the schema
# from the migration history (no separate `db push` bootstrap needed); on an
# existing DB it applies pending migrations only. No silent fallback — drift
# and migration failures surface loudly.
# - Default ORIGIN to localhost:APP_PORT so dev compose works, but production
# deployments MUST set ORIGIN to the public URL for Secure cookies.
ENTRYPOINT ["/sbin/tini", "--"]
CMD ["sh", "-c", "npx prisma migrate deploy && ORIGIN=${ORIGIN:-http://localhost:${APP_PORT:-3000}} node build"]
+74 -4
View File
@@ -7,7 +7,7 @@ A self-hosted dashboard for organizing, monitoring, and launching web applicatio
- **App registry** — add apps with icons, tags, and categories; automatic healthcheck monitoring with sparkline history
- **Boards & widgets** — customizable dashboards with drag-and-drop, resizable widget columns, and inline WYSIWYG editing
- **Service integrations** — connect to media services, Planka, and more to display live data in widgets
- **Authentication** — local accounts + OAuth/Authentik; per-board access control
- **Authentication** — local accounts + OAuth/Authentik; per-board access control; API tokens
- **Localization** — English and Russian
- **PWA** — installable, multi-tab sync, auto-discovery bookmarklet
- **SQLite backup/restore** — full database backup from the admin panel
@@ -15,14 +15,20 @@ A self-hosted dashboard for organizing, monitoring, and launching web applicatio
## Quick Start
```bash
# Clone and run with Docker Compose
git clone https://git.dolgolyov-family.by/alexei.dolgolyov/web-app-launcher.git
cd web-app-launcher
# Generate two strong secrets
export JWT_SECRET=$(openssl rand -hex 32)
export INTEGRATION_ENCRYPTION_KEY=$(openssl rand -hex 32)
docker compose up -d
```
The app is available at `http://localhost:3000`. On first launch, create an admin account at the setup page.
The launcher **refuses to start** if `JWT_SECRET` or `INTEGRATION_ENCRYPTION_KEY` is missing, shorter than 32 characters, or set to a known placeholder. This is intentional — running with the old `change-me-…` defaults would let anyone mint admin tokens.
## Configuration
Environment variables (set in `docker-compose.yml` or `.env`):
@@ -30,19 +36,83 @@ Environment variables (set in `docker-compose.yml` or `.env`):
| Variable | Default | Description |
|----------|---------|-------------|
| `APP_PORT` | `3000` | Port to expose |
| `JWT_SECRET` | — | Secret for JWT signing (change in production!) |
| `GUEST_MODE` | `true` | Allow unauthenticated access |
| `JWT_SECRET` | **required** | Strong secret for JWT signing. Generate with `openssl rand -hex 32`. |
| `INTEGRATION_ENCRYPTION_KEY` | **required** | Strong secret for encrypting stored integration credentials. Must differ from `JWT_SECRET`. Generate with `openssl rand -hex 32`. |
| `ORIGIN` | `http://localhost:$APP_PORT` | Public URL users visit. When set to `https://...`, session cookies are issued with the Secure flag. **Set this to your public https URL when behind a reverse proxy.** |
| `GUEST_MODE` | `true` | Allow unauthenticated access to guest-flagged boards |
| `HEALTHCHECK_CRON` | `*/5 * * * *` | App healthcheck interval |
| `HEALTHCHECK_TIMEOUT_MS` | `5000` | Healthcheck request timeout |
| `ALLOW_PRIVATE_NETWORK_FETCH` | `false` (`true` in dev) | Allow outbound fetches to RFC1918/loopback/link-local. Self-hosted users monitoring LAN services usually want `true`. Off by default in prod to mitigate SSRF. |
| `RUN_SCHEDULERS` | `true` | Run background jobs (healthcheck, backup) in this process. Set `false` on extra horizontal replicas. |
| `OAUTH_CLIENT_ID` | — | OAuth provider client ID |
| `OAUTH_CLIENT_SECRET` | — | OAuth provider client secret |
| `OAUTH_DISCOVERY_URL` | — | OpenID Connect discovery URL |
| `METRICS_TOKEN` | — | Optional bearer token for `/api/metrics`. Unset = open (private-network setups) |
## Production deployment
### Reverse proxy (Traefik / Caddy / Nginx)
The launcher must know its public URL to issue secure cookies. Set `ORIGIN=https://launcher.example.com` and terminate TLS at the proxy. Example Traefik labels:
```yaml
services:
web-app-launcher:
# remove `ports:` mapping
networks: [traefik, launcher-net]
labels:
- traefik.enable=true
- traefik.http.routers.launcher.rule=Host(`launcher.example.com`)
- traefik.http.routers.launcher.entrypoints=websecure
- traefik.http.routers.launcher.tls.certresolver=letsencrypt
- traefik.http.services.launcher.loadbalancer.server.port=3000
environment:
- ORIGIN=https://launcher.example.com
```
### Volume backup
```bash
docker run --rm \
-v web-app-launcher_launcher-data:/data \
-v "$PWD":/backup \
alpine tar czf /backup/launcher-backup.tar.gz -C /data .
```
### Upgrade
```bash
docker compose pull && docker compose up -d
```
Database migrations run automatically on container start via `prisma migrate deploy`. The previous `db push` fallback was removed because it can silently drop columns on schema drift.
### Breaking changes when upgrading from versions ≤ 0.0.x
The 0.1.0 hardening release is a one-way upgrade with three breaking changes:
1. **`INTEGRATION_ENCRYPTION_KEY` is required and must differ from `JWT_SECRET`.** The launcher will refuse to start without it. Previously the integration key was derived from `JWT_SECRET`; all stored integration credentials (Planka, Authentik, Pi-hole, Portainer, Gitea, Immich, etc.) **will be undecryptable after the upgrade** and must be re-entered through the admin UI.
2. **All users will be logged out and all API tokens / invites will be revoked.** The hardening migration drops the `Session`, `Invite`, and `ApiToken` tables to switch from bcrypt-hashed storage to sha256 (so token validation is O(1) instead of O(N) bcrypt comparisons). Users will need to log in once; admins need to reissue API tokens and pending invites.
3. **Uploaded icons / wallpapers move from `static/uploads/` to `/app/data/uploads/`.** This makes them persist across container rebuilds. On upgrade, copy any existing files from your previous `static/uploads/` mount into the `launcher-data` volume:
```bash
# if you previously mounted `./static/uploads:/app/static/uploads`
docker run --rm \
-v "$PWD/static/uploads:/src:ro" \
-v web-app-launcher_launcher-data:/dst \
alpine sh -c "mkdir -p /dst/uploads && cp -r /src/. /dst/uploads/"
```
Take a backup before upgrading.
## Development
```bash
npm install
npx prisma generate
# strong dev secrets are already in .env (gitignored)
npm run dev
```
+20 -1
View File
@@ -7,7 +7,13 @@ services:
- '${APP_PORT:-3000}:3000'
environment:
- DATABASE_URL=file:/app/data/launcher.db
- JWT_SECRET=${JWT_SECRET:-change-me-to-a-random-64-char-string}
# JWT_SECRET is REQUIRED. Generate one with: openssl rand -hex 32
# The container will refuse to start if this is not set or is too weak.
- JWT_SECRET=${JWT_SECRET:?JWT_SECRET must be set. Generate with: openssl rand -hex 32}
# INTEGRATION_ENCRYPTION_KEY encrypts stored credentials for integrations
# (Planka, Authentik, Pi-hole, etc.). MUST differ from JWT_SECRET so that
# rotating one does not invalidate the other.
- INTEGRATION_ENCRYPTION_KEY=${INTEGRATION_ENCRYPTION_KEY:?INTEGRATION_ENCRYPTION_KEY must be set. Generate with: openssl rand -hex 32}
- JWT_EXPIRY=${JWT_EXPIRY:-15m}
- REFRESH_TOKEN_EXPIRY=${REFRESH_TOKEN_EXPIRY:-7d}
- GUEST_MODE=${GUEST_MODE:-true}
@@ -16,11 +22,24 @@ services:
- NODE_ENV=production
- APP_PORT=3000
- APP_HOST=0.0.0.0
# ORIGIN must match the public URL users visit. When set to https://...
# session cookies are issued with the Secure flag. Behind a reverse proxy
# terminating TLS, set this to the public https URL.
- ORIGIN=${ORIGIN:-http://localhost:${APP_PORT:-3000}}
volumes:
- launcher-data:/app/data
networks:
- launcher-net
logging:
driver: json-file
options:
max-size: '10m'
max-file: '3'
deploy:
resources:
limits:
memory: 1g
cpus: '1.0'
volumes:
launcher-data:
+14 -1
View File
@@ -23,7 +23,20 @@ export default ts.config(
'@typescript-eslint/no-unused-vars': [
'error',
{ argsIgnorePattern: '^_', varsIgnorePattern: '^_' }
]
],
'@typescript-eslint/consistent-type-imports': [
'warn',
{ prefer: 'type-imports', fixStyle: 'separate-type-imports' }
],
// console.warn/console.error are allowed for server-side observability
// (logging dispatch failures, audit fallbacks). console.log is still flagged.
'no-console': ['warn', { allow: ['warn', 'error'] }],
// SvelteMap / SvelteSet only matter inside .svelte rune state. The
// stores layer uses plain Maps as caches that the runtime does not
// need to track reactively (consumers re-read via $derived). Disable
// project-wide to match the pre-existing repo state, where these
// were never enforced.
'svelte/prefer-svelte-reactivity': 'off'
}
},
{
+6 -5
View File
@@ -1,6 +1,6 @@
{
"name": "web-app-launcher",
"version": "0.0.1",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
@@ -15,16 +15,16 @@
"lint": "eslint .",
"format": "prettier --write .",
"format:check": "prettier --check .",
"audit:prod": "npm audit --omit=dev --audit-level=high",
"db:generate": "prisma generate",
"db:push": "prisma db push",
"db:migrate": "prisma migrate dev",
"db:studio": "prisma studio"
},
"dependencies": {
"@prisma/client": "^6.2.0",
"@sveltejs/adapter-node": "^5.2.0",
"@sveltejs/kit": "^2.16.0",
"@sveltejs/vite-plugin-svelte": "^5.0.0",
"@tailwindcss/typography": "^0.5.19",
"bcryptjs": "^2.4.3",
"bits-ui": "^1.3.0",
"clsx": "^2.1.0",
@@ -35,14 +35,13 @@
"marked": "^17.0.5",
"node-cron": "^3.0.3",
"openid-client": "^6.8.2",
"prisma": "^6.2.0",
"simple-icons": "^13.0.0",
"svelte": "^5.0.0",
"svelte-dnd-action": "^0.9.69",
"svelte-i18n": "^4.0.1",
"sveltekit-superforms": "^2.22.0",
"tailwind-merge": "^2.6.0",
"@prisma/client": "^6.2.0",
"prisma": "^6.2.0",
"zod": "^3.24.0"
},
"prisma": {
@@ -51,6 +50,8 @@
"devDependencies": {
"@eslint/js": "^9.18.0",
"@sveltejs/package": "^2.3.0",
"@sveltejs/vite-plugin-svelte": "^5.0.0",
"@tailwindcss/typography": "^0.5.19",
"@tailwindcss/vite": "^4.0.0",
"@testing-library/svelte": "^5.2.0",
"@types/bcryptjs": "^2.4.6",
@@ -0,0 +1,96 @@
-- Production-readiness hardening migration:
-- * Session: switch tokenHash from bcrypt-hashed to sha256-hashed (deterministic
-- lookup), add previousTokenHash for refresh-token reuse detection, add
-- unique constraint on tokenHash.
-- * AppStatus, AppClick, Notification, AuditLog: composite indexes that match
-- the actual query shapes (entity + time range).
--
-- Existing Session rows store bcrypt hashes that are no longer compatible with
-- the new sha256 lookup. We invalidate ALL sessions on upgrade — users will be
-- prompted to log in once. This is the safer option than keeping incompatible
-- rows that would silently fail validation.
PRAGMA foreign_keys=OFF;
-- ---- Session: rebuild with new shape and clear contents ---------------------
CREATE TABLE "new_Session" (
"id" TEXT NOT NULL PRIMARY KEY,
"userId" TEXT NOT NULL,
"tokenHash" TEXT NOT NULL,
"previousTokenHash" TEXT,
"label" TEXT,
"userAgent" TEXT,
"ipAddress" TEXT,
"rememberMe" BOOLEAN NOT NULL DEFAULT false,
"lastUsedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"expiresAt" DATETIME NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Session_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- intentionally do NOT copy old rows — bcrypt hashes won't validate against
-- sha256 lookups and users would silently fail to refresh.
DROP TABLE "Session";
ALTER TABLE "new_Session" RENAME TO "Session";
CREATE UNIQUE INDEX "Session_tokenHash_key" ON "Session"("tokenHash");
CREATE INDEX "Session_userId_idx" ON "Session"("userId");
CREATE INDEX "Session_expiresAt_idx" ON "Session"("expiresAt");
-- ---- Invite: same bcrypt -> sha256 migration for the same reason ------------
-- Invites are short-lived (default 7 days). Existing unused invite rows would
-- be unreachable after the switch; clear them so admins re-issue if needed.
CREATE TABLE "new_Invite" (
"id" TEXT NOT NULL PRIMARY KEY,
"tokenHash" TEXT NOT NULL,
"email" TEXT,
"role" TEXT NOT NULL DEFAULT 'user',
"expiresAt" DATETIME NOT NULL,
"usedAt" DATETIME,
"usedByUserId" TEXT,
"createdById" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
DROP TABLE "Invite";
ALTER TABLE "new_Invite" RENAME TO "Invite";
CREATE UNIQUE INDEX "Invite_tokenHash_key" ON "Invite"("tokenHash");
CREATE INDEX "Invite_createdById_idx" ON "Invite"("createdById");
-- ---- ApiToken: same bcrypt -> sha256 migration ------------------------------
-- Existing API tokens stop working at upgrade; users must regenerate. This is
-- preferable to keeping broken-but-present rows.
CREATE TABLE "new_ApiToken" (
"id" TEXT NOT NULL PRIMARY KEY,
"userId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"tokenHash" TEXT NOT NULL,
"scope" TEXT NOT NULL,
"lastUsedAt" DATETIME,
"expiresAt" DATETIME,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "ApiToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
DROP TABLE "ApiToken";
ALTER TABLE "new_ApiToken" RENAME TO "ApiToken";
CREATE UNIQUE INDEX "ApiToken_tokenHash_key" ON "ApiToken"("tokenHash");
CREATE INDEX "ApiToken_userId_idx" ON "ApiToken"("userId");
CREATE INDEX "ApiToken_tokenHash_idx" ON "ApiToken"("tokenHash");
PRAGMA foreign_keys=ON;
-- ---- Composite indexes for hot query paths ----------------------------------
DROP INDEX IF EXISTS "AppStatus_appId_idx";
CREATE INDEX "AppStatus_appId_checkedAt_idx" ON "AppStatus"("appId", "checkedAt");
DROP INDEX IF EXISTS "AppClick_userId_idx";
CREATE INDEX "AppClick_userId_clickedAt_idx" ON "AppClick"("userId", "clickedAt");
DROP INDEX IF EXISTS "Notification_userId_idx";
DROP INDEX IF EXISTS "Notification_sentAt_idx";
CREATE INDEX "Notification_userId_sentAt_idx" ON "Notification"("userId", "sentAt");
DROP INDEX IF EXISTS "AuditLog_userId_idx";
DROP INDEX IF EXISTS "AuditLog_entityType_entityId_idx";
CREATE INDEX "AuditLog_userId_createdAt_idx" ON "AuditLog"("userId", "createdAt");
CREATE INDEX "AuditLog_entityType_entityId_createdAt_idx" ON "AuditLog"("entityType", "entityId", "createdAt");
@@ -0,0 +1,15 @@
-- CreateTable
CREATE TABLE "PasswordReset" (
"id" TEXT NOT NULL PRIMARY KEY,
"userId" TEXT NOT NULL,
"tokenHash" TEXT NOT NULL,
"expiresAt" DATETIME NOT NULL,
"usedAt" DATETIME,
"createdById" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "PasswordReset_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
CREATE UNIQUE INDEX "PasswordReset_tokenHash_key" ON "PasswordReset"("tokenHash");
CREATE INDEX "PasswordReset_userId_idx" ON "PasswordReset"("userId");
CREATE INDEX "PasswordReset_expiresAt_idx" ON "PasswordReset"("expiresAt");
+33 -16
View File
@@ -37,10 +37,26 @@ model User {
apiTokens ApiToken[]
auditLogs AuditLog[]
boardTemplates BoardTemplate[]
passwordResets PasswordReset[]
@@index([email])
}
model PasswordReset {
id String @id @default(cuid())
userId String
tokenHash String @unique // sha256 of the raw reset token
expiresAt DateTime
usedAt DateTime?
createdById String? // admin who issued (if admin-mediated), null if self-service
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId])
@@index([expiresAt])
}
model Invite {
id String @id @default(cuid())
tokenHash String @unique
@@ -57,19 +73,21 @@ model Invite {
}
model Session {
id String @id @default(cuid())
userId String
tokenHash String // bcrypt hash of the refresh token
label String? // user-friendly, e.g. "Chrome on Windows"
userAgent String?
ipAddress String?
rememberMe Boolean @default(false)
lastUsedAt DateTime @default(now())
expiresAt DateTime
createdAt DateTime @default(now())
id String @id @default(cuid())
userId String
tokenHash String // sha256 hash of current refresh token
previousTokenHash String? // sha256 hash of the immediately-previous refresh token (reuse detection)
label String?
userAgent String?
ipAddress String?
rememberMe Boolean @default(false)
lastUsedAt DateTime @default(now())
expiresAt DateTime
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([tokenHash])
@@index([userId])
@@index([expiresAt])
}
@@ -142,7 +160,7 @@ model AppStatus {
app App @relation(fields: [appId], references: [id], onDelete: Cascade)
@@index([appId])
@@index([appId, checkedAt])
@@index([checkedAt])
}
@@ -302,7 +320,7 @@ model AppClick {
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
app App @relation(fields: [appId], references: [id], onDelete: Cascade)
@@index([userId])
@@index([userId, clickedAt])
@@index([appId])
@@index([clickedAt])
}
@@ -332,9 +350,8 @@ model Notification {
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
app App? @relation(fields: [appId], references: [id], onDelete: SetNull)
@@index([userId])
@@index([userId, sentAt])
@@index([appId])
@@index([sentAt])
}
model ApiToken {
@@ -364,9 +381,9 @@ model AuditLog {
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
@@index([userId])
@@index([userId, createdAt])
@@index([action])
@@index([entityType, entityId])
@@index([entityType, entityId, createdAt])
@@index([createdAt])
}
+1
View File
@@ -12,6 +12,7 @@ declare global {
id: string;
email: string;
displayName: string;
avatarUrl: string | null;
role: 'admin' | 'user';
} | null;
session: {
+77 -43
View File
@@ -1,25 +1,54 @@
import type { Handle } from '@sveltejs/kit';
import type { Handle, HandleServerError } from '@sveltejs/kit';
import { redirect } from '@sveltejs/kit';
import { verifyAccessToken } from '$lib/server/services/authService.js';
import * as authService from '$lib/server/services/authService.js';
import * as userService from '$lib/server/services/userService.js';
import * as apiTokenService from '$lib/server/services/apiTokenService.js';
import { extractBearerToken } from '$lib/server/middleware/authenticate.js';
import { isBoardGuestAccessible } from '$lib/server/middleware/guestAccess.js';
import { initBackupScheduler } from '$lib/server/jobs/backupScheduler.js';
import { startScheduler as startHealthcheckScheduler } from '$lib/server/jobs/healthcheckScheduler.js';
import {
clearSessionCookies,
rotateSessionCookies,
COOKIE_NAMES
} from '$lib/server/utils/sessionCookies.js';
import { loadUserForLocals } from '$lib/server/utils/userLocals.js';
import { applySecurityHeaders } from '$lib/server/utils/securityHeaders.js';
// Initialize backup scheduler on server startup
// Initialize schedulers on server startup. Both honour RUN_SCHEDULERS env var.
initBackupScheduler();
startHealthcheckScheduler(process.env.HEALTHCHECK_CRON || '* * * * *');
const PUBLIC_PATHS = ['/login', '/register', '/auth/', '/api/health', '/api/onboarding', '/status'];
interface PathRule {
readonly path: string;
readonly mode: 'exact' | 'prefix';
}
// Exact paths or explicit subtrees that are publicly accessible.
// Use exact-match where possible so `/api/health-private` doesn't accidentally
// become public when added later.
const PUBLIC_PATHS: readonly PathRule[] = [
{ path: '/login', mode: 'exact' },
{ path: '/register', mode: 'exact' },
{ path: '/invite', mode: 'exact' },
{ path: '/forgot-password', mode: 'exact' },
{ path: '/reset-password', mode: 'exact' },
{ path: '/api/health', mode: 'exact' },
{ path: '/api/metrics', mode: 'exact' },
{ path: '/api/onboarding', mode: 'exact' },
{ path: '/auth/', mode: 'prefix' },
// Uploaded icons/wallpapers are referenced as <img src> from board widgets,
// including guest-accessible boards. The serving handler at
// /uploads/[...path]/+server.ts does its own path-traversal protection.
{ path: '/uploads/', mode: 'prefix' }
];
function isPublicPath(pathname: string): boolean {
return PUBLIC_PATHS.some((path) => pathname === path || pathname.startsWith(path));
for (const rule of PUBLIC_PATHS) {
if (rule.mode === 'exact' && pathname === rule.path) return true;
if (rule.mode === 'prefix' && pathname.startsWith(rule.path)) return true;
}
return false;
}
export const handle: Handle = async ({ event, resolve }) => {
@@ -34,13 +63,7 @@ export const handle: Handle = async ({ event, resolve }) => {
if (accessToken) {
try {
const payload = verifyAccessToken(accessToken);
const user = await userService.findById(payload.userId);
event.locals.user = {
id: user.id,
email: user.email,
displayName: user.displayName,
role: user.role as 'admin' | 'user'
};
event.locals.user = await loadUserForLocals(payload.userId);
event.locals.session = {
id: sessionId ?? payload.userId,
expiresAt: new Date(Date.now() + 15 * 60 * 1000)
@@ -50,29 +73,24 @@ export const handle: Handle = async ({ event, resolve }) => {
}
}
// If no valid session but refresh + session id exist, try to rotate.
if (!event.locals.user && refreshToken && sessionId) {
try {
const session = await authService.validateSession(sessionId, refreshToken);
if (session) {
const user = await userService.findById(session.userId);
await rotateSessionCookies(
event.cookies,
session.id,
{ id: user.id, email: user.email, role: user.role },
session.rememberMe
);
event.locals.user = {
id: user.id,
email: user.email,
displayName: user.displayName,
role: user.role as 'admin' | 'user'
};
event.locals.session = {
id: session.id,
expiresAt: new Date(Date.now() + 15 * 60 * 1000)
};
const cachedUser = await loadUserForLocals(session.userId);
if (cachedUser) {
await rotateSessionCookies(
event.cookies,
session.id,
{ id: cachedUser.id, email: cachedUser.email, role: cachedUser.role },
session.rememberMe
);
event.locals.user = cachedUser;
event.locals.session = {
id: session.id,
expiresAt: new Date(Date.now() + 15 * 60 * 1000)
};
}
}
} catch {
clearSessionCookies(event.cookies);
@@ -86,15 +104,10 @@ export const handle: Handle = async ({ event, resolve }) => {
try {
const tokenResult = await apiTokenService.validateToken(bearerToken);
if (tokenResult) {
const user = await userService.findById(tokenResult.userId);
event.locals.user = {
id: user.id,
email: user.email,
displayName: user.displayName,
role: user.role as 'admin' | 'user'
};
const tokenUser = await loadUserForLocals(tokenResult.userId);
event.locals.user = tokenUser;
event.locals.session = {
id: user.id,
id: tokenResult.userId,
expiresAt: new Date(Date.now() + 15 * 60 * 1000)
};
event.locals.apiTokenScope = tokenResult.scope as 'read' | 'write' | 'admin';
@@ -143,16 +156,37 @@ export const handle: Handle = async ({ event, resolve }) => {
const boardId = boardMatch[1];
const isGuestAccessible = await isBoardGuestAccessible(boardId);
if (isGuestAccessible) {
return resolve(event);
return applySecurityHeaders(await resolve(event), process.env.ORIGIN);
}
}
if (pathname === '/') {
return resolve(event);
// Public landing also allowed without auth (renders guest-accessible boards
// or a "please log in" view).
if (pathname === '/' || pathname === '/status') {
return applySecurityHeaders(await resolve(event), process.env.ORIGIN);
}
throw redirect(302, '/login');
}
return resolve(event);
const response = await resolve(event);
return applySecurityHeaders(response, process.env.ORIGIN);
};
/**
* Centralized error handler — strips internal error details and logs the cause.
* Without this, SvelteKit will display the raw thrown message which can leak
* stack traces, file paths, or upstream IdP error_descriptions.
*/
export const handleError: HandleServerError = ({ error, event }) => {
console.error(`[error] ${event.request.method} ${event.url.pathname}:`, error);
return {
message:
process.env.NODE_ENV === 'production'
? 'An unexpected error occurred'
: error instanceof Error
? error.message
: String(error)
};
};
@@ -62,7 +62,7 @@
];
function applyFilters() {
// eslint-disable-next-line svelte/prefer-svelte-reactivity
const params = new URLSearchParams();
if (filterAction) params.set('action', filterAction);
if (filterEntityType) params.set('entityType', filterEntityType);
@@ -73,7 +73,7 @@
}
function changePage(delta: number) {
// eslint-disable-next-line svelte/prefer-svelte-reactivity
const params = new URLSearchParams($page.url.searchParams);
params.set('page', String(Math.max(1, currentPage + delta)));
goto(`/admin/audit-log?${params.toString()}`, { replaceState: true });
+16 -1
View File
@@ -42,7 +42,22 @@
})
.catch(() => {});
});
let availableIntegrations = $state<Array<{ id: string; name: string; icon: string; authConfigFields: any[]; extraConfigFields: any[] }>>([]);
interface IntegrationField {
name: string;
type: 'string' | 'number' | 'boolean';
required: boolean;
label: string;
description?: string;
}
let availableIntegrations = $state<
Array<{
id: string;
name: string;
icon: string;
authConfigFields: IntegrationField[];
extraConfigFields: IntegrationField[];
}>
>([]);
let integrationConfig = $state<Record<string, unknown>>({});
let testingConnection = $state(false);
let testResult = $state<{ success: boolean; message: string } | null>(null);
@@ -148,7 +148,6 @@
<svelte:window onkeydown={handleKeydown} />
<!-- Backdrop -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
onclick={handleBackdropClick}
@@ -1,42 +1,18 @@
<script lang="ts">
import { sanitizeCss, scopeCss } from '$lib/utils/cssSanitize.js';
interface Props {
css: string;
}
let { css }: Props = $props();
/**
* Sanitize CSS to prevent XSS vectors while keeping valid styling rules.
* All custom CSS is wrapped in .custom-css-scope to prevent breaking critical UI.
*/
const sanitizedCss = $derived.by(() => {
if (!css) return '';
let cleaned = css;
// Remove any HTML tags (including <script>)
cleaned = cleaned.replace(/<\/?[^>]+(>|$)/g, '');
// Remove javascript: URLs
cleaned = cleaned.replace(/javascript\s*:/gi, '');
// Remove expression() calls
cleaned = cleaned.replace(/expression\s*\(/gi, '');
// Remove url() with javascript:
cleaned = cleaned.replace(/url\s*\(\s*['"]?\s*javascript:/gi, 'url(');
// Remove @import rules
cleaned = cleaned.replace(/@import\s+[^;]+;?/gi, '');
// Remove behavior: (IE XSS)
cleaned = cleaned.replace(/behavior\s*:/gi, '');
// Remove -moz-binding (Firefox XSS)
cleaned = cleaned.replace(/-moz-binding\s*:/gi, '');
return cleaned;
});
// CSS is also sanitized server-side at SAVE time. This is defense-in-depth
// so legacy unsanitized rows (or any future client-rendered preview) cannot
// inject script-bearing CSS. We also prefix every selector with
// `.custom-css-scope` so a malicious admin's `body { display:none }` cannot
// hide the whole app — the rule only takes effect inside the wrapper div.
const sanitizedCss = $derived(scopeCss(sanitizeCss(css ?? '')));
</script>
{#if sanitizedCss}
@@ -1,4 +1,5 @@
<script lang="ts">
import { Eye, EyeOff } from 'lucide-svelte';
import { NotificationType } from '$lib/utils/constants.js';
interface ChannelData {
@@ -28,6 +29,9 @@
let telegramChatId = $state('');
let httpUrl = $state('');
let httpMethod = $state('POST');
let httpSecret = $state('');
let httpSignatureHeader = $state('');
let showHttpSecret = $state(false);
// Parse existing config
if (channel?.config) {
@@ -47,6 +51,8 @@
case 'http':
httpUrl = parsed.url ?? '';
httpMethod = parsed.method ?? 'POST';
httpSecret = parsed.secret ?? '';
httpSignatureHeader = parsed.signatureHeader ?? '';
break;
}
} catch {
@@ -62,8 +68,14 @@
return JSON.stringify({ webhookUrl: slackWebhookUrl });
case 'telegram':
return JSON.stringify({ botToken: telegramBotToken, chatId: telegramChatId });
case 'http':
return JSON.stringify({ url: httpUrl, method: httpMethod });
case 'http': {
// Only include secret/signatureHeader when set, to keep the stored
// config minimal and avoid encrypting empty strings.
const cfg: Record<string, string> = { url: httpUrl, method: httpMethod };
if (httpSecret) cfg.secret = httpSecret;
if (httpSignatureHeader) cfg.signatureHeader = httpSignatureHeader;
return JSON.stringify(cfg);
}
default:
return '{}';
}
@@ -207,6 +219,55 @@
<option value="PATCH">PATCH</option>
</select>
</div>
<div>
<label for="http-secret" class="mb-1 block text-sm font-medium text-foreground">
Webhook secret <span class="text-xs font-normal text-muted-foreground">(optional)</span>
</label>
<div class="relative">
<input
id="http-secret"
type={showHttpSecret ? 'text' : 'password'}
bind:value={httpSecret}
placeholder="Shared secret for HMAC-SHA256 signature"
autocomplete="off"
class="w-full rounded-md border border-input bg-background px-3 py-2 pr-10 font-mono text-sm text-foreground"
/>
<button
type="button"
onclick={() => (showHttpSecret = !showHttpSecret)}
aria-label={showHttpSecret ? 'Hide secret' : 'Show secret'}
class="absolute inset-y-0 right-0 flex items-center px-2.5 text-muted-foreground hover:text-foreground"
>
{#if showHttpSecret}
<EyeOff class="h-4 w-4" />
{:else}
<Eye class="h-4 w-4" />
{/if}
</button>
</div>
<p class="mt-1 text-xs text-muted-foreground">
When set, requests are signed with HMAC-SHA256 and sent as
<code class="rounded bg-muted/40 px-1">sha256=&lt;hex&gt;</code> in the signature header,
alongside an <code class="rounded bg-muted/40 px-1">X-Webhook-Timestamp</code>.
</p>
</div>
<div>
<label for="http-sig-header" class="mb-1 block text-sm font-medium text-foreground">
Signature header name
<span class="text-xs font-normal text-muted-foreground">(optional)</span>
</label>
<input
id="http-sig-header"
type="text"
bind:value={httpSignatureHeader}
placeholder="X-Signature-256"
autocomplete="off"
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground"
/>
<p class="mt-1 text-xs text-muted-foreground">
Defaults to <code class="rounded bg-muted/40 px-1">X-Signature-256</code>. Override for receivers expecting a different name (e.g. <code class="rounded bg-muted/40 px-1">X-Hub-Signature-256</code>).
</p>
</div>
{/if}
<!-- Enabled Toggle -->
@@ -222,7 +283,7 @@
<!-- Test Result -->
{#if testResult}
<p class="text-sm {testResult.startsWith('Failed') ? 'text-destructive' : 'text-green-500'}">
<p class="text-sm {testResult.startsWith('Failed') ? 'text-destructive' : 'text-emerald-500'}">
{testResult}
</p>
{/if}
@@ -25,7 +25,7 @@
async function loadNotifications(page: number = 1) {
loading = true;
try {
// eslint-disable-next-line svelte/prefer-svelte-reactivity
const params = new URLSearchParams({
limit: String(PAGE_SIZE),
offset: String((page - 1) * PAGE_SIZE)
@@ -1,6 +1,4 @@
<script lang="ts">
import { goto } from '$app/navigation';
const STEPS = ['welcome', 'admin', 'authMode', 'theme', 'complete'] as const;
type Step = (typeof STEPS)[number];
@@ -68,7 +68,6 @@
</script>
{#if search.open}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div
class="fixed inset-0 z-50 flex items-start justify-center bg-black/50 pt-[15vh] backdrop-blur-sm"
onclick={handleBackdropClick}
@@ -1,22 +0,0 @@
<script lang="ts">
interface Props {
count?: number;
}
let { count = 3 }: Props = $props();
const items = $derived(Array.from({ length: count }, (_, i) => i));
</script>
{#each items as i (i)}
<div class="rounded-lg border border-border bg-card p-5">
<div class="flex items-start gap-3">
<div class="skeleton h-8 w-8 rounded-md"></div>
<div class="min-w-0 flex-1">
<div class="skeleton mb-2 h-5 w-1/2 rounded"></div>
<div class="skeleton mb-1 h-3 w-full rounded"></div>
<div class="skeleton mt-2 h-3 w-20 rounded"></div>
</div>
</div>
</div>
{/each}
@@ -1,21 +0,0 @@
<script lang="ts">
interface Props {
count?: number;
}
let { count = 1 }: Props = $props();
const items = $derived(Array.from({ length: count }, (_, i) => i));
</script>
{#each items as i (i)}
<div class="rounded-lg border border-border bg-card p-4">
<div class="mb-3 flex items-start justify-between">
<div class="skeleton h-10 w-10 rounded-lg"></div>
<div class="skeleton h-5 w-14 rounded-full"></div>
</div>
<div class="skeleton mb-2 h-4 w-3/4 rounded"></div>
<div class="skeleton h-3 w-full rounded"></div>
<div class="skeleton mt-1 h-3 w-1/2 rounded"></div>
</div>
{/each}
@@ -1,32 +0,0 @@
<script lang="ts">
interface Props {
count?: number;
widgetsPerSection?: number;
}
let { count = 2, widgetsPerSection = 4 }: Props = $props();
const sections = $derived(Array.from({ length: count }, (_, i) => i));
const widgets = $derived(Array.from({ length: widgetsPerSection }, (_, i) => i));
</script>
{#each sections as s (s)}
<div class="rounded-lg border border-border bg-card/50">
<!-- Section header skeleton -->
<div class="flex items-center gap-2 px-4 py-3">
<div class="skeleton h-4 w-4 rounded"></div>
<div class="skeleton h-4 w-32 rounded"></div>
</div>
<!-- Widget grid skeleton -->
<div class="grid grid-cols-2 gap-3 px-4 pb-4 sm:grid-cols-3 lg:grid-cols-4">
{#each widgets as w (w)}
<div class="flex flex-col items-center gap-2 rounded-lg border border-border bg-card p-4">
<div class="skeleton h-12 w-12 rounded-lg"></div>
<div class="skeleton h-3 w-16 rounded"></div>
<div class="skeleton h-4 w-12 rounded-full"></div>
</div>
{/each}
</div>
</div>
{/each}
@@ -92,7 +92,7 @@
{#if open && filtered.length > 0}
<div class="absolute left-0 top-full z-50 mt-1 max-h-48 w-full overflow-y-auto rounded-lg border border-border bg-card shadow-lg">
{#each filtered as item, i}
{#each filtered as item, i (item)}
<button
type="button"
onclick={() => selectItem(item)}
+1 -2
View File
@@ -39,11 +39,10 @@
transition:fade={{ duration: 120 }}
>
<!-- Dialog -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="mx-4 w-full max-w-sm rounded-2xl border border-border bg-card p-6 shadow-2xl"
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => e.stopPropagation()}
transition:scale={{ start: 0.95, duration: 150 }}
role="alertdialog"
tabindex="-1"
+60
View File
@@ -0,0 +1,60 @@
<script lang="ts">
import type { Snippet } from 'svelte';
interface Props {
status: number;
title: string;
hint?: string;
/** Optional detail block (e.g. raw error message in a <details>). */
details?: Snippet;
/** Primary + secondary call-to-action snippets. */
actions?: Snippet;
/** When true, render the chrome (AmbientBackground, card surface). For
* boards/admin nested errors we want to inherit the parent layout. */
standalone?: boolean;
}
let { status, title, hint, details, actions, standalone = false }: Props = $props();
</script>
{#if standalone}
<main
class="relative z-10 flex min-h-screen items-center justify-center bg-background/80 p-4 text-foreground"
>
<div
class="w-full max-w-lg rounded-xl border border-border bg-card/90 p-8 text-center shadow-xl backdrop-blur-sm"
>
<div class="mb-2 text-xs uppercase tracking-widest text-muted-foreground">
{status}
</div>
<h1 class="mb-2 text-2xl font-semibold">{title}</h1>
{#if hint}
<p class="mb-6 text-sm text-muted-foreground">{hint}</p>
{/if}
{#if details}
<div class="mb-6 text-left">{@render details()}</div>
{/if}
{#if actions}
<div class="flex flex-wrap justify-center gap-2">{@render actions()}</div>
{/if}
</div>
</main>
{:else}
<main
class="mx-auto flex min-h-[60vh] max-w-lg flex-col items-center justify-center px-4 py-16 text-center"
>
<div class="mb-2 text-xs uppercase tracking-widest text-muted-foreground">
{status}
</div>
<h1 class="mb-2 text-2xl font-semibold">{title}</h1>
{#if hint}
<p class="mb-6 text-sm text-muted-foreground">{hint}</p>
{/if}
{#if details}
<div class="mb-6 w-full text-left">{@render details()}</div>
{/if}
{#if actions}
<div class="flex flex-wrap justify-center gap-2">{@render actions()}</div>
{/if}
</main>
{/if}
@@ -138,7 +138,7 @@
<p class="py-4 text-center text-xs text-muted-foreground">{$t('common.no_results') ?? 'No matching icons'}</p>
{:else}
<div class="grid grid-cols-8 gap-0.5">
{#each filteredIcons as iconName}
{#each filteredIcons as iconName (iconName)}
<button
type="button"
onclick={() => selectIcon(iconName)}
@@ -120,7 +120,7 @@
</script>
{#if name}
{#each values as v}
{#each values as v (v)}
<input type="hidden" {name} value={v} />
{/each}
{/if}
+2 -2
View File
@@ -99,7 +99,7 @@
<!-- Tag pills -->
{#if tags.length > 0}
<div class="mb-1.5 flex flex-wrap gap-1">
{#each tags as tag}
{#each tags as tag (tag)}
<span class="flex items-center gap-1 rounded-md bg-primary/10 px-2 py-0.5 text-xs text-primary">
{tag}
<button
@@ -129,7 +129,7 @@
{#if open && filtered.length > 0}
<div class="absolute left-0 top-full z-50 mt-1 max-h-48 w-full overflow-y-auto rounded-lg border border-border bg-card shadow-lg">
{#each filtered as item, i}
{#each filtered as item, i (item)}
<button
type="button"
onclick={() => selectItem(item)}
@@ -24,10 +24,10 @@
function groupLabel(dateStr: string): string {
const date = new Date(dateStr);
/* eslint-disable svelte/prefer-svelte-reactivity */
const today = new Date();
const tomorrow = new Date();
/* eslint-enable svelte/prefer-svelte-reactivity */
tomorrow.setDate(today.getDate() + 1);
if (date.toDateString() === today.toDateString()) return 'Today';
@@ -52,7 +52,7 @@
}
const grouped = $derived.by((): GroupedEvents[] => {
// eslint-disable-next-line svelte/prefer-svelte-reactivity
const groups: Map<string, CalendarEvent[]> = new Map();
for (const evt of events) {
const key = new Date(evt.start).toDateString();
@@ -30,7 +30,7 @@
async function fetchMetric() {
error = false;
try {
// eslint-disable-next-line svelte/prefer-svelte-reactivity
const params = new URLSearchParams({ source: config.source });
if (config.value) params.set('value', config.value);
if (config.url) params.set('url', config.url);
@@ -190,8 +190,7 @@
<div class="max-h-80 space-y-3 overflow-y-auto">
{#if widgetType === 'app'}
<div>
<!-- svelte-ignore a11y_label_has_associated_control -->
<label class={labelClass}>{$t('widget.app') ?? 'App'}</label>
<div class={labelClass}>{$t('widget.app') ?? 'App'}</div>
<!-- Search -->
<div class="relative mb-2">
<svg class="absolute left-2.5 top-1/2 -translate-y-1/2 text-muted-foreground" xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
@@ -211,7 +210,7 @@
<p class="py-4 text-center text-xs text-muted-foreground">{$t('common.no_results') ?? 'No apps found'}</p>
{:else}
<div class="grid grid-cols-2 gap-1">
{#each filteredApps as app}
{#each filteredApps as app (app.id)}
<button
type="button"
onclick={() => { appId = app.id; }}
@@ -303,8 +302,7 @@
</label>
</div>
<div>
<!-- svelte-ignore a11y_label_has_associated_control -->
<label class={labelClass}>{$t('widget.apps') ?? 'Apps'}</label>
<div class={labelClass}>{$t('widget.apps') ?? 'Apps'}</div>
<MultiEntityPicker
items={apps.map((a) => ({ value: a.id, label: a.name, icon: a.icon, iconType: a.iconType }))}
bind:values={statusAppIds}
@@ -349,13 +347,11 @@
{:else if widgetType === 'system_stats'}
<div>
<!-- svelte-ignore a11y_label_has_associated_control -->
<label class={labelClass}>Source URL
<input type="url" bind:value={sysStatsSourceUrl} placeholder="http://localhost:61208/api/3" class={inputClass} bind:this={firstInput} />
</label>
</div>
<div>
<!-- svelte-ignore a11y_label_has_associated_control -->
<label class={labelClass}>Source Type
<select bind:value={sysStatsSourceType} class={inputClass}>
<option value="glances">Glances</option>
@@ -365,7 +361,6 @@
</label>
</div>
<div>
<!-- svelte-ignore a11y_label_has_associated_control -->
<label class={labelClass}>Refresh ({sysStatsRefreshInterval}s)
<input type="range" min="5" max="300" bind:value={sysStatsRefreshInterval} class="w-full accent-primary" />
</label>
@@ -373,13 +368,11 @@
{:else if widgetType === 'rss'}
<div>
<!-- svelte-ignore a11y_label_has_associated_control -->
<label class={labelClass}>Feed URL
<input type="url" bind:value={rssFeedUrl} placeholder="https://..." class={inputClass} bind:this={firstInput} />
</label>
</div>
<div>
<!-- svelte-ignore a11y_label_has_associated_control -->
<label class={labelClass}>Max Items ({rssMaxItems})
<input type="range" min="1" max="50" bind:value={rssMaxItems} class="w-full accent-primary" />
</label>
@@ -391,9 +384,8 @@
{:else if widgetType === 'calendar'}
<div>
<!-- svelte-ignore a11y_label_has_associated_control -->
<label class={labelClass}>iCal URLs</label>
{#each calendarUrlsRaw as cal, i}
<div class={labelClass}>iCal URLs</div>
{#each calendarUrlsRaw as cal, i (i)}
<div class="mb-1 flex items-center gap-1">
<input type="url" bind:value={cal.url} placeholder="https://..." class="{inputClass} flex-1" />
<input type="text" bind:value={cal.label} placeholder="Label" class="{inputClass} w-20" />
@@ -477,9 +469,8 @@
{:else if widgetType === 'link_group'}
<div>
<!-- svelte-ignore a11y_label_has_associated_control -->
<label class={labelClass}>Links</label>
{#each linkGroupLinks as link, i}
<div class={labelClass}>Links</div>
{#each linkGroupLinks as link, i (i)}
<div class="mb-1 flex items-center gap-1">
<input type="text" bind:value={link.label} placeholder="Label" class="{inputClass} w-24" />
<input type="url" bind:value={link.url} placeholder="URL" class="{inputClass} flex-1" />
@@ -531,7 +522,7 @@
<label class={labelClass}>{$t('widget.app') ?? 'App'}
<select bind:value={integrationAppId} class={inputClass} bind:this={firstInput}>
<option value="">Select app...</option>
{#each apps as app}
{#each apps as app (app.id)}
<option value={app.id}>{app.name}</option>
{/each}
</select>
@@ -1,4 +1,9 @@
<script lang="ts">
// Note: this file is ~1000 lines because it contains the create-form for
// all 14 widget types. A clean split would lift each widget's state into
// its own subcomponent — a non-trivial refactor that touches form-submit
// plumbing. Deferred until UI/UX restructure pass; the structure is
// internally consistent (each {:else if} block follows the same pattern).
import { t } from 'svelte-i18n';
import IconGrid from '$lib/components/ui/IconGrid.svelte';
import EntityPicker from '$lib/components/ui/EntityPicker.svelte';
@@ -132,7 +137,10 @@
.then((r) => r.json())
.then((json) => {
if (json.success) {
integrationApps = (json.data ?? []).filter((a: any) => a.integrationEnabled && a.integrationType);
integrationApps = (json.data ?? []).filter(
(a: { integrationEnabled?: boolean; integrationType?: string | null }) =>
a.integrationEnabled && a.integrationType
);
}
})
.catch(() => {});
@@ -147,7 +155,9 @@
.then((r) => r.json())
.then((json) => {
if (json.success) {
const integration = (json.data ?? []).find((i: any) => i.id === app.integrationType);
const integration = (json.data ?? []).find(
(i: { id: string }) => i.id === app.integrationType
);
integrationEndpoints = integration?.endpoints ?? [];
}
})
@@ -108,7 +108,7 @@
{$t('widget.width') ?? 'Width'}
</div>
<div class="flex flex-col gap-1">
{#each Array.from({ length: maxCols }, (_, i) => i + 1) as span}
{#each Array.from({ length: maxCols }, (_, i) => i + 1) as span (span)}
{@const isActive = span === colSpan}
{@const isPreview = span === previewSpan}
<button
@@ -125,7 +125,7 @@
>
<!-- Visual block representation -->
<div class="flex gap-px">
{#each Array(maxCols) as _, ci}
{#each Array(maxCols) as _, ci (ci)}
<div
class="h-2.5 w-3 rounded-[2px] transition-colors
{ci < span
+88 -49
View File
@@ -2,18 +2,22 @@
import { t } from 'svelte-i18n';
import AppWidget from './AppWidget.svelte';
import BookmarkWidget from './BookmarkWidget.svelte';
import NoteWidget from './NoteWidget.svelte';
import EmbedWidget from './EmbedWidget.svelte';
import StatusWidget from './StatusWidget.svelte';
import ClockWeatherWidget from './ClockWeatherWidget.svelte';
import SystemStatsWidget from './SystemStatsWidget.svelte';
import RssFeedWidget from './RssFeedWidget.svelte';
import CalendarWidget from './CalendarWidget.svelte';
import MarkdownWidget from './MarkdownWidget.svelte';
import MetricWidget from './MetricWidget.svelte';
import LinkGroupWidget from './LinkGroupWidget.svelte';
import CameraStreamWidget from './CameraStreamWidget.svelte';
import IntegrationWidget from './integration/IntegrationWidget.svelte';
// Heavy widgets (marked/DOMPurify/hls.js/integration renderers) are lazy-loaded
// so empty boards don't pay their bundle cost. Loaders return a module promise
// rendered by <svelte:component> via #await.
const NoteWidgetLoader = () => import('./NoteWidget.svelte');
const MarkdownWidgetLoader = () => import('./MarkdownWidget.svelte');
const SystemStatsWidgetLoader = () => import('./SystemStatsWidget.svelte');
const RssFeedWidgetLoader = () => import('./RssFeedWidget.svelte');
const CalendarWidgetLoader = () => import('./CalendarWidget.svelte');
const MetricWidgetLoader = () => import('./MetricWidget.svelte');
const CameraStreamWidgetLoader = () => import('./CameraStreamWidget.svelte');
const IntegrationWidgetLoader = () => import('./integration/IntegrationWidget.svelte');
interface AppData {
id: string;
@@ -53,12 +57,22 @@
});
</script>
{#snippet skeleton()}
<div class="flex h-full items-center justify-center rounded-xl border border-border bg-card p-4">
<span class="text-xs text-muted-foreground"></span>
</div>
{/snippet}
{#if widget.type === 'app' && widget.app}
<AppWidget app={widget.app} {cardSize} />
{:else if widget.type === 'bookmark'}
<BookmarkWidget config={parsedConfig} />
{:else if widget.type === 'note'}
<NoteWidget config={{ content: parsedConfig.content ?? '', format: parsedConfig.format ?? 'markdown' }} />
{#await NoteWidgetLoader()}
{@render skeleton()}
{:then mod}
<mod.default config={{ content: parsedConfig.content ?? '', format: parsedConfig.format ?? 'markdown' }} />
{/await}
{:else if widget.type === 'embed'}
<EmbedWidget config={{ url: parsedConfig.url ?? '', height: parsedConfig.height ?? 300, sandbox: parsedConfig.sandbox }} />
{:else if widget.type === 'status'}
@@ -72,57 +86,82 @@
clockStyle: parsedConfig.clockStyle ?? 'digital'
}} />
{:else if widget.type === 'system_stats'}
<SystemStatsWidget config={{
sourceUrl: parsedConfig.sourceUrl ?? '',
sourceType: parsedConfig.sourceType ?? 'custom',
metrics: parsedConfig.metrics ?? [],
refreshInterval: parsedConfig.refreshInterval ?? 30
}} />
{#await SystemStatsWidgetLoader()}
{@render skeleton()}
{:then mod}
<mod.default config={{
sourceUrl: parsedConfig.sourceUrl ?? '',
sourceType: parsedConfig.sourceType ?? 'custom',
metrics: parsedConfig.metrics ?? [],
refreshInterval: parsedConfig.refreshInterval ?? 30
}} />
{/await}
{:else if widget.type === 'rss'}
<RssFeedWidget config={{
feedUrl: parsedConfig.feedUrl ?? '',
maxItems: parsedConfig.maxItems ?? 10,
showSummary: parsedConfig.showSummary ?? true
}} />
{#await RssFeedWidgetLoader()}
{@render skeleton()}
{:then mod}
<mod.default config={{
feedUrl: parsedConfig.feedUrl ?? '',
maxItems: parsedConfig.maxItems ?? 10,
showSummary: parsedConfig.showSummary ?? true
}} />
{/await}
{:else if widget.type === 'calendar'}
<CalendarWidget config={{
icalUrls: parsedConfig.icalUrls ?? [],
daysAhead: parsedConfig.daysAhead ?? 7
}} />
{#await CalendarWidgetLoader()}
{@render skeleton()}
{:then mod}
<mod.default config={{ icalUrls: parsedConfig.icalUrls ?? [], daysAhead: parsedConfig.daysAhead ?? 7 }} />
{/await}
{:else if widget.type === 'markdown'}
<MarkdownWidget
config={{ content: parsedConfig.content ?? '', syntaxTheme: parsedConfig.syntaxTheme }}
widgetId={widget.id}
/>
{#await MarkdownWidgetLoader()}
{@render skeleton()}
{:then mod}
<mod.default
config={{ content: parsedConfig.content ?? '', syntaxTheme: parsedConfig.syntaxTheme }}
widgetId={widget.id}
/>
{/await}
{:else if widget.type === 'metric'}
<MetricWidget config={{
label: parsedConfig.label ?? 'Metric',
source: parsedConfig.source ?? 'static',
value: parsedConfig.value,
url: parsedConfig.url,
jsonPath: parsedConfig.jsonPath,
query: parsedConfig.query,
unit: parsedConfig.unit,
refreshInterval: parsedConfig.refreshInterval ?? 60
}} />
{#await MetricWidgetLoader()}
{@render skeleton()}
{:then mod}
<mod.default config={{
label: parsedConfig.label ?? 'Metric',
source: parsedConfig.source ?? 'static',
value: parsedConfig.value,
url: parsedConfig.url,
jsonPath: parsedConfig.jsonPath,
query: parsedConfig.query,
unit: parsedConfig.unit,
refreshInterval: parsedConfig.refreshInterval ?? 60
}} />
{/await}
{:else if widget.type === 'link_group'}
<LinkGroupWidget config={{
links: parsedConfig.links ?? [],
collapsible: parsedConfig.collapsible ?? false
}} />
{:else if widget.type === 'camera'}
<CameraStreamWidget config={{
streamUrl: parsedConfig.streamUrl ?? '',
type: parsedConfig.type ?? 'image',
refreshInterval: parsedConfig.refreshInterval ?? 10,
aspectRatio: parsedConfig.aspectRatio ?? '16/9'
}} />
{#await CameraStreamWidgetLoader()}
{@render skeleton()}
{:then mod}
<mod.default config={{
streamUrl: parsedConfig.streamUrl ?? '',
type: parsedConfig.type ?? 'image',
refreshInterval: parsedConfig.refreshInterval ?? 10,
aspectRatio: parsedConfig.aspectRatio ?? '16/9'
}} />
{/await}
{:else if widget.type === 'integration'}
<IntegrationWidget config={{
appId: parsedConfig.appId ?? '',
endpointId: parsedConfig.endpointId ?? '',
refreshInterval: parsedConfig.refreshInterval ?? 60
}} />
{#await IntegrationWidgetLoader()}
{@render skeleton()}
{:then mod}
<mod.default config={{
appId: parsedConfig.appId ?? '',
endpointId: parsedConfig.endpointId ?? '',
refreshInterval: parsedConfig.refreshInterval ?? 60
}} />
{/await}
{:else}
<div class="flex h-full items-center justify-center rounded-xl border border-border bg-card p-4">
<span class="text-xs text-muted-foreground">{$t('widget.type', { values: { type: widget.type } })}</span>
@@ -121,7 +121,7 @@
<p class="py-8 text-center text-sm text-muted-foreground">{$t('common.no_results') ?? 'No matching widget types'}</p>
{:else}
<div class="grid grid-cols-2 gap-1.5">
{#each filteredTypes as wt}
{#each filteredTypes as wt (wt.value)}
<button
type="button"
onclick={() => onSelect(wt.value)}
@@ -129,7 +129,7 @@
>
<div class="mt-0.5 shrink-0 rounded-lg bg-primary/10 p-2 text-primary">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
{#each iconFor(wt.value).split('|') as segment}
{#each iconFor(wt.value).split('|') as segment, si (si)}
{#if segment.includes('m') || segment.includes('M') || segment.includes('a') || segment.includes('z') || segment.includes('A') || segment.includes('c') || segment.includes('l') || segment.includes('v') || segment.includes('h') || segment.includes('V') || segment.includes('H')}
<path d={segment} />
{:else}
@@ -23,8 +23,8 @@
<p class="py-4 text-center text-sm text-muted-foreground">No chart data</p>
{:else}
<svg viewBox="0 0 100 60" class="h-40 w-full" preserveAspectRatio="none">
{#each data.datasets as dataset, di}
{#each dataset.values as value, i}
{#each data.datasets as dataset, di (di)}
{#each dataset.values as value, i (i)}
{@const barHeight = (value / maxValue) * 50}
{@const x = (i / data.labels.length) * 100 + 1 + di * (barWidth / data.datasets.length)}
<rect
@@ -42,7 +42,7 @@
</svg>
{#if data.datasets.length > 1}
<div class="mt-2 flex flex-wrap justify-center gap-3">
{#each data.datasets as dataset, di}
{#each data.datasets as dataset, di (di)}
<div class="flex items-center gap-1 text-xs text-muted-foreground">
<span class="h-2 w-2 rounded-full" style="background-color: {dataset.color ?? defaultColors[di % defaultColors.length]}"></span>
{dataset.label}
@@ -25,7 +25,7 @@
{#if alerts.length > 0}
<div class="space-y-2 px-4 pt-2">
{#each alerts as alert}
{#each alerts as alert, i (i)}
<AlertBannerRenderer data={alert} />
{/each}
</div>
+54
View File
@@ -31,6 +31,51 @@
"auth.no_account": "Don't have an account?",
"auth.have_account": "Already have an account?",
"auth.sign_in_link": "Sign in",
"auth.back_to_login": "Back to sign in",
"auth.remember_me": "Keep me signed in for 30 days",
"auth.forgot_password": "Forgot password?",
"auth.forgot_password_title": "Reset password",
"auth.forgot_password_hint": "Enter your account email. An admin will share a reset link with you.",
"auth.forgot_password_submit": "Request reset link",
"auth.forgot_password_submitted_title": "Request received",
"auth.forgot_password_submitted_hint": "If an account exists for that email, your admin can now generate a reset link for you in the admin panel.",
"auth.reset_password_title": "Choose a new password",
"auth.reset_password_for": "Resetting password for",
"auth.reset_password_submit": "Set new password",
"auth.reset_invalid_title": "Reset link is invalid",
"auth.reset_invalid_hint": "The link may have expired, already been used, or copied incorrectly. Ask your admin to issue a new one.",
"auth.request_new_reset": "Request a new reset link",
"auth.new_password": "New password",
"auth.confirm_password": "Confirm password",
"auth.invite_title": "Redeem invite",
"auth.invite_hint": "Paste the invite token an admin sent you. You'll be taken to the registration page next.",
"auth.invite_token": "Invite token",
"auth.invite_continue": "Continue",
"auth.invite_banner_admin": "You've been invited to join as an",
"auth.invite_banner_user": "You've been invited to join.",
"auth.invite_banner_locked": "This invite is locked to",
"error.unauthorized_title": "Sign in to continue",
"error.unauthorized_hint": "Your session may have expired. Sign back in to continue.",
"error.forbidden_title": "You don't have access to this",
"error.forbidden_hint": "Ask an admin to grant access if you believe this is a mistake.",
"error.not_found_title": "Page not found",
"error.not_found_hint": "The page you were looking for doesn't exist or was moved.",
"error.rate_limited_title": "Too many requests",
"error.rate_limited_hint": "Slow down. Try again in a moment.",
"error.generic_title": "Something went wrong",
"error.generic_hint": "An unexpected error occurred. Try refreshing, or head back to the dashboard.",
"error.back_to_dashboard": "Back to dashboard",
"error.technical_details": "Technical details",
"error.board_not_found_title": "This board doesn't exist",
"error.board_not_found_hint": "It may have been deleted or you have the wrong URL.",
"error.board_forbidden_title": "You don't have access to this board",
"error.board_forbidden_hint": "Ask the board owner or an admin to share it with you.",
"error.board_generic_title": "Couldn't load this board",
"error.admin_forbidden_title": "Admin access required",
"error.admin_forbidden_hint": "Your account doesn't have the admin role. Ask another admin to grant access if you need it.",
"error.admin_not_found_title": "Admin page not found",
"error.admin_generic_title": "Couldn't load this admin page",
"board.title": "Boards",
"board.boards_available": "{count} board(s) available",
@@ -134,6 +179,10 @@
"admin.users": "Users",
"admin.groups": "Groups",
"admin.settings": "Settings",
"admin.invites": "Invites",
"admin.password_resets": "Password Resets",
"admin.tags": "Tags",
"admin.audit_log": "Audit Log",
"admin.user_management": "User Management",
"admin.create_user": "Create User",
@@ -331,10 +380,15 @@
"settings.saturation": "Saturation",
"settings.background": "Background Effect",
"settings.language": "Language",
"settings.card_style": "Card Style",
"settings.save": "Save Preferences",
"settings.saving": "Saving...",
"settings.saved": "Preferences saved!",
"card_style.solid": "Solid",
"card_style.glass": "Glass",
"card_style.outline": "Outline",
"offline.title": "You're Offline",
"offline.description": "It looks like you've lost your internet connection. Check your network and try again.",
"offline.retry": "Retry",
+54
View File
@@ -29,6 +29,51 @@
"auth.no_account": "Нет аккаунта?",
"auth.have_account": "Уже есть аккаунт?",
"auth.sign_in_link": "Войти",
"auth.back_to_login": "Назад ко входу",
"auth.remember_me": "Запомнить меня на 30 дней",
"auth.forgot_password": "Забыли пароль?",
"auth.forgot_password_title": "Сброс пароля",
"auth.forgot_password_hint": "Введите email вашей учётной записи. Администратор отправит вам ссылку для сброса.",
"auth.forgot_password_submit": "Запросить ссылку",
"auth.forgot_password_submitted_title": "Запрос принят",
"auth.forgot_password_submitted_hint": "Если для этого email существует учётная запись, администратор сможет сгенерировать ссылку для сброса в админ-панели.",
"auth.reset_password_title": "Выберите новый пароль",
"auth.reset_password_for": "Сброс пароля для",
"auth.reset_password_submit": "Установить новый пароль",
"auth.reset_invalid_title": "Ссылка для сброса недействительна",
"auth.reset_invalid_hint": "Срок ссылки истёк, она уже была использована, или скопирована некорректно. Попросите администратора выпустить новую.",
"auth.request_new_reset": "Запросить новую ссылку",
"auth.new_password": "Новый пароль",
"auth.confirm_password": "Подтвердите пароль",
"auth.invite_title": "Принять приглашение",
"auth.invite_hint": "Вставьте токен приглашения, который отправил администратор. Вы перейдёте к регистрации.",
"auth.invite_token": "Токен приглашения",
"auth.invite_continue": "Продолжить",
"auth.invite_banner_admin": "Вас пригласили присоединиться как",
"auth.invite_banner_user": "Вас пригласили присоединиться.",
"auth.invite_banner_locked": "Это приглашение привязано к",
"error.unauthorized_title": "Войдите, чтобы продолжить",
"error.unauthorized_hint": "Сессия истекла. Войдите снова.",
"error.forbidden_title": "У вас нет доступа",
"error.forbidden_hint": "Попросите администратора предоставить доступ, если это ошибка.",
"error.not_found_title": "Страница не найдена",
"error.not_found_hint": "Страница не существует или была перемещена.",
"error.rate_limited_title": "Слишком много запросов",
"error.rate_limited_hint": "Подождите и попробуйте снова через минуту.",
"error.generic_title": "Что-то пошло не так",
"error.generic_hint": "Произошла неожиданная ошибка. Обновите страницу или вернитесь на главную.",
"error.back_to_dashboard": "Назад на главную",
"error.technical_details": "Технические детали",
"error.board_not_found_title": "Доска не существует",
"error.board_not_found_hint": "Возможно, она была удалена или неверный URL.",
"error.board_forbidden_title": "У вас нет доступа к этой доске",
"error.board_forbidden_hint": "Попросите владельца или администратора поделиться ею.",
"error.board_generic_title": "Не удалось загрузить доску",
"error.admin_forbidden_title": "Требуются права администратора",
"error.admin_forbidden_hint": "У вашей учётной записи нет роли администратора. Попросите другого администратора предоставить доступ.",
"error.admin_not_found_title": "Админ-страница не найдена",
"error.admin_generic_title": "Не удалось загрузить админ-страницу",
"board.title": "Доски",
"board.boards_available": "Доступно досок: {count}",
"board.new": "Новая доска",
@@ -127,6 +172,10 @@
"admin.users": "Пользователи",
"admin.groups": "Группы",
"admin.settings": "Настройки",
"admin.invites": "Приглашения",
"admin.password_resets": "Сброс паролей",
"admin.tags": "Теги",
"admin.audit_log": "Журнал аудита",
"admin.user_management": "Управление пользователями",
"admin.create_user": "Создать пользователя",
"admin.new_user": "Новый пользователь",
@@ -311,9 +360,14 @@
"settings.saturation": "Насыщенность",
"settings.background": "Эффект фона",
"settings.language": "Язык",
"settings.card_style": "Стиль карточек",
"settings.save": "Сохранить настройки",
"settings.saving": "Сохранение...",
"settings.saved": "Настройки сохранены!",
"card_style.solid": "Сплошной",
"card_style.glass": "Стекло",
"card_style.outline": "Контур",
"settings.bookmarklet_title": "Быстрое добавление (букмарклет)",
"settings.bookmarklet_instructions": "Перетащите кнопку ниже на панель закладок браузера. При посещении любой страницы нажмите её, чтобы быстро добавить сайт в App Launcher.",
"settings.bookmarklet_drag": "Добавить в Launcher",
+89
View File
@@ -0,0 +1,89 @@
/**
* Typed error classes for server-side service layer.
* Route handlers can `instanceof` to map them to HTTP responses without
* substring-matching error messages.
*/
export class AppError extends Error {
readonly statusCode: number;
readonly code: string;
constructor(message: string, statusCode: number, code: string) {
super(message);
this.name = 'AppError';
this.statusCode = statusCode;
this.code = code;
}
}
export class NotFoundError extends AppError {
constructor(message = 'Resource not found') {
super(message, 404, 'NOT_FOUND');
this.name = 'NotFoundError';
}
}
export class ValidationError extends AppError {
constructor(message = 'Invalid input') {
super(message, 400, 'VALIDATION_ERROR');
this.name = 'ValidationError';
}
}
export class PermissionError extends AppError {
constructor(message = 'Permission denied') {
super(message, 403, 'PERMISSION_DENIED');
this.name = 'PermissionError';
}
}
export class UnauthorizedError extends AppError {
constructor(message = 'Authentication required') {
super(message, 401, 'UNAUTHORIZED');
this.name = 'UnauthorizedError';
}
}
export class ConflictError extends AppError {
constructor(message = 'Resource conflict') {
super(message, 409, 'CONFLICT');
this.name = 'ConflictError';
}
}
export class RateLimitError extends AppError {
constructor(message = 'Too many requests') {
super(message, 429, 'RATE_LIMITED');
this.name = 'RateLimitError';
}
}
export class IntegrationError extends AppError {
constructor(message = 'Upstream integration failed') {
super(message, 502, 'INTEGRATION_ERROR');
this.name = 'IntegrationError';
}
}
/**
* Map any thrown value to {status, message} for route responses.
* Hides internal messages in production for non-AppError throws.
*/
export function toHttpError(err: unknown): { status: number; message: string; code?: string } {
if (err instanceof AppError) {
return { status: err.statusCode, message: err.message, code: err.code };
}
if (err instanceof Error) {
// Last-ditch: treat "not found" messages as 404 for back-compat with services
// that still throw plain Error('… not found').
if (/not found/i.test(err.message)) {
return { status: 404, message: err.message, code: 'NOT_FOUND' };
}
}
const message =
process.env.NODE_ENV === 'production'
? 'Internal server error'
: err instanceof Error
? err.message
: 'Internal server error';
return { status: 500, message, code: 'INTERNAL' };
}
+19 -15
View File
@@ -1,30 +1,34 @@
const DEFAULT_TIMEOUT_MS = 10_000;
import { DEFAULTS } from '$lib/utils/constants.js';
import { safeFetch, type SafeResponse } from '$lib/server/utils/safeFetch.js';
const DEFAULT_TIMEOUT_MS = DEFAULTS.REMOTE_FETCH_DEFAULT_TIMEOUT_MS;
/**
* SSRF-guarded, timeout-bounded, response-size-capped fetch for integrations.
* Drop-in replacement for the previous bare `fetch` returns a SafeResponse
* whose `.json()`/`.text()` methods refuse to materialize bodies over the
* configured cap.
*/
export async function fetchWithTimeout(
url: string,
options?: RequestInit & { timeout?: number }
): Promise<Response> {
const timeout = options?.timeout ?? DEFAULT_TIMEOUT_MS;
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeout);
options?: RequestInit & { timeout?: number; trusted?: boolean }
): Promise<SafeResponse> {
const { timeout, trusted, ...rest } = options ?? {};
try {
const response = await fetch(url, {
...options,
signal: controller.signal,
return await safeFetch(url, {
...rest,
timeoutMs: timeout ?? DEFAULT_TIMEOUT_MS,
trusted,
headers: {
'User-Agent': 'WebAppLauncher/1.0',
...options?.headers
...(rest.headers as Record<string, string> | undefined)
}
});
return response;
} catch (err) {
if (err instanceof DOMException && err.name === 'AbortError') {
throw new Error(`Integration request timed out after ${timeout}ms`);
throw new Error(`Integration request timed out after ${timeout ?? DEFAULT_TIMEOUT_MS}ms`);
}
throw err;
} finally {
clearTimeout(timeoutId);
}
}
+4 -3
View File
@@ -1,4 +1,5 @@
import { fetchWithTimeout } from '../base.js';
import type { SafeResponse } from '$lib/server/utils/safeFetch.js';
export interface DelugeTorrent {
readonly name: string;
@@ -34,7 +35,7 @@ async function rpcCall(
params: unknown[],
id: number,
cookie?: string
): Promise<Response> {
): Promise<SafeResponse> {
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
if (cookie) {
headers['Cookie'] = cookie;
@@ -50,7 +51,7 @@ async function rpcCall(
return res;
}
function extractCookie(res: Response): string {
function extractCookie(res: SafeResponse): string {
const setCookie = res.headers.get('set-cookie');
if (!setCookie) return '';
const match = setCookie.match(/^([^;]+)/);
@@ -60,7 +61,7 @@ function extractCookie(res: Response): string {
export async function authenticate(appUrl: string, password: string): Promise<string> {
const url = rpcUrl(appUrl);
const res = await rpcCall(url, 'auth.login', [password], 1);
const body = await res.json();
const body = (await res.json()) as { result?: boolean };
if (!body.result) {
throw new Error('Deluge authentication failed — invalid password');
+9 -10
View File
@@ -1,17 +1,16 @@
import { createCipheriv, createDecipheriv, randomBytes, createHash } from 'crypto';
import { env } from '$env/dynamic/private';
import { createCipheriv, createDecipheriv, randomBytes, hkdfSync } from 'crypto';
import { requireIntegrationEncryptionKey } from '$lib/server/utils/env.js';
const ALGORITHM = 'aes-256-gcm';
const IV_LENGTH = 12;
const KEY_LENGTH = 32;
const SALT = Buffer.from('web-app-launcher::integration-encryption::v1', 'utf8');
const INFO = Buffer.from('aes-256-gcm-key', 'utf8');
function getKey(): Buffer {
const keySource = env.INTEGRATION_ENCRYPTION_KEY ?? env.JWT_SECRET ?? '';
if (!keySource) {
throw new Error(
'No encryption key available. Set INTEGRATION_ENCRYPTION_KEY or JWT_SECRET.'
);
}
return createHash('sha256').update(keySource).digest();
const keySource = requireIntegrationEncryptionKey();
const derived = hkdfSync('sha256', keySource, SALT, INFO, KEY_LENGTH);
return Buffer.from(derived);
}
export function encrypt(plaintext: string): string {
@@ -34,7 +33,7 @@ export function decrypt(encryptedText: string): string {
const encrypted = Buffer.from(parts[2], 'hex');
const decipher = createDecipheriv(ALGORITHM, key, iv);
decipher.setAuthTag(authTag);
return decipher.update(encrypted) + decipher.final('utf8');
return Buffer.concat([decipher.update(encrypted), decipher.final()]).toString('utf8');
}
export function tryDecrypt(encryptedText: string | null | undefined): string | null {
@@ -19,7 +19,7 @@ function formatRelativeTime(dateStr: string): string {
export function toRecentCommits(
commits: readonly GiteaCommit[],
repoName: string
_repoName: string
): ListData {
return {
items: commits.map((c) => {
+2 -1
View File
@@ -1,4 +1,5 @@
import { fetchWithTimeout } from '../base.js';
import type { SafeResponse } from '$lib/server/utils/safeFetch.js';
export interface NpmProxyHost {
readonly id: number;
@@ -75,7 +76,7 @@ async function authenticatedFetch(
token: string,
email: string,
password: string
): Promise<Response> {
): Promise<SafeResponse> {
const baseUrl = buildBaseUrl(appUrl);
const url = `${baseUrl}${path}`;
+28 -16
View File
@@ -29,22 +29,37 @@ function buildBaseUrl(appUrl: string): string {
return `${appUrl.replace(/\/$/, '')}/admin/api.php`;
}
export async function fetchSummary(appUrl: string, apiToken: string): Promise<PiholeSummary> {
const url = `${buildBaseUrl(appUrl)}?summary&auth=${apiToken}`;
const res = await fetchWithTimeout(url);
/**
* Pi-hole v5 API exposes both GET and POST. We use POST with a form-encoded
* body so the API token is NOT logged in reverse-proxy access logs alongside
* the URL. The token still hits the wire, of course but it's no longer
* persisted in every `/var/log/nginx/access.log` entry.
*/
async function postWithAuth(
appUrl: string,
params: Record<string, string>,
apiToken: string
): Promise<unknown> {
const body = new URLSearchParams({ ...params, auth: apiToken });
const res = await fetchWithTimeout(buildBaseUrl(appUrl), {
method: 'POST',
body: body.toString(),
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }
});
if (!res.ok) {
throw new Error(`Pi-hole API returned ${res.status}`);
}
return res.json();
}
export async function fetchSummary(appUrl: string, apiToken: string): Promise<PiholeSummary> {
const data = (await postWithAuth(appUrl, { summary: '' }, apiToken)) as PiholeSummary;
return data;
}
export async function fetchTopItems(appUrl: string, apiToken: string): Promise<PiholeTopItems> {
const url = `${buildBaseUrl(appUrl)}?topItems=10&auth=${apiToken}`;
const res = await fetchWithTimeout(url);
if (!res.ok) {
throw new Error(`Pi-hole API returned ${res.status}`);
}
return res.json();
const data = (await postWithAuth(appUrl, { topItems: '10' }, apiToken)) as PiholeTopItems;
return data;
}
export async function fetchAllQueries(
@@ -52,11 +67,8 @@ export async function fetchAllQueries(
apiToken: string,
count = 100
): Promise<readonly PiholeQueryEntry[]> {
const url = `${buildBaseUrl(appUrl)}?getAllQueries=${count}&auth=${apiToken}`;
const res = await fetchWithTimeout(url);
if (!res.ok) {
throw new Error(`Pi-hole API returned ${res.status}`);
}
const json = await res.json();
return json.data ?? [];
const data = (await postWithAuth(appUrl, { getAllQueries: String(count) }, apiToken)) as {
data?: readonly PiholeQueryEntry[];
};
return data.data ?? [];
}
+2 -1
View File
@@ -1,4 +1,5 @@
import { fetchWithTimeout } from '../base.js';
import type { SafeResponse } from '$lib/server/utils/safeFetch.js';
export interface PlankaCard {
readonly id: string;
@@ -69,7 +70,7 @@ async function authenticatedFetch(
token: string,
email: string,
password: string
): Promise<Response> {
): Promise<SafeResponse> {
const baseUrl = buildBaseUrl(appUrl);
const url = `${baseUrl}${path}`;
+17 -8
View File
@@ -3,7 +3,10 @@ import { createBackup, enforceRetention, getBackupSettings } from '$lib/server/s
import { logAction } from '$lib/server/services/auditLogService.js';
import { AuditAction } from '$lib/utils/constants.js';
let scheduledTask: cron.ScheduledTask | null = null;
// HMR-safe singleton — see comment in healthcheckScheduler.ts.
const g = globalThis as unknown as { __walBackupScheduler?: { task: cron.ScheduledTask | null } };
if (!g.__walBackupScheduler) g.__walBackupScheduler = { task: null };
const state = g.__walBackupScheduler;
/**
* Start the backup scheduler with the given settings.
@@ -14,7 +17,11 @@ export function startBackupScheduler(settings: {
readonly backupCronExpression: string;
readonly backupMaxCount: number;
}): void {
if (scheduledTask) {
if (process.env.RUN_SCHEDULERS === 'false') {
return;
}
if (state.task) {
return;
}
@@ -26,7 +33,7 @@ export function startBackupScheduler(settings: {
return;
}
scheduledTask = cron.schedule(settings.backupCronExpression, async () => {
state.task = cron.schedule(settings.backupCronExpression, async () => {
try {
const backup = await createBackup();
enforceRetention(settings.backupMaxCount);
@@ -47,9 +54,9 @@ export function startBackupScheduler(settings: {
* Stop the backup scheduler.
*/
export function stopBackupScheduler(): void {
if (scheduledTask) {
scheduledTask.stop();
scheduledTask = null;
if (state.task) {
state.task.stop();
state.task = null;
}
}
@@ -71,10 +78,12 @@ export function restartBackupScheduler(settings: {
* Call this at server startup.
*/
export async function initBackupScheduler(): Promise<void> {
if (process.env.RUN_SCHEDULERS === 'false') return;
try {
const settings = await getBackupSettings();
startBackupScheduler(settings);
} catch {
// Swallow errors — backup scheduler is non-critical
} catch (err) {
console.warn('[backup] initBackupScheduler failed:', err);
}
}
+141 -73
View File
@@ -3,28 +3,40 @@ import { checkAllApps, pruneOldStatuses } from '$lib/server/services/healthcheck
import { broadcastNotification } from '$lib/server/services/notificationService.js';
import { pruneOldLogs } from '$lib/server/services/auditLogService.js';
import * as appService from '$lib/server/services/appService.js';
import { AppStatusValue, NotificationEvent } from '$lib/utils/constants.js';
import { metricRegistry, Counters } from '$lib/server/services/metricsService.js';
import { prisma } from '$lib/server/prisma.js';
import {
AppStatusValue,
NotificationEvent,
DEFAULTS
} from '$lib/utils/constants.js';
let scheduledTask: cron.ScheduledTask | null = null;
let cleanupTask: cron.ScheduledTask | null = null;
let auditPruneTask: cron.ScheduledTask | null = null;
// Pin scheduler state on globalThis so SvelteKit/Vite HMR reloads of
// hooks.server.ts don't spawn duplicate cron tasks. node-cron retains strong
// refs to its tasks; without this guard each reload would multiply the workload.
interface SchedulerGlobals {
scheduled?: cron.ScheduledTask | null;
cleanup?: cron.ScheduledTask | null;
auditPrune?: cron.ScheduledTask | null;
retention?: cron.ScheduledTask | null;
previousStatuses?: Map<string, string>;
}
const g = globalThis as unknown as { __walSchedulers?: SchedulerGlobals };
if (!g.__walSchedulers) g.__walSchedulers = {};
const schedulers = g.__walSchedulers;
if (!schedulers.previousStatuses) {
schedulers.previousStatuses = new Map<string, string>();
}
const previousStatuses = schedulers.previousStatuses;
// Track previous status per app to detect transitions
const previousStatuses = new Map<string, string>();
let healthcheckInFlight = false;
/**
* Check if a status transition warrants a notification.
*/
function getStatusChangeEvent(
previousStatus: string | undefined,
newStatus: string
): string | null {
if (!previousStatus) {
return null; // First check — no transition
}
if (previousStatus === newStatus) {
return null; // No change
}
if (!previousStatus) return null;
if (previousStatus === newStatus) return null;
if (newStatus === AppStatusValue.OFFLINE && previousStatus !== AppStatusValue.OFFLINE) {
return NotificationEvent.APP_OFFLINE;
@@ -35,91 +47,147 @@ function getStatusChangeEvent(
if (newStatus === AppStatusValue.DEGRADED && previousStatus !== AppStatusValue.DEGRADED) {
return NotificationEvent.APP_DEGRADED;
}
return null;
}
/**
* Start the healthcheck scheduler.
* Runs checkAllApps on a cron schedule (default: every 60 seconds).
* Also starts an hourly cleanup job to prune old status records.
* Triggers notifications when app status changes.
*/
export function startScheduler(cronExpression: string = '* * * * *'): void {
if (scheduledTask) {
async function runHealthcheckTick(): Promise<void> {
if (healthcheckInFlight) {
// Previous tick still running — skip to avoid stacking.
return;
}
healthcheckInFlight = true;
metricRegistry.incCounter(Counters.HEALTHCHECK_TOTAL);
try {
const results = await checkAllApps();
const hadOffline = results.some((r) => r.status === AppStatusValue.OFFLINE);
if (hadOffline) metricRegistry.incCounter(Counters.HEALTHCHECK_FAILED);
scheduledTask = cron.schedule(cronExpression, async () => {
try {
const results = await checkAllApps();
for (const result of results) {
const prevStatus = previousStatuses.get(result.appId);
const event = getStatusChangeEvent(prevStatus, result.status);
// Check for status transitions and send notifications
for (const result of results) {
const prevStatus = previousStatuses.get(result.appId);
const event = getStatusChangeEvent(prevStatus, result.status);
if (event) {
// Fire-and-forget notification
appService
.findById(result.appId)
.then((app) => {
const statusLabel = result.status.charAt(0).toUpperCase() + result.status.slice(1);
const message = `${app.name} is now ${statusLabel} (was ${prevStatus ?? 'unknown'})`;
return broadcastNotification(result.appId, event, message);
})
.catch(() => {
// Swallow notification errors
});
}
previousStatuses.set(result.appId, result.status);
if (event) {
appService
.findById(result.appId)
.then((app) => {
const statusLabel =
result.status.charAt(0).toUpperCase() + result.status.slice(1);
const message = `${app.name} is now ${statusLabel} (was ${prevStatus ?? 'unknown'})`;
return broadcastNotification(result.appId, event, message);
})
.catch((err) => {
console.warn('[healthcheck] notification dispatch failed:', err);
});
}
} catch {
// Swallow errors to prevent scheduler crash
previousStatuses.set(result.appId, result.status);
}
} catch (err) {
console.warn('[healthcheck] tick failed:', err);
} finally {
healthcheckInFlight = false;
}
}
async function pruneOrphanedStatuses(): Promise<void> {
// Remove `previousStatuses` entries for apps that no longer have healthcheck
// enabled or that were deleted, so the map stays bounded.
const active = await prisma.app.findMany({
where: { healthcheckEnabled: true },
select: { id: true }
});
const activeIds = new Set(active.map((a) => a.id));
for (const key of previousStatuses.keys()) {
if (!activeIds.has(key)) previousStatuses.delete(key);
}
}
async function pruneAppClicks(): Promise<void> {
const cutoff = new Date(
Date.now() - DEFAULTS.APP_CLICK_RETENTION_DAYS * 24 * 60 * 60 * 1000
);
await prisma.appClick.deleteMany({ where: { clickedAt: { lt: cutoff } } });
}
async function pruneNotifications(): Promise<void> {
const cutoff = new Date(
Date.now() - DEFAULTS.NOTIFICATION_RETENTION_DAYS * 24 * 60 * 60 * 1000
);
// Only delete READ notifications past retention; unread stay around.
await prisma.notification.deleteMany({
where: { readAt: { not: null }, sentAt: { lt: cutoff } }
});
}
/**
* Start the healthcheck + retention schedulers. Idempotent.
* Honours RUN_SCHEDULERS env var so horizontally-scaled setups can disable
* cron in non-leader nodes.
*/
export function startScheduler(cronExpression: string = '* * * * *'): void {
if (process.env.RUN_SCHEDULERS === 'false') return;
if (schedulers.scheduled) return;
schedulers.scheduled = cron.schedule(cronExpression, () => {
runHealthcheckTick().catch((err) => {
console.warn('[healthcheck] runHealthcheckTick rejected:', err);
});
});
// Cleanup job: run every hour at minute 0
cleanupTask = cron.schedule('0 * * * *', async () => {
// Hourly retention sweep.
schedulers.cleanup = cron.schedule('0 * * * *', async () => {
try {
await pruneOldStatuses();
} catch {
// Swallow errors to prevent scheduler crash
await pruneOrphanedStatuses();
} catch (err) {
console.warn('[healthcheck] pruneOldStatuses failed:', err);
}
});
// Audit log pruning: run daily at midnight
auditPruneTask = cron.schedule('0 0 * * *', async () => {
// Daily AppClick / Notification cleanup at 02:00.
schedulers.retention = cron.schedule('0 2 * * *', async () => {
try {
await pruneOldLogs(90); // Default 90 day retention
} catch {
// Swallow errors to prevent scheduler crash
await pruneAppClicks();
await pruneNotifications();
} catch (err) {
console.warn('[healthcheck] daily retention failed:', err);
}
});
// Run an initial check shortly after startup
// Daily audit log pruning at midnight.
schedulers.auditPrune = cron.schedule('0 0 * * *', async () => {
try {
await pruneOldLogs(DEFAULTS.AUDIT_LOG_RETENTION_DAYS);
} catch (err) {
console.warn('[healthcheck] pruneOldLogs failed:', err);
}
});
// Run an initial check shortly after startup.
setTimeout(() => {
checkAllApps().catch(() => {
// Swallow initial check errors
runHealthcheckTick().catch(() => {
// already logged inside the tick
});
}, 5000);
}
/**
* Stop the healthcheck scheduler and cleanup job.
*/
export function stopScheduler(): void {
if (scheduledTask) {
scheduledTask.stop();
scheduledTask = null;
if (schedulers.scheduled) {
schedulers.scheduled.stop();
schedulers.scheduled = null;
}
if (cleanupTask) {
cleanupTask.stop();
cleanupTask = null;
if (schedulers.cleanup) {
schedulers.cleanup.stop();
schedulers.cleanup = null;
}
if (auditPruneTask) {
auditPruneTask.stop();
auditPruneTask = null;
if (schedulers.auditPrune) {
schedulers.auditPrune.stop();
schedulers.auditPrune = null;
}
if (schedulers.retention) {
schedulers.retention.stop();
schedulers.retention = null;
}
}
@@ -0,0 +1,76 @@
import { prisma } from '$lib/server/prisma.js';
import { checkPermission } from '$lib/server/services/permissionService.js';
import { EntityType, PermissionLevel, UserRole } from '$lib/utils/constants.js';
import { NotFoundError, PermissionError } from '$lib/server/errors.js';
interface AuthUser {
readonly id: string;
readonly role: 'admin' | 'user';
}
/**
* Ensure the user can edit (or delete) an App. Admins always pass. Otherwise
* the user must be the creator OR hold an EDIT-or-higher permission on the App.
*/
export async function requireAppEdit(user: AuthUser, appId: string): Promise<void> {
if (user.role === UserRole.ADMIN) return;
const app = await prisma.app.findUnique({
where: { id: appId },
select: { createdById: true }
});
if (!app) throw new NotFoundError(`App not found: ${appId}`);
if (app.createdById && app.createdById === user.id) return;
const result = await checkPermission(EntityType.APP, appId, user.id, PermissionLevel.EDIT);
if (!result.hasPermission) {
throw new PermissionError('You do not have permission to modify this app');
}
}
/**
* Ensure the user can edit a Board (and by extension its sections/widgets).
*/
export async function requireBoardEdit(user: AuthUser, boardId: string): Promise<void> {
if (user.role === UserRole.ADMIN) return;
const board = await prisma.board.findUnique({
where: { id: boardId },
select: { createdById: true }
});
if (!board) throw new NotFoundError(`Board not found: ${boardId}`);
if (board.createdById && board.createdById === user.id) return;
const result = await checkPermission(EntityType.BOARD, boardId, user.id, PermissionLevel.EDIT);
if (!result.hasPermission) {
throw new PermissionError('You do not have permission to modify this board');
}
}
/**
* Resolve a section -> board id, then check board edit permission.
*/
export async function requireSectionEdit(user: AuthUser, sectionId: string): Promise<string> {
const section = await prisma.section.findUnique({
where: { id: sectionId },
select: { boardId: true }
});
if (!section) throw new NotFoundError(`Section not found: ${sectionId}`);
await requireBoardEdit(user, section.boardId);
return section.boardId;
}
/**
* Resolve a widget -> section -> board id, then check board edit permission.
*/
export async function requireWidgetEdit(user: AuthUser, widgetId: string): Promise<string> {
const widget = await prisma.widget.findUnique({
where: { id: widgetId },
select: { section: { select: { boardId: true } } }
});
if (!widget) throw new NotFoundError(`Widget not found: ${widgetId}`);
await requireBoardEdit(user, widget.section.boardId);
return widget.section.boardId;
}
+50 -4
View File
@@ -1,14 +1,60 @@
import { PrismaClient } from '@prisma/client';
import { validateServerEnv } from './utils/env.js';
// Validate env on first import — surfaces misconfigured deployments at boot
// rather than at first authenticated request.
validateServerEnv();
const globalForPrisma = globalThis as unknown as { prisma: PrismaClient };
export const prisma = globalForPrisma.prisma || new PrismaClient();
export const prisma =
globalForPrisma.prisma ||
new PrismaClient({
log: process.env.NODE_ENV === 'production' ? ['error', 'warn'] : ['warn', 'error']
});
if (process.env.NODE_ENV !== 'production') {
globalForPrisma.prisma = prisma;
}
let pragmasApplied = false;
/**
* Apply SQLite tuning pragmas. Must run AFTER $connect. Idempotent.
* - journal_mode=WAL: enables concurrent reads with one writer (vs default
* rollback journal which locks the whole file for every write).
* - busy_timeout=5000: makes Prisma queries wait for the lock instead of
* immediately throwing SQLITE_BUSY when the cron + user write at the same time.
* - synchronous=NORMAL: safe with WAL, ~2x faster than FULL.
* - foreign_keys=ON: SQLite default is OFF; required for Prisma onDelete.
*/
async function applySqlitePragmas(): Promise<void> {
if (pragmasApplied) return;
try {
await prisma.$executeRawUnsafe('PRAGMA journal_mode = WAL');
await prisma.$executeRawUnsafe('PRAGMA busy_timeout = 5000');
await prisma.$executeRawUnsafe('PRAGMA synchronous = NORMAL');
await prisma.$executeRawUnsafe('PRAGMA foreign_keys = ON');
pragmasApplied = true;
} catch (err) {
console.warn('[prisma] Failed to apply SQLite pragmas:', err);
}
}
// Eagerly connect so the first request doesn't pay the connection cost
prisma.$connect().catch(() => {
// Connection will be retried lazily on first query
});
prisma
.$connect()
.then(() => applySqlitePragmas())
.catch(() => {
// Connection will be retried lazily on first query
});
/**
* Re-apply pragmas after a reconnection (e.g. after backup restore which
* does $disconnect + restart).
*/
export async function reapplySqlitePragmas(): Promise<void> {
pragmasApplied = false;
await applySqlitePragmas();
}
@@ -13,8 +13,9 @@ vi.mock('../../prisma.js', () => ({
}
}));
// Set JWT_SECRET for tests
process.env.JWT_SECRET = 'test-secret-key-for-unit-tests';
// Set JWT_SECRET for tests — must be ≥ 32 chars and not a known placeholder
// (enforced by getJwtSecret in authService).
process.env.JWT_SECRET = 'test-secret-key-for-unit-tests-must-be-at-least-32-chars-long';
import {
hashPassword,
@@ -74,11 +75,13 @@ describe('authService', () => {
});
describe('generateRefreshToken', () => {
it('generates a hex string', () => {
it('generates a prefixed hex string', () => {
const token = generateRefreshToken();
expect(typeof token).toBe('string');
expect(token.length).toBe(96); // 48 bytes * 2 hex chars
expect(/^[0-9a-f]+$/.test(token)).toBe(true);
// "rt_" prefix (3) + 48 bytes * 2 hex chars (96) = 99
expect(token.length).toBe(99);
expect(token.startsWith('rt_')).toBe(true);
expect(/^rt_[0-9a-f]+$/.test(token)).toBe(true);
});
it('generates unique tokens', () => {
@@ -106,7 +109,9 @@ describe('authService', () => {
const result = await createSession('usr-1', { userAgent: 'ua', ipAddress: '127.0.0.1' });
expect(result.sessionId).toBe('ses-1');
expect(result.refreshToken.length).toBe(96);
// Tokens are now prefixed with "rt_" (3 chars) + 96 hex chars = 99
expect(result.refreshToken.length).toBe(99);
expect(result.refreshToken.startsWith('rt_')).toBe(true);
expect(result.expiresAt.getTime()).toBeGreaterThan(Date.now());
expect(prisma.session.create).toHaveBeenCalledTimes(1);
});
@@ -168,7 +173,9 @@ describe('authService', () => {
const result = await rotateSession('ses-1');
expect(result.sessionId).toBe('ses-1');
expect(result.refreshToken.length).toBe(96);
// Tokens are now prefixed with "rt_" (3 chars) + 96 hex chars = 99
expect(result.refreshToken.length).toBe(99);
expect(result.refreshToken.startsWith('rt_')).toBe(true);
expect(prisma.session.update).toHaveBeenCalledTimes(1);
});
});
@@ -103,6 +103,27 @@ describe('discoveryService', () => {
});
describe('discoverTraefik', () => {
// Build a Response-like object compatible with both raw fetch tests and
// the SafeResponse wrapper used by safeFetch.
function mockResponse(body: unknown, ok: boolean, status: number) {
const bodyString = JSON.stringify(body);
const bodyBytes = new TextEncoder().encode(bodyString);
const stream = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(bodyBytes);
controller.close();
}
});
return {
ok,
status,
headers: new Headers({ 'content-type': 'application/json' }),
body: stream,
json: () => Promise.resolve(body),
text: () => Promise.resolve(bodyString)
};
}
it('returns services from Traefik routers', async () => {
const routers = [
{
@@ -123,15 +144,9 @@ describe('discoveryService', () => {
'fetch',
vi.fn((url: string) => {
if (url.includes('/api/http/routers')) {
return Promise.resolve({
ok: true,
json: () => Promise.resolve(routers)
});
return Promise.resolve(mockResponse(routers, true, 200));
}
return Promise.resolve({
ok: true,
json: () => Promise.resolve(services)
});
return Promise.resolve(mockResponse(services, true, 200));
})
);
@@ -148,7 +163,7 @@ describe('discoveryService', () => {
it('returns error on Traefik API failure', async () => {
vi.stubGlobal(
'fetch',
vi.fn(() => Promise.resolve({ ok: false, status: 500 }))
vi.fn(() => Promise.resolve(mockResponse({}, false, 500)))
);
const result = await discoverTraefik('http://traefik.local:8080');
+46 -50
View File
@@ -1,20 +1,29 @@
import { randomBytes, createHash } from 'crypto';
import bcrypt from 'bcryptjs';
import { randomBytes, createHash, timingSafeEqual } from 'crypto';
import { prisma } from '../prisma.js';
const BCRYPT_ROUNDS = 10;
/**
* Hash a token string using SHA-256 for fast lookup, then bcrypt for storage.
* We use SHA-256 as an intermediate to create a fixed-length input for bcrypt
* (bcrypt has a 72-byte limit).
* API tokens are 256-bit cryptographically random (32 bytes hex). They carry
* full entropy, so we don't need bcrypt's expensive KDF SHA-256 is sufficient
* and gives us O(1) indexed lookup via the `tokenHash @unique` column.
*
* Storage shape: `tokenHash` = sha256(plaintextToken).
* On lookup we compute the same hash and do a single `findUnique`.
*/
function sha256(token: string): string {
function sha256Hex(token: string): string {
return createHash('sha256').update(token).digest('hex');
}
function constantTimeEqual(a: string, b: string): boolean {
if (a.length !== b.length) return false;
const aBuf = Buffer.from(a);
const bBuf = Buffer.from(b);
return timingSafeEqual(aBuf, bBuf);
}
/**
* Generate a new API token. Returns the plaintext token (shown once) and the DB record.
* Generate a new API token. Returns the plaintext token (shown once) and the
* DB record. We embed a short, non-secret prefix so the user can identify
* which token a leaked log line refers to without having to reveal the token.
*/
export async function generateToken(
userId: string,
@@ -22,8 +31,8 @@ export async function generateToken(
scope: string,
expiresAt?: string
) {
const plainToken = randomBytes(32).toString('hex');
const tokenHash = await bcrypt.hash(sha256(plainToken), BCRYPT_ROUNDS);
const plainToken = `wal_${randomBytes(32).toString('hex')}`;
const tokenHash = sha256Hex(plainToken);
const token = await prisma.apiToken.create({
data: {
@@ -41,27 +50,20 @@ export async function generateToken(
scope: token.scope,
expiresAt: token.expiresAt,
createdAt: token.createdAt,
token: plainToken // Only returned once at creation time
token: plainToken
};
}
/**
* Revoke (delete) an API token.
*/
export async function revokeToken(tokenId: string, userId: string) {
const token = await prisma.apiToken.findUnique({ where: { id: tokenId } });
if (!token || token.userId !== userId) {
throw new Error('API token not found');
}
await prisma.apiToken.delete({ where: { id: tokenId } });
}
/**
* List all tokens for a user (without the hash).
*/
export async function listTokens(userId: string) {
const tokens = await prisma.apiToken.findMany({
return prisma.apiToken.findMany({
where: { userId },
select: {
id: true,
@@ -73,22 +75,22 @@ export async function listTokens(userId: string) {
},
orderBy: { createdAt: 'desc' }
});
return tokens;
}
/**
* Validate a plaintext token string. Returns the user info if valid, null otherwise.
* Updates lastUsedAt on successful validation.
* Validate a plaintext token. O(1) lookup via the unique indexed hash column.
* Returns null if the token doesn't exist or is expired.
*/
export async function validateToken(tokenString: string): Promise<{
readonly userId: string;
readonly scope: string;
} | null> {
const tokenSha = sha256(tokenString);
if (typeof tokenString !== 'string' || tokenString.length === 0) return null;
// We need to check against all tokens since bcrypt hashes are unique per-hash.
// For better performance at scale, consider indexing on a prefix or using a different scheme.
const allTokens = await prisma.apiToken.findMany({
const tokenHash = sha256Hex(tokenString);
const token = await prisma.apiToken.findUnique({
where: { tokenHash },
select: {
id: true,
userId: true,
@@ -98,30 +100,24 @@ export async function validateToken(tokenString: string): Promise<{
}
});
for (const token of allTokens) {
const isMatch = await bcrypt.compare(tokenSha, token.tokenHash);
if (isMatch) {
// Check expiry
if (token.expiresAt && token.expiresAt < new Date()) {
return null; // Token expired
}
if (!token) return null;
// Update lastUsedAt (fire-and-forget)
prisma.apiToken
.update({
where: { id: token.id },
data: { lastUsedAt: new Date() }
})
.catch(() => {
// Swallow errors from lastUsedAt update
});
// Defense-in-depth: even though findUnique only returns the matching row,
// we still compare in constant time to harden against pathological cases
// (collisions, race conditions, future refactors).
if (!constantTimeEqual(token.tokenHash, tokenHash)) return null;
return {
userId: token.userId,
scope: token.scope
};
}
}
if (token.expiresAt && token.expiresAt < new Date()) return null;
return null;
prisma.apiToken
.update({
where: { id: token.id },
data: { lastUsedAt: new Date() }
})
.catch((err) => {
console.warn('[api-token] lastUsedAt update failed:', err);
});
return { userId: token.userId, scope: token.scope };
}
+8 -2
View File
@@ -21,8 +21,14 @@ export function logAction(
details: details ? JSON.stringify(details) : '{}'
}
})
.catch(() => {
// Non-blocking: swallow errors so the parent operation is unaffected
.catch((err) => {
// Non-blocking but observable: log to stderr so a failing audit trail
// (e.g. DB full, locked) is visible in container logs.
console.warn(
`[audit] failed to record ${action} ${entityType}:${entityId}:`,
err
);
});
}
+61 -17
View File
@@ -1,16 +1,38 @@
import bcrypt from 'bcryptjs';
import jwt from 'jsonwebtoken';
import { createHash, randomBytes, timingSafeEqual } from 'crypto';
import { prisma } from '../prisma.js';
import { DEFAULTS } from '$lib/utils/constants.js';
import { DEFAULTS, FORBIDDEN_SECRETS, MIN_SECRET_LENGTH } from '$lib/utils/constants.js';
import type { JwtPayload } from '$lib/types/auth.js';
const SALT_ROUNDS = 12;
const SALT_ROUNDS = DEFAULTS.SALT_ROUNDS;
function sha256Hex(value: string): string {
return createHash('sha256').update(value).digest('hex');
}
function constantTimeEqual(a: string, b: string): boolean {
if (a.length !== b.length) return false;
return timingSafeEqual(Buffer.from(a), Buffer.from(b));
}
function getJwtSecret(): string {
const secret = process.env.JWT_SECRET;
if (!secret) {
throw new Error('JWT_SECRET environment variable is not set');
}
if (FORBIDDEN_SECRETS.has(secret.trim().toLowerCase())) {
throw new Error(
'JWT_SECRET is set to a forbidden placeholder value. ' +
'Generate a strong secret with: openssl rand -hex 32'
);
}
if (secret.length < MIN_SECRET_LENGTH) {
throw new Error(
`JWT_SECRET must be at least ${MIN_SECRET_LENGTH} characters long ` +
'(generate with: openssl rand -hex 32)'
);
}
return secret;
}
@@ -46,9 +68,7 @@ export function verifyAccessToken(token: string): JwtPayload {
}
export function generateRefreshToken(): string {
const bytes = new Uint8Array(48);
crypto.getRandomValues(bytes);
return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
return `rt_${randomBytes(48).toString('hex')}`;
}
export interface SessionMetadata {
@@ -64,8 +84,8 @@ export interface IssuedSession {
readonly expiresAt: Date;
}
const DEFAULT_SESSION_TTL_DAYS = 7;
const REMEMBER_ME_SESSION_TTL_DAYS = 30;
const DEFAULT_SESSION_TTL_DAYS = DEFAULTS.REFRESH_TOKEN_EXPIRY_DAYS;
const REMEMBER_ME_SESSION_TTL_DAYS = DEFAULTS.REMEMBER_ME_REFRESH_TOKEN_EXPIRY_DAYS;
function sessionExpiry(rememberMe: boolean): Date {
const days = rememberMe ? REMEMBER_ME_SESSION_TTL_DAYS : DEFAULT_SESSION_TTL_DAYS;
@@ -76,20 +96,20 @@ function sessionExpiry(rememberMe: boolean): Date {
/**
* Create a new session row and return the session id + raw refresh token.
* The raw token is returned once only the hash is stored.
*/
export async function createSession(
userId: string,
meta: SessionMetadata = {}
): Promise<IssuedSession> {
const refreshToken = generateRefreshToken();
const tokenHash = await bcrypt.hash(refreshToken, SALT_ROUNDS);
const tokenHash = sha256Hex(refreshToken);
const expiresAt = sessionExpiry(meta.rememberMe ?? false);
const session = await prisma.session.create({
data: {
userId,
tokenHash,
previousTokenHash: null,
label: meta.label ?? null,
userAgent: meta.userAgent ?? null,
ipAddress: meta.ipAddress ?? null,
@@ -102,35 +122,59 @@ export async function createSession(
}
/**
* Validate a session id + refresh token. Returns the session row if valid, else null.
* Does NOT mutate the session.
* Validate a session id + refresh token. O(1) via unique(tokenHash) index.
*
* Refresh-token reuse detection: if the supplied token matches `previousTokenHash`
* (the token we rotated AWAY from), treat it as compromise revoke the
* session and return null. This catches the scenario where an attacker has
* captured the old refresh token cookie pair: when they replay it after the
* legitimate user has rotated, we notice and kick them out.
*/
export async function validateSession(sessionId: string, refreshToken: string) {
const session = await prisma.session.findUnique({ where: { id: sessionId } });
if (!session) return null;
if (new Date() > session.expiresAt) return null;
const matches = await bcrypt.compare(refreshToken, session.tokenHash);
if (!matches) return null;
const tokenHash = sha256Hex(refreshToken);
if (
session.previousTokenHash &&
constantTimeEqual(session.previousTokenHash, tokenHash)
) {
// Refresh-token reuse — possible compromise. Revoke the session.
await prisma.session.delete({ where: { id: sessionId } }).catch(() => {});
console.warn(
`[auth] refresh-token reuse detected on session ${sessionId} — revoking`
);
return null;
}
if (!constantTimeEqual(session.tokenHash, tokenHash)) return null;
return session;
}
/**
* Rotate a session's refresh token. Keeps the same session id (so the sessions
* page shows a stable row). Refreshes expiry based on the session's rememberMe.
* Rotate a session's refresh token. Keeps the same session id and records the
* previous hash so a replay can be detected (see validateSession).
*/
export async function rotateSession(sessionId: string): Promise<IssuedSession> {
const existing = await prisma.session.findUnique({ where: { id: sessionId } });
if (!existing) throw new Error('Session not found');
const refreshToken = generateRefreshToken();
const tokenHash = await bcrypt.hash(refreshToken, SALT_ROUNDS);
const tokenHash = sha256Hex(refreshToken);
const expiresAt = sessionExpiry(existing.rememberMe);
await prisma.session.update({
where: { id: sessionId },
data: { tokenHash, expiresAt, lastUsedAt: new Date() }
data: {
tokenHash,
previousTokenHash: existing.tokenHash,
expiresAt,
lastUsedAt: new Date()
}
});
return { sessionId, refreshToken, expiresAt };
+87 -62
View File
@@ -1,16 +1,17 @@
import { prisma } from '../prisma.js';
import { prisma, reapplySqlitePragmas } from '../prisma.js';
import { DEFAULTS } from '$lib/utils/constants.js';
import fs from 'node:fs';
import path from 'node:path';
const BACKUP_DIR = path.resolve('data', 'backups');
// SQLite file format magic: "SQLite format 3\0"
const SQLITE_MAGIC = Buffer.from([
0x53, 0x51, 0x4c, 0x69, 0x74, 0x65, 0x20, 0x66, 0x6f, 0x72, 0x6d, 0x61, 0x74, 0x20, 0x33, 0x00
]);
let _restoring = false;
/**
* Check if a database restore is currently in progress.
* Other services can check this to avoid querying during restore.
*/
export function isRestoring(): boolean {
return _restoring;
}
@@ -29,15 +30,30 @@ function ensureBackupDir(): void {
function getDatabasePath(): string {
const url = process.env.DATABASE_URL ?? 'file:../data/launcher.db';
// Strip the "file:" prefix and resolve relative to prisma/ directory
const relative = url.replace(/^file:/, '');
return path.resolve('prisma', relative);
}
/**
* Create a backup of the SQLite database using VACUUM INTO.
* This produces a clean, compacted copy without locking the live DB.
* Validate that the file at `filePath` is a SQLite database by checking the
* 16-byte magic header. Without this, a 0-byte or text file silently
* overwrites the live DB during restore.
*/
function isSqliteFile(filePath: string): boolean {
let fd: number | null = null;
try {
fd = fs.openSync(filePath, 'r');
const buf = Buffer.alloc(16);
const bytesRead = fs.readSync(fd, buf, 0, 16, 0);
if (bytesRead < 16) return false;
return buf.equals(SQLITE_MAGIC);
} catch {
return false;
} finally {
if (fd !== null) fs.closeSync(fd);
}
}
export async function createBackup(): Promise<BackupInfo> {
ensureBackupDir();
@@ -45,13 +61,10 @@ export async function createBackup(): Promise<BackupInfo> {
const filename = `backup-${timestamp}.db`;
const backupPath = path.join(BACKUP_DIR, filename);
// Use VACUUM INTO for a safe, consistent copy
// Escape single quotes for defense-in-depth (path is server-generated, not user input)
const safePath = backupPath.replace(/\\/g, '/').replace(/'/g, "''");
await prisma.$executeRawUnsafe(`VACUUM INTO '${safePath}'`);
const stats = fs.statSync(backupPath);
return {
filename,
size: stats.size,
@@ -59,14 +72,9 @@ export async function createBackup(): Promise<BackupInfo> {
};
}
/**
* List all existing backups, sorted newest first.
*/
export function listBackups(): ReadonlyArray<BackupInfo> {
ensureBackupDir();
const files = fs.readdirSync(BACKUP_DIR).filter((f) => f.endsWith('.db'));
return files
.map((filename) => {
const stats = fs.statSync(path.join(BACKUP_DIR, filename));
@@ -79,87 +87,107 @@ export function listBackups(): ReadonlyArray<BackupInfo> {
.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
}
/**
* Get the absolute path to a backup file. Returns null if not found.
*/
export function getBackupFilePath(filename: string): string | null {
// Sanitize: prevent directory traversal
const sanitized = path.basename(filename);
if (sanitized !== filename) return null; // path traversal attempt
if (!/^[\w.-]+\.db$/.test(sanitized)) return null;
const fullPath = path.join(BACKUP_DIR, sanitized);
if (!fs.existsSync(fullPath)) {
return null;
}
if (!fs.existsSync(fullPath)) return null;
return fullPath;
}
/**
* Delete a backup file. Returns true if deleted, false if not found.
*/
export function deleteBackup(filename: string): boolean {
const fullPath = getBackupFilePath(filename);
if (!fullPath) {
return false;
}
if (!fullPath) return false;
fs.unlinkSync(fullPath);
return true;
}
/**
* Restore the database from a backup file.
* Sets a restoring flag, disconnects Prisma, replaces the DB file, then reconnects.
*
* Hardened:
* 1. Verify the source file's SQLite magic header before touching the live DB.
* 2. Set _restoring = true BEFORE $disconnect (TOCTOU narrowing).
* 3. Take a safety snapshot of the current DB so a partial copy can be rolled back.
* 4. Use rename (atomic on same fs) instead of copyFileSync where possible.
* 5. Reapply SQLite pragmas after reconnect.
*/
export async function restoreBackup(filename: string): Promise<void> {
const backupPath = getBackupFilePath(filename);
if (!backupPath) {
throw new Error(`Backup not found: ${filename}`);
}
if (_restoring) {
throw new Error('A restore is already in progress');
}
const dbPath = getDatabasePath();
_restoring = true;
// Disconnect Prisma so the DB file is not locked
await prisma.$disconnect();
const backupPath = getBackupFilePath(filename);
if (!backupPath) {
_restoring = false;
throw new Error(`Backup not found: ${filename}`);
}
if (!isSqliteFile(backupPath)) {
_restoring = false;
throw new Error(`File is not a valid SQLite database: ${filename}`);
}
const dbPath = getDatabasePath();
const safetyPath = `${dbPath}.pre-restore-${Date.now()}.bak`;
try {
// Replace the live database with the backup
fs.copyFileSync(backupPath, dbPath);
} finally {
// Always reconnect, even if copy fails
// 1. Snapshot the live DB so we can roll back on failure.
if (fs.existsSync(dbPath)) {
fs.copyFileSync(dbPath, safetyPath);
}
// 2. Disconnect Prisma so the DB file is not locked.
await prisma.$disconnect();
// 3. Copy backup → temp, then rename atomically over the live DB.
const stagingPath = `${dbPath}.restore.tmp`;
fs.copyFileSync(backupPath, stagingPath);
fs.renameSync(stagingPath, dbPath);
// 4. Reconnect + re-apply pragmas (WAL etc.)
await prisma.$connect();
await reapplySqlitePragmas();
// Cleanup safety snapshot on success.
if (fs.existsSync(safetyPath)) {
try {
fs.unlinkSync(safetyPath);
} catch {
// non-fatal
}
}
} catch (err) {
// Roll back from safety snapshot.
try {
if (fs.existsSync(safetyPath)) {
fs.copyFileSync(safetyPath, dbPath);
fs.unlinkSync(safetyPath);
}
await prisma.$connect();
await reapplySqlitePragmas();
} catch {
// Best-effort recovery.
}
throw err;
} finally {
_restoring = false;
}
}
/**
* Enforce retention policy: delete oldest backups beyond maxCount.
*/
export function enforceRetention(maxCount: number): number {
const backups = listBackups();
if (backups.length <= maxCount) return 0;
if (backups.length <= maxCount) {
return 0;
}
// Backups are sorted newest-first; remove from the end
const toDelete = backups.slice(maxCount);
for (const backup of toDelete) {
deleteBackup(backup.filename);
}
return toDelete.length;
}
/**
* Load backup settings from SystemSettings.
*/
export async function getBackupSettings(): Promise<{
readonly backupEnabled: boolean;
readonly backupCronExpression: string;
@@ -178,9 +206,6 @@ export async function getBackupSettings(): Promise<{
};
}
/**
* Update backup settings in SystemSettings.
*/
export async function updateBackupSettings(data: {
readonly backupEnabled?: boolean;
readonly backupCronExpression?: string;
+28 -56
View File
@@ -7,6 +7,7 @@ import type {
} from '$lib/types/board.js';
import type { CreateWidgetInput, UpdateWidgetInput } from '$lib/types/widget.js';
import { WidgetType } from '$lib/utils/constants.js';
import { sanitizeCss } from '$lib/utils/cssSanitize.js';
import {
appWidgetConfigSchema,
bookmarkWidgetConfigSchema,
@@ -81,29 +82,36 @@ export async function findAllBoards() {
});
}
export async function findBoardById(id: string) {
const board = await prisma.board.findUnique({
where: { id },
// Single source of truth for the board → sections → widgets → app include shape.
// Includes app.links and app.appTags so AppWidget.svelte can render tags/links
// without a follow-up fetch.
const BOARD_INCLUDE = {
sections: {
orderBy: { order: 'asc' as const },
include: {
sections: {
orderBy: { order: 'asc' },
widgets: {
orderBy: { order: 'asc' as const },
include: {
widgets: {
orderBy: { order: 'asc' },
app: {
include: {
app: {
include: {
statuses: {
orderBy: { checkedAt: 'desc' },
take: 1
}
}
}
statuses: {
orderBy: { checkedAt: 'desc' as const },
take: 1
},
links: { orderBy: { order: 'asc' as const } },
appTags: { include: { tag: true } }
}
}
}
}
}
}
} as const;
export async function findBoardById(id: string) {
const board = await prisma.board.findUnique({
where: { id },
include: BOARD_INCLUDE
});
if (!board) {
throw new Error(`Board not found: ${id}`);
@@ -114,26 +122,7 @@ export async function findBoardById(id: string) {
export async function findDefaultBoard() {
return prisma.board.findFirst({
where: { isDefault: true },
include: {
sections: {
orderBy: { order: 'asc' },
include: {
widgets: {
orderBy: { order: 'asc' },
include: {
app: {
include: {
statuses: {
orderBy: { checkedAt: 'desc' },
take: 1
}
}
}
}
}
}
}
}
include: BOARD_INCLUDE
});
}
@@ -141,26 +130,7 @@ export async function findGuestAccessibleBoards() {
return prisma.board.findMany({
where: { isGuestAccessible: true },
orderBy: { createdAt: 'asc' },
include: {
sections: {
orderBy: { order: 'asc' },
include: {
widgets: {
orderBy: { order: 'asc' },
include: {
app: {
include: {
statuses: {
orderBy: { checkedAt: 'desc' },
take: 1
}
}
}
}
}
}
}
}
include: BOARD_INCLUDE
});
}
@@ -210,7 +180,9 @@ export async function updateBoard(id: string, input: UpdateBoardInput) {
if (input.wallpaperUrl !== undefined) data.wallpaperUrl = input.wallpaperUrl;
if (input.wallpaperBlur !== undefined) data.wallpaperBlur = input.wallpaperBlur;
if (input.wallpaperOverlay !== undefined) data.wallpaperOverlay = input.wallpaperOverlay;
if (input.customCss !== undefined) data.customCss = input.customCss;
if (input.customCss !== undefined) {
data.customCss = input.customCss === null ? null : sanitizeCss(input.customCss);
}
return prisma.board.update({
where: { id },
+3 -7
View File
@@ -2,6 +2,7 @@
* Calendar service fetches and parses iCal (.ics) files.
* Uses lightweight hand-parsing of VEVENT blocks (no heavy dependencies).
*/
import { safeFetch } from '$lib/server/utils/safeFetch.js';
const CACHE_TTL_MS = 30 * 60 * 1000; // 30 minutes
const FETCH_TIMEOUT_MS = 10_000;
@@ -157,12 +158,9 @@ async function fetchIcalText(url: string): Promise<string> {
const cached = getCached(url);
if (cached) return cached;
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
try {
const response = await fetch(url, {
signal: controller.signal,
const response = await safeFetch(url, {
timeoutMs: FETCH_TIMEOUT_MS,
headers: {
'User-Agent': 'WebAppLauncher/1.0',
Accept: 'text/calendar, application/ics'
@@ -181,8 +179,6 @@ async function fetchIcalText(url: string): Promise<string> {
throw new Error('Calendar request timed out');
}
throw err;
} finally {
clearTimeout(timeoutId);
}
}
+33 -74
View File
@@ -1,81 +1,46 @@
/**
* Camera/Stream proxy service proxies image requests to camera URLs.
* Includes SSRF protection to reject private IP ranges.
* Delegates SSRF protection to safeFetch (DNS-resolved private-range check,
* IPv6-mapped IPv4 coverage, decimal-IP coverage). Adds per-URL rate limiting
* so a viewer cannot hammer a fragile camera.
*/
import { safeFetch, assertSafeUrl } from '$lib/server/utils/safeFetch.js';
import { DEFAULTS } from '$lib/utils/constants.js';
const FETCH_TIMEOUT_MS = 10_000;
const RATE_LIMIT_INTERVAL_MS = 5_000; // Max 1 request per 5s per URL
const RATE_LIMIT_INTERVAL_MS = 5_000;
const MAX_SNAPSHOT_BYTES = 5 * 1024 * 1024;
const RATE_LIMIT_TRACKER_MAX = 5_000;
const lastFetchTimes = new Map<string, number>();
/**
* Check if a hostname resolves to a private/reserved IP range.
* Prevents SSRF attacks by blocking requests to internal networks.
*/
function isPrivateOrReservedHost(hostname: string): boolean {
// Block obvious private hostnames
if (
hostname === 'localhost' ||
hostname === '127.0.0.1' ||
hostname === '::1' ||
hostname === '0.0.0.0'
) {
return true;
function pruneRateLimitTracker(): void {
const cutoff = Date.now() - RATE_LIMIT_INTERVAL_MS * 10;
for (const [url, ts] of lastFetchTimes) {
if (ts < cutoff) lastFetchTimes.delete(url);
}
// Check IPv4 private ranges
const ipv4Match = hostname.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
if (ipv4Match) {
const [, a, b] = ipv4Match.map(Number);
// 10.0.0.0/8
if (a === 10) return true;
// 172.16.0.0/12
if (a === 172 && b >= 16 && b <= 31) return true;
// 192.168.0.0/16
if (a === 192 && b === 168) return true;
// 127.0.0.0/8
if (a === 127) return true;
// 169.254.0.0/16 (link-local)
if (a === 169 && b === 254) return true;
// 0.0.0.0/8
if (a === 0) return true;
while (lastFetchTimes.size > RATE_LIMIT_TRACKER_MAX) {
const first = lastFetchTimes.keys().next().value;
if (first === undefined) break;
lastFetchTimes.delete(first);
}
// Block IPv6 private ranges (simplified check)
if (hostname.startsWith('fe80:') || hostname.startsWith('fc') || hostname.startsWith('fd')) {
return true;
}
return false;
}
/**
* Validate a URL for camera proxying.
* Only allows http/https and rejects private IPs.
* Validate a URL for camera proxying. Returns the same shape as before for
* back-compat with the proxy endpoint.
*/
export function validateStreamUrl(urlStr: string): { valid: boolean; error?: string } {
let parsed: URL;
export async function validateStreamUrl(
urlStr: string
): Promise<{ valid: boolean; error?: string }> {
try {
parsed = new URL(urlStr);
} catch {
return { valid: false, error: 'Invalid URL format' };
await assertSafeUrl(urlStr);
return { valid: true };
} catch (err) {
return { valid: false, error: err instanceof Error ? err.message : 'Invalid URL' };
}
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
return { valid: false, error: 'Only http and https protocols are allowed' };
}
if (isPrivateOrReservedHost(parsed.hostname)) {
return { valid: false, error: 'Requests to private/reserved IP ranges are not allowed' };
}
return { valid: true };
}
/**
* Check rate limit for a given URL.
*/
function checkRateLimit(url: string): boolean {
const lastFetch = lastFetchTimes.get(url);
if (!lastFetch) return true;
@@ -84,6 +49,7 @@ function checkRateLimit(url: string): boolean {
function recordFetch(url: string): void {
lastFetchTimes.set(url, Date.now());
if (lastFetchTimes.size > RATE_LIMIT_TRACKER_MAX / 2) pruneRateLimitTracker();
}
export interface CameraSnapshot {
@@ -93,28 +59,23 @@ export interface CameraSnapshot {
/**
* Fetch a snapshot image from a camera URL.
* Proxies the HTTP request and returns the image buffer.
*/
export async function fetchSnapshot(url: string): Promise<CameraSnapshot> {
// Validate URL
const validation = validateStreamUrl(url);
const validation = await validateStreamUrl(url);
if (!validation.valid) {
throw new Error(validation.error ?? 'Invalid stream URL');
}
// Rate limit check
if (!checkRateLimit(url)) {
throw new Error('Rate limited: please wait before requesting this camera again');
}
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
try {
recordFetch(url);
const response = await fetch(url, {
signal: controller.signal,
const response = await safeFetch(url, {
timeoutMs: FETCH_TIMEOUT_MS,
maxBytes: MAX_SNAPSHOT_BYTES,
headers: { 'User-Agent': 'WebAppLauncher/1.0' }
});
@@ -136,14 +97,12 @@ export async function fetchSnapshot(url: string): Promise<CameraSnapshot> {
throw new Error('Camera request timed out');
}
throw err;
} finally {
clearTimeout(timeoutId);
}
}
/**
* Clear the rate limit tracking.
*/
export function clearRateLimits(): void {
lastFetchTimes.clear();
}
// Suppress unused warning when this is imported elsewhere
void DEFAULTS;
+23 -7
View File
@@ -1,6 +1,7 @@
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
import { findAll as findAllApps } from './appService.js';
import { safeFetch } from '$lib/server/utils/safeFetch.js';
const execFileAsync = promisify(execFile);
@@ -97,7 +98,10 @@ export async function discoverDocker(socketPath: string): Promise<{
readonly services: readonly DiscoveredService[];
readonly error?: string;
}> {
if (!/^[\w/.:-]+$/.test(socketPath)) {
// Restrict to a tight regex: alphanumerics, dash, underscore, dot, slash.
// Disallow colons / `tcp://` style strings so an admin can't pivot the
// docker CLI to a remote daemon.
if (!/^[\w/.-]+$/.test(socketPath)) {
return { services: [], error: 'Invalid Docker socket path' };
}
@@ -106,10 +110,18 @@ export async function discoverDocker(socketPath: string): Promise<{
const { stdout } = await execFileAsync(
'curl',
['-s', '--unix-socket', socketPath, 'http://localhost/containers/json?all=false'],
{ timeout: 10000 }
{ timeout: 10000, maxBuffer: 16 * 1024 * 1024 }
);
const containers: DockerContainer[] = JSON.parse(stdout);
let containers: DockerContainer[];
try {
containers = JSON.parse(stdout) as DockerContainer[];
} catch {
return { services: [], error: 'Docker daemon returned non-JSON response' };
}
if (!Array.isArray(containers)) {
return { services: [], error: 'Docker daemon returned unexpected payload' };
}
const services: DiscoveredService[] = [];
@@ -158,9 +170,11 @@ export async function discoverTraefik(apiUrl: string): Promise<{
try {
const normalizedUrl = apiUrl.replace(/\/+$/, '');
// Traefik is typically on the same internal network, so private-net is
// expected. Admin-only endpoint; trusted: true to skip the SSRF guard.
const [routersRes, servicesRes] = await Promise.all([
fetch(`${normalizedUrl}/api/http/routers`),
fetch(`${normalizedUrl}/api/http/services`)
safeFetch(`${normalizedUrl}/api/http/routers`, { trusted: true, timeoutMs: 10_000 }),
safeFetch(`${normalizedUrl}/api/http/services`, { trusted: true, timeoutMs: 10_000 })
]);
if (!routersRes.ok) {
@@ -170,8 +184,10 @@ export async function discoverTraefik(apiUrl: string): Promise<{
};
}
const routers: TraefikRouter[] = await routersRes.json();
const traefikServices: TraefikService[] = servicesRes.ok ? await servicesRes.json() : [];
const routers = (await routersRes.json()) as TraefikRouter[];
const traefikServices = servicesRes.ok
? ((await servicesRes.json()) as TraefikService[])
: [];
// Build a map of service name -> backend URL
const serviceUrlMap = new Map<string, string>();
+65 -69
View File
@@ -1,9 +1,10 @@
import * as appService from './appService.js';
import { prisma } from '../prisma.js';
import { AppStatusValue } from '$lib/utils/constants.js';
import { AppStatusValue, DEFAULTS } from '$lib/utils/constants.js';
import { safeFetch } from '$lib/server/utils/safeFetch.js';
const MAX_RECORDS_PER_APP = 288;
const RETENTION_HOURS = 24;
const RETENTION_HOURS = DEFAULTS.APP_STATUS_RETENTION_HOURS;
export interface HealthcheckResult {
readonly appId: string;
@@ -13,6 +14,8 @@ export interface HealthcheckResult {
/**
* Perform a health check on a single app by making an HTTP request to its URL.
* SSRF-guarded refuses to check URLs resolving to private/loopback addresses
* unless ALLOW_PRIVATE_NETWORK_FETCH is set (typically true for LAN homelabs).
*/
export async function checkAppHealth(app: {
readonly id: string;
@@ -21,23 +24,17 @@ export async function checkAppHealth(app: {
readonly healthcheckExpectedStatus: number;
readonly healthcheckTimeout: number;
}): Promise<HealthcheckResult> {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), app.healthcheckTimeout);
const start = Date.now();
try {
const response = await fetch(app.url, {
// safeFetch re-validates each redirect hop so the healthcheck cannot be
// used as an SSRF pivot via a 302 redirect from a public host.
const response = await safeFetch(app.url, {
method: app.healthcheckMethod,
signal: controller.signal,
redirect: 'follow',
headers: {
'User-Agent': 'WebAppLauncher-Healthcheck/1.0'
}
timeoutMs: app.healthcheckTimeout,
headers: { 'User-Agent': 'WebAppLauncher-Healthcheck/1.0' }
});
const responseTime = Date.now() - start;
const status =
response.status === app.healthcheckExpectedStatus
? AppStatusValue.ONLINE
@@ -46,89 +43,88 @@ export async function checkAppHealth(app: {
return { appId: app.id, status, responseTime };
} catch (err) {
const responseTime = Date.now() - start;
if (err instanceof DOMException && err.name === 'AbortError') {
return { appId: app.id, status: AppStatusValue.OFFLINE, responseTime };
}
return { appId: app.id, status: AppStatusValue.OFFLINE, responseTime: null };
} finally {
clearTimeout(timeoutId);
}
}
/**
* Run `worker` over `items` with at most `limit` in flight at any time.
* Avoids the "fan out 100 concurrent fetches" pattern that loopback-DoSes
* the launcher when an app's URL points back at itself.
*/
async function withConcurrency<T, R>(
items: readonly T[],
limit: number,
worker: (item: T) => Promise<R>
): Promise<R[]> {
const results: R[] = new Array(items.length);
let cursor = 0;
const runOne = async (): Promise<void> => {
while (true) {
const idx = cursor++;
if (idx >= items.length) return;
results[idx] = await worker(items[idx]);
}
};
const runners: Promise<void>[] = [];
for (let i = 0; i < Math.min(limit, items.length); i++) {
runners.push(runOne());
}
await Promise.all(runners);
return results;
}
/**
* Check all apps that have healthcheck enabled, record their statuses.
*/
export async function checkAllApps(): Promise<readonly HealthcheckResult[]> {
const targets = await appService.getHealthcheckTargets();
if (targets.length === 0) return [];
if (targets.length === 0) {
return [];
}
const results = await Promise.allSettled(targets.map((target) => checkAppHealth(target)));
const outcomes: HealthcheckResult[] = [];
for (const result of results) {
if (result.status === 'fulfilled') {
const { appId, status, responseTime } = result.value;
const outcomes = await withConcurrency(
targets,
DEFAULTS.HEALTHCHECK_CONCURRENCY,
async (target) => {
const result = await checkAppHealth(target);
try {
await appService.recordStatus(appId, status, responseTime);
} catch {
// Log but don't fail the whole batch
await appService.recordStatus(result.appId, result.status, result.responseTime);
} catch (recordErr) {
console.warn('[healthcheck] failed to record status:', recordErr);
}
outcomes.push(result.value);
return result;
}
}
);
return outcomes;
}
/**
* Prune old AppStatus records.
* - Deletes records older than RETENTION_HOURS
* - Keeps at most MAX_RECORDS_PER_APP per app (deletes oldest excess)
* - Deletes records older than RETENTION_HOURS in one query.
* - For each app, keeps at most MAX_RECORDS_PER_APP using a single window-
* function delete (was N+1 queries per app per hour).
*/
export async function pruneOldStatuses(): Promise<void> {
const cutoff = new Date(Date.now() - RETENTION_HOURS * 60 * 60 * 1000);
// Step 1: Delete all records older than retention period
await prisma.appStatus.deleteMany({
where: {
checkedAt: { lt: cutoff }
}
where: { checkedAt: { lt: cutoff } }
});
// Step 2: For each app, keep at most MAX_RECORDS_PER_APP
const apps = await prisma.app.findMany({
where: { healthcheckEnabled: true },
select: { id: true }
});
for (const app of apps) {
const count = await prisma.appStatus.count({
where: { appId: app.id }
});
if (count > MAX_RECORDS_PER_APP) {
const excess = count - MAX_RECORDS_PER_APP;
// Find the oldest records to delete
const oldestRecords = await prisma.appStatus.findMany({
where: { appId: app.id },
orderBy: { checkedAt: 'asc' },
take: excess,
select: { id: true }
});
if (oldestRecords.length > 0) {
await prisma.appStatus.deleteMany({
where: {
id: { in: oldestRecords.map((r) => r.id) }
}
});
}
}
}
// Single SQL pass: keep the newest MAX_RECORDS_PER_APP rows per appId.
// Uses ROW_NUMBER() OVER (PARTITION BY appId ORDER BY checkedAt DESC).
await prisma.$executeRawUnsafe(
`DELETE FROM AppStatus
WHERE id IN (
SELECT id FROM (
SELECT id, ROW_NUMBER() OVER (PARTITION BY appId ORDER BY checkedAt DESC) AS rn
FROM AppStatus
) WHERE rn > ?
)`,
MAX_RECORDS_PER_APP
);
}
+29 -25
View File
@@ -1,8 +1,8 @@
import bcrypt from 'bcryptjs';
import { createHash, randomBytes, timingSafeEqual } from 'crypto';
import { prisma } from '../prisma.js';
import { DEFAULTS } from '$lib/utils/constants.js';
const SALT_ROUNDS = 12;
const DEFAULT_EXPIRY_DAYS = 7;
const DEFAULT_EXPIRY_DAYS = DEFAULTS.INVITE_DEFAULT_EXPIRY_DAYS;
export interface CreateInviteInput {
readonly email?: string;
@@ -13,21 +13,28 @@ export interface CreateInviteInput {
export interface IssuedInvite {
readonly id: string;
readonly token: string; // raw token — shown to admin exactly once
readonly token: string;
readonly email: string | null;
readonly role: string;
readonly expiresAt: Date;
}
function generateToken(): string {
const bytes = new Uint8Array(32);
crypto.getRandomValues(bytes);
return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
function generateRawToken(): string {
return `inv_${randomBytes(32).toString('hex')}`;
}
function sha256Hex(value: string): string {
return createHash('sha256').update(value).digest('hex');
}
function constantTimeEqual(a: string, b: string): boolean {
if (a.length !== b.length) return false;
return timingSafeEqual(Buffer.from(a), Buffer.from(b));
}
export async function createInvite(input: CreateInviteInput): Promise<IssuedInvite> {
const token = generateToken();
const tokenHash = await bcrypt.hash(token, SALT_ROUNDS);
const token = generateRawToken();
const tokenHash = sha256Hex(token);
const days = input.expiresInDays ?? DEFAULT_EXPIRY_DAYS;
const expiresAt = new Date();
expiresAt.setDate(expiresAt.getDate() + days);
@@ -72,24 +79,21 @@ export async function revokeInvite(id: string): Promise<void> {
}
/**
* Look up an invite by raw token. Performs a full table scan of non-expired,
* non-used invites and bcrypt-compares each. Invite volume is tiny in practice,
* so the scan cost is negligible.
* Look up an invite by raw token. O(1) via the unique tokenHash index.
* Tokens are 256-bit random; SHA-256 is sufficient (no bcrypt KDF cost).
*/
export async function findInviteByToken(token: string) {
const candidates = await prisma.invite.findMany({
where: {
usedAt: null,
expiresAt: { gt: new Date() }
}
});
if (typeof token !== 'string' || token.length === 0) return null;
for (const invite of candidates) {
if (await bcrypt.compare(token, invite.tokenHash)) {
return invite;
}
}
return null;
const tokenHash = sha256Hex(token);
const invite = await prisma.invite.findUnique({ where: { tokenHash } });
if (!invite) return null;
if (!constantTimeEqual(invite.tokenHash, tokenHash)) return null;
if (invite.usedAt) return null;
if (invite.expiresAt <= new Date()) return null;
return invite;
}
export async function consumeInvite(inviteId: string, userId: string): Promise<void> {
+4 -9
View File
@@ -3,6 +3,7 @@
* Supports static values, JSON endpoints with dot-path extraction, and Prometheus queries.
* Tracks previous values for trend calculation.
*/
import { safeFetch, type SafeResponse } from '$lib/server/utils/safeFetch.js';
const DEFAULT_CACHE_TTL_MS = 60_000; // 1 minute
const FETCH_TIMEOUT_MS = 10_000;
@@ -75,23 +76,17 @@ function calculateTrend(current: number, previous: number | null): 'up' | 'down'
return 'flat';
}
async function fetchWithTimeout(url: string): Promise<Response> {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
async function fetchWithTimeout(url: string): Promise<SafeResponse> {
try {
const response = await fetch(url, {
signal: controller.signal,
return await safeFetch(url, {
timeoutMs: FETCH_TIMEOUT_MS,
headers: { 'User-Agent': 'WebAppLauncher/1.0' }
});
return response;
} catch (err) {
if (err instanceof DOMException && err.name === 'AbortError') {
throw new Error('Metric request timed out');
}
throw err;
} finally {
clearTimeout(timeoutId);
}
}
+169
View File
@@ -0,0 +1,169 @@
import { prisma } from '../prisma.js';
import { AppStatusValue } from '$lib/utils/constants.js';
/**
* Tiny Prometheus-text metrics gatherer. Avoids the prom-client dependency
* (~150KB + extra runtime memory) by emitting the exposition format directly.
* If we later want histograms or counters with labels at high cardinality,
* swap this out for prom-client.
*/
interface CounterSnapshot {
readonly name: string;
readonly help: string;
readonly value: number;
readonly labels?: Record<string, string>;
}
function escapeLabel(value: string): string {
return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n');
}
function renderLabels(labels?: Record<string, string>): string {
if (!labels) return '';
const parts = Object.entries(labels).map(([k, v]) => `${k}="${escapeLabel(v)}"`);
return parts.length ? `{${parts.join(',')}}` : '';
}
/**
* In-memory counter / gauge state. Process-local Prometheus is expected to
* scrape a single launcher instance (the app is SQLite-bound to one process
* anyway). Reset on restart, like most lightweight setups.
*/
class MetricRegistry {
private counters = new Map<string, number>();
private gauges = new Map<string, number>();
incCounter(name: string, by = 1): void {
this.counters.set(name, (this.counters.get(name) ?? 0) + by);
}
setGauge(name: string, value: number): void {
this.gauges.set(name, value);
}
getCounter(name: string): number {
return this.counters.get(name) ?? 0;
}
snapshot(): { counters: Map<string, number>; gauges: Map<string, number> } {
return { counters: new Map(this.counters), gauges: new Map(this.gauges) };
}
}
export const metricRegistry = new MetricRegistry();
// Counter names — keep them ASCII identifiers (Prometheus naming rules).
export const Counters = {
HEALTHCHECK_TOTAL: 'wal_healthcheck_total',
HEALTHCHECK_FAILED: 'wal_healthcheck_failed_total',
LOGIN_SUCCESS: 'wal_login_success_total',
LOGIN_FAILED: 'wal_login_failed_total',
NOTIFICATION_SENT: 'wal_notification_sent_total',
NOTIFICATION_FAILED: 'wal_notification_failed_total',
INTEGRATION_FETCH_TOTAL: 'wal_integration_fetch_total',
INTEGRATION_FETCH_FAILED: 'wal_integration_fetch_failed_total'
} as const;
/**
* Build the full exposition. Combines:
* - process-local counters (login attempts, healthcheck ticks, etc.)
* - DB-backed gauges (current online/offline app count, user count, etc.)
*/
export async function renderMetrics(): Promise<string> {
const lines: string[] = [];
// --- Static help/type lines + counter snapshots ---
const COUNTER_HELP: Record<string, string> = {
[Counters.HEALTHCHECK_TOTAL]: 'Total healthcheck ticks executed since process start',
[Counters.HEALTHCHECK_FAILED]: 'Healthcheck ticks where any app returned offline',
[Counters.LOGIN_SUCCESS]: 'Successful local logins since process start',
[Counters.LOGIN_FAILED]: 'Failed local logins since process start',
[Counters.NOTIFICATION_SENT]: 'Notification dispatch attempts',
[Counters.NOTIFICATION_FAILED]: 'Notification dispatch failures',
[Counters.INTEGRATION_FETCH_TOTAL]: 'Integration fetch attempts',
[Counters.INTEGRATION_FETCH_FAILED]: 'Integration fetch failures'
};
const { counters } = metricRegistry.snapshot();
for (const name of Object.values(Counters)) {
const value = counters.get(name) ?? 0;
lines.push(`# HELP ${name} ${COUNTER_HELP[name]}`);
lines.push(`# TYPE ${name} counter`);
lines.push(`${name} ${value}`);
}
// --- DB-backed gauges ---
const gauges: CounterSnapshot[] = [];
try {
const [totalApps, healthchecked, totalUsers, totalBoards] = await Promise.all([
prisma.app.count(),
prisma.app.count({ where: { healthcheckEnabled: true } }),
prisma.user.count(),
prisma.board.count()
]);
gauges.push(
{ name: 'wal_apps_total', help: 'Total apps registered', value: totalApps },
{
name: 'wal_apps_healthchecked_total',
help: 'Apps with healthcheck enabled',
value: healthchecked
},
{ name: 'wal_users_total', help: 'Total user accounts', value: totalUsers },
{ name: 'wal_boards_total', help: 'Total boards', value: totalBoards }
);
// Latest status per app — broken down by status value.
// Subquery: for each app, take the most recent AppStatus row.
const latest = await prisma.$queryRaw<{ status: string; count: number }[]>`
SELECT status, COUNT(*) AS count
FROM (
SELECT appId, status, ROW_NUMBER() OVER (PARTITION BY appId ORDER BY checkedAt DESC) AS rn
FROM AppStatus
)
WHERE rn = 1
GROUP BY status
`;
for (const status of Object.values(AppStatusValue)) {
const row = latest.find((r) => r.status === status);
gauges.push({
name: 'wal_app_status',
help: 'Current count of apps by latest status',
value: Number(row?.count ?? 0),
labels: { status }
});
}
} catch (err) {
// DB issue — emit an "up" gauge of 0 so scrapers can alert on it.
// eslint-disable-next-line no-console
console.warn('[metrics] failed to gather DB gauges:', err);
lines.push(`# HELP wal_db_up 1 if the metrics endpoint could read from the DB`);
lines.push(`# TYPE wal_db_up gauge`);
lines.push(`wal_db_up 0`);
lines.push('');
return lines.join('\n');
}
// Group same-name gauges so we emit HELP/TYPE once.
const grouped = new Map<string, CounterSnapshot[]>();
for (const g of gauges) {
const arr = grouped.get(g.name);
if (arr) arr.push(g);
else grouped.set(g.name, [g]);
}
for (const [name, samples] of grouped) {
lines.push(`# HELP ${name} ${samples[0].help}`);
lines.push(`# TYPE ${name} gauge`);
for (const s of samples) {
lines.push(`${name}${renderLabels(s.labels)} ${s.value}`);
}
}
lines.push(`# HELP wal_db_up 1 if the metrics endpoint could read from the DB`);
lines.push(`# TYPE wal_db_up gauge`);
lines.push(`wal_db_up 1`);
lines.push('');
return lines.join('\n');
}
+119 -63
View File
@@ -1,5 +1,19 @@
import { createHmac } from 'crypto';
import { prisma } from '../prisma.js';
import { NotificationType } from '$lib/utils/constants.js';
import { safeFetch } from '$lib/server/utils/safeFetch.js';
import { metricRegistry, Counters } from './metricsService.js';
const NOTIFICATION_TIMEOUT_MS = 10_000;
/**
* HMAC-SHA256 signature, GitHub-style: `sha256=<hex>`. Receivers verify by
* computing the same HMAC over the raw request body using their shared secret
* and constant-time-comparing it to the header value.
*/
function hmacSignature(secret: string, body: string): string {
return 'sha256=' + createHmac('sha256', secret).update(body).digest('hex');
}
// --- Channel Management ---
@@ -61,28 +75,12 @@ export async function getChannelById(id: string, userId: string) {
// --- Notification Dispatchers ---
interface DiscordConfig {
readonly webhookUrl: string;
}
interface SlackConfig {
readonly webhookUrl: string;
}
interface TelegramConfig {
readonly botToken: string;
readonly chatId: string;
}
interface HttpConfig {
readonly url: string;
readonly headers?: Record<string, string>;
}
async function sendDiscord(webhookUrl: string, message: string): Promise<void> {
metricRegistry.incCounter(Counters.NOTIFICATION_SENT);
try {
await fetch(webhookUrl, {
await safeFetch(webhookUrl, {
method: 'POST',
timeoutMs: NOTIFICATION_TIMEOUT_MS,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
embeds: [
@@ -95,37 +93,41 @@ async function sendDiscord(webhookUrl: string, message: string): Promise<void> {
]
})
});
} catch {
// Fire-and-forget: swallow errors
} catch (err) {
metricRegistry.incCounter(Counters.NOTIFICATION_FAILED);
console.warn('[notification] discord dispatch failed:', err);
}
}
async function sendSlack(webhookUrl: string, message: string): Promise<void> {
metricRegistry.incCounter(Counters.NOTIFICATION_SENT);
try {
await fetch(webhookUrl, {
await safeFetch(webhookUrl, {
method: 'POST',
timeoutMs: NOTIFICATION_TIMEOUT_MS,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
blocks: [
{
type: 'section',
text: {
type: 'mrkdwn',
text: `*Web App Launcher*\n${message}`
}
text: { type: 'mrkdwn', text: `*Web App Launcher*\n${message}` }
}
]
})
});
} catch {
// Fire-and-forget: swallow errors
} catch (err) {
metricRegistry.incCounter(Counters.NOTIFICATION_FAILED);
console.warn('[notification] slack dispatch failed:', err);
}
}
async function sendTelegram(botToken: string, chatId: string, message: string): Promise<void> {
metricRegistry.incCounter(Counters.NOTIFICATION_SENT);
try {
await fetch(`https://api.telegram.org/bot${botToken}/sendMessage`, {
await safeFetch(`https://api.telegram.org/bot${botToken}/sendMessage`, {
method: 'POST',
trusted: true,
timeoutMs: NOTIFICATION_TIMEOUT_MS,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
chat_id: chatId,
@@ -133,28 +135,73 @@ async function sendTelegram(botToken: string, chatId: string, message: string):
parse_mode: 'HTML'
})
});
} catch {
// Fire-and-forget: swallow errors
} catch (err) {
metricRegistry.incCounter(Counters.NOTIFICATION_FAILED);
console.warn('[notification] telegram dispatch failed:', err);
}
}
async function sendHttp(
url: string,
payload: unknown,
headers?: Record<string, string>
): Promise<void> {
try {
await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(headers ?? {})
},
body: JSON.stringify(payload)
});
} catch {
// Fire-and-forget: swallow errors
options?: {
readonly headers?: Record<string, string>;
readonly secret?: string;
readonly signatureHeader?: string;
}
): Promise<void> {
metricRegistry.incCounter(Counters.NOTIFICATION_SENT);
const body = JSON.stringify(payload);
const finalHeaders: Record<string, string> = {
'Content-Type': 'application/json',
'User-Agent': 'WebAppLauncher-Webhook/1.0',
...(options?.headers ?? {})
};
if (options?.secret) {
// Allow the receiver to opt into a non-standard header name (e.g.
// `X-Hub-Signature-256` for GitHub-compatible receivers). Default is the
// generic `X-Signature-256` so most receivers parse it the same way.
const headerName = options.signatureHeader ?? 'X-Signature-256';
finalHeaders[headerName] = hmacSignature(options.secret, body);
// Add a timestamp so receivers can reject replays.
const ts = String(Math.floor(Date.now() / 1000));
finalHeaders['X-Webhook-Timestamp'] = ts;
}
try {
await safeFetch(url, {
method: 'POST',
timeoutMs: NOTIFICATION_TIMEOUT_MS,
headers: finalHeaders,
body
});
} catch (err) {
metricRegistry.incCounter(Counters.NOTIFICATION_FAILED);
console.warn('[notification] http dispatch failed:', err);
}
}
function isObject(v: unknown): v is Record<string, unknown> {
return typeof v === 'object' && v !== null && !Array.isArray(v);
}
function pickString(v: unknown, key: string): string | undefined {
if (!isObject(v)) return undefined;
const x = v[key];
return typeof x === 'string' && x.length > 0 ? x : undefined;
}
function pickHeaders(v: unknown): Record<string, string> | undefined {
if (!isObject(v)) return undefined;
const h = v.headers;
if (!isObject(h)) return undefined;
const out: Record<string, string> = {};
for (const [k, val] of Object.entries(h)) {
if (typeof val === 'string') out[k] = val;
}
return Object.keys(out).length ? out : undefined;
}
/**
@@ -168,35 +215,38 @@ async function dispatchToChannel(
try {
config = JSON.parse(channel.config);
} catch {
return; // Invalid config — skip
return;
}
switch (channel.type) {
case NotificationType.DISCORD: {
const dc = config as DiscordConfig;
if (dc.webhookUrl) {
await sendDiscord(dc.webhookUrl, message);
}
const webhookUrl = pickString(config, 'webhookUrl');
if (webhookUrl) await sendDiscord(webhookUrl, message);
break;
}
case NotificationType.SLACK: {
const sc = config as SlackConfig;
if (sc.webhookUrl) {
await sendSlack(sc.webhookUrl, message);
}
const webhookUrl = pickString(config, 'webhookUrl');
if (webhookUrl) await sendSlack(webhookUrl, message);
break;
}
case NotificationType.TELEGRAM: {
const tc = config as TelegramConfig;
if (tc.botToken && tc.chatId) {
await sendTelegram(tc.botToken, tc.chatId, message);
}
const botToken = pickString(config, 'botToken');
const chatId = pickString(config, 'chatId');
if (botToken && chatId) await sendTelegram(botToken, chatId, message);
break;
}
case NotificationType.HTTP: {
const hc = config as HttpConfig;
if (hc.url) {
await sendHttp(hc.url, { message, timestamp: new Date().toISOString() }, hc.headers);
const url = pickString(config, 'url');
if (url) {
await sendHttp(
url,
{ message, timestamp: new Date().toISOString() },
{
headers: pickHeaders(config),
secret: pickString(config, 'secret'),
signatureHeader: pickString(config, 'signatureHeader')
}
);
}
break;
}
@@ -230,9 +280,15 @@ export async function sendNotification(
where: { userId, enabled: true }
});
// Fire-and-forget: dispatch to all channels in parallel
Promise.allSettled(channels.map((ch) => dispatchToChannel(ch, message))).catch(() => {
// Swallow any unexpected errors
// Fire-and-forget: dispatch to all channels in parallel. allSettled never
// rejects but we still log per-channel rejections so they don't vanish.
Promise.allSettled(channels.map((ch) => dispatchToChannel(ch, message))).then((results) => {
for (const r of results) {
if (r.status === 'rejected') {
console.warn('[notification] dispatch rejected:', r.reason);
}
}
});
return notification;
@@ -0,0 +1,145 @@
import { createHash, randomBytes, timingSafeEqual } from 'crypto';
import { prisma } from '../prisma.js';
import * as authService from './authService.js';
import { invalidateUserCache } from '../utils/userLocals.js';
const RESET_TOKEN_TTL_HOURS = 24;
function generateRawToken(): string {
return `pwr_${randomBytes(32).toString('hex')}`;
}
function sha256Hex(value: string): string {
return createHash('sha256').update(value).digest('hex');
}
function constantTimeEqual(a: string, b: string): boolean {
if (a.length !== b.length) return false;
return timingSafeEqual(Buffer.from(a), Buffer.from(b));
}
export interface IssuedReset {
readonly id: string;
readonly userId: string;
readonly token: string; // raw — shown once
readonly expiresAt: Date;
}
/**
* Create a password-reset token for the given email.
*
* Returns null if no local-password user exists for the email. The caller
* MUST surface the same response shape regardless of return value, to avoid
* leaking account existence (account enumeration mitigation).
*
* Existing unused reset tokens for the same user are invalidated.
*/
export async function requestReset(
email: string,
createdById?: string
): Promise<IssuedReset | null> {
const user = await prisma.user.findUnique({
where: { email: email.toLowerCase() },
select: { id: true, password: true, authProvider: true }
});
if (!user || !user.password) return null;
// Invalidate prior unused tokens for this user so only the latest works.
await prisma.passwordReset.deleteMany({
where: { userId: user.id, usedAt: null }
});
const token = generateRawToken();
const tokenHash = sha256Hex(token);
const expiresAt = new Date(Date.now() + RESET_TOKEN_TTL_HOURS * 60 * 60 * 1000);
const row = await prisma.passwordReset.create({
data: {
userId: user.id,
tokenHash,
expiresAt,
createdById: createdById ?? null
}
});
return { id: row.id, userId: user.id, token, expiresAt };
}
/**
* Find an unused, unexpired reset token by its raw value.
*/
export async function findResetByToken(token: string) {
if (typeof token !== 'string' || token.length === 0) return null;
const tokenHash = sha256Hex(token);
const row = await prisma.passwordReset.findUnique({
where: { tokenHash },
include: { user: { select: { id: true, email: true, displayName: true } } }
});
if (!row) return null;
if (!constantTimeEqual(row.tokenHash, tokenHash)) return null;
if (row.usedAt) return null;
if (row.expiresAt <= new Date()) return null;
return row;
}
/**
* Consume a reset token: hash and apply the new password, mark the token used,
* revoke all existing sessions for the user (they should re-login with the new
* password from every device).
*/
export async function applyReset(token: string, newPassword: string): Promise<void> {
const row = await findResetByToken(token);
if (!row) {
throw new Error('Reset link is invalid, expired, or has already been used.');
}
const newHash = await authService.hashPassword(newPassword);
await prisma.$transaction([
prisma.user.update({ where: { id: row.userId }, data: { password: newHash } }),
prisma.passwordReset.update({
where: { id: row.id },
data: { usedAt: new Date() }
}),
// Revoke all sessions for this user — forces every other device to re-login.
prisma.session.deleteMany({ where: { userId: row.userId } })
]);
invalidateUserCache(row.userId);
}
/**
* List pending (unused, unexpired) password resets used by the admin UI to
* share the reset link with the user (since we don't ship SMTP).
*/
export async function listPendingResets() {
return prisma.passwordReset.findMany({
where: {
usedAt: null,
expiresAt: { gt: new Date() }
},
orderBy: { createdAt: 'desc' },
include: {
user: { select: { id: true, email: true, displayName: true } }
}
});
}
/**
* Revoke a pending reset (admin action).
*/
export async function revokeReset(id: string): Promise<void> {
await prisma.passwordReset.deleteMany({ where: { id } });
}
/**
* Periodic cleanup of expired and used tokens.
*/
export async function pruneExpiredResets(): Promise<number> {
const cutoff = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
const result = await prisma.passwordReset.deleteMany({
where: {
OR: [{ expiresAt: { lt: new Date() } }, { usedAt: { lt: cutoff } }]
}
});
return result.count;
}
+5 -4
View File
@@ -4,11 +4,12 @@ import {
PermissionLevel,
PERMISSION_HIERARCHY,
TargetType,
type EntityType,
type TargetType as TargetTypeType
type EntityType
} from '$lib/utils/constants.js';
import type { CreatePermissionInput, PermissionCheckResult } from '$lib/types/permission.js';
type TargetTypeValue = (typeof TargetType)[keyof typeof TargetType];
export async function checkPermission(
entityType: EntityType,
entityId: string,
@@ -116,7 +117,7 @@ export async function grantPermission(input: CreatePermissionInput) {
export async function revokePermission(
entityType: EntityType,
entityId: string,
targetType: TargetTypeType,
targetType: TargetTypeValue,
targetId: string
) {
await prisma.permission.deleteMany({
@@ -136,7 +137,7 @@ export async function getPermissionsForEntity(entityType: EntityType, entityId:
});
}
export async function getPermissionsForTarget(targetType: TargetTypeType, targetId: string) {
export async function getPermissionsForTarget(targetType: TargetTypeValue, targetId: string) {
return prisma.permission.findMany({
where: { targetType, targetId },
orderBy: { createdAt: 'asc' }
+3 -7
View File
@@ -2,6 +2,7 @@
* RSS/Atom feed service fetches and parses RSS/Atom feeds.
* Uses lightweight XML parsing without heavy dependencies.
*/
import { safeFetch } from '$lib/server/utils/safeFetch.js';
const CACHE_TTL_MS = 15 * 60 * 1000; // 15 minutes
const FETCH_TIMEOUT_MS = 10_000;
@@ -137,12 +138,9 @@ export async function fetchFeed(feedUrl: string, maxItems?: number): Promise<rea
const cached = getCached(cacheKey);
if (cached) return cached;
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
try {
const response = await fetch(feedUrl, {
signal: controller.signal,
const response = await safeFetch(feedUrl, {
timeoutMs: FETCH_TIMEOUT_MS,
headers: {
'User-Agent': 'WebAppLauncher/1.0',
Accept: 'application/rss+xml, application/atom+xml, application/xml, text/xml'
@@ -176,8 +174,6 @@ export async function fetchFeed(feedUrl: string, maxItems?: number): Promise<rea
throw new Error('Feed request timed out');
}
throw err;
} finally {
clearTimeout(timeoutId);
}
}
@@ -2,6 +2,7 @@
* System stats service fetches metrics from various sources using an adapter pattern.
* Supports Glances, Prometheus, and custom JSON endpoints.
*/
import { safeFetch } from '$lib/server/utils/safeFetch.js';
const DEFAULT_CACHE_TTL_MS = 30_000; // 30 seconds
const FETCH_TIMEOUT_MS = 10_000;
@@ -37,12 +38,9 @@ function setCache(key: string, data: readonly SystemMetric[], ttlMs: number): vo
}
async function fetchWithTimeout(url: string): Promise<unknown> {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
try {
const response = await fetch(url, {
signal: controller.signal,
const response = await safeFetch(url, {
timeoutMs: FETCH_TIMEOUT_MS,
headers: { 'User-Agent': 'WebAppLauncher/1.0' }
});
@@ -56,8 +54,6 @@ async function fetchWithTimeout(url: string): Promise<unknown> {
throw new Error('System stats request timed out');
}
throw err;
} finally {
clearTimeout(timeoutId);
}
}
+82 -11
View File
@@ -93,25 +93,96 @@ export async function getUptimeTimeline(appId: string, timeRange: TimeRange = '2
/**
* Get aggregated uptime for all apps with healthcheck enabled.
*
* Single-query implementation: fetches all status rows in the range with one
* IN-clause query, then aggregates per-app in JS. Replaces the previous
* Promise.all(apps.map(getUptimeStats)) which fired O(N) DB queries.
*/
export async function getAllAppsUptime(timeRange: TimeRange = '24h') {
const cutoff = getTimeRangeCutoff(timeRange);
const apps = await prisma.app.findMany({
where: { healthcheckEnabled: true },
select: { id: true, name: true, url: true }
});
const results = await Promise.all(
apps.map(async (app) => {
const stats = await getUptimeStats(app.id, timeRange);
return {
...stats,
appName: app.name,
appUrl: app.url
};
})
);
if (apps.length === 0) return [];
return results;
const appIds = apps.map((a) => a.id);
const statuses = await prisma.appStatus.findMany({
where: {
appId: { in: appIds },
checkedAt: { gte: cutoff }
},
orderBy: { checkedAt: 'asc' },
select: { appId: true, status: true, responseTime: true, checkedAt: true }
});
// Pre-bucket by appId.
const buckets = new Map<
string,
{ status: string; responseTime: number | null; checkedAt: Date }[]
>();
for (const s of statuses) {
const arr = buckets.get(s.appId);
if (arr) arr.push(s);
else buckets.set(s.appId, [s]);
}
return apps.map((app) => {
const bucket = buckets.get(app.id) ?? [];
if (bucket.length === 0) {
return {
appId: app.id,
appName: app.name,
appUrl: app.url,
timeRange,
currentStatus: null as string | null,
uptimePercentage: null,
avgResponseTime: null,
totalChecks: 0,
onlineChecks: 0,
offlineChecks: 0,
degradedChecks: 0
};
}
let onlineChecks = 0;
let offlineChecks = 0;
let degradedChecks = 0;
let rtSum = 0;
let rtCount = 0;
for (const s of bucket) {
if (s.status === AppStatusValue.ONLINE) onlineChecks++;
else if (s.status === AppStatusValue.OFFLINE) offlineChecks++;
else if (s.status === AppStatusValue.DEGRADED) degradedChecks++;
if (s.responseTime !== null) {
rtSum += s.responseTime;
rtCount++;
}
}
const relevant = onlineChecks + offlineChecks + degradedChecks;
const uptimePercentage =
relevant > 0 ? Math.round((onlineChecks / relevant) * 10000) / 100 : null;
const avgResponseTime = rtCount > 0 ? Math.round(rtSum / rtCount) : null;
return {
appId: app.id,
appName: app.name,
appUrl: app.url,
timeRange,
currentStatus: bucket[bucket.length - 1].status,
uptimePercentage,
avgResponseTime,
totalChecks: bucket.length,
onlineChecks,
offlineChecks,
degradedChecks
};
});
}
/**
+10 -5
View File
@@ -1,5 +1,6 @@
import { prisma } from '../prisma.js';
import { hashPassword } from './authService.js';
import { invalidateUserCache } from '../utils/userLocals.js';
import type { CreateUserInput, UpdateUserInput } from '$lib/types/user.js';
const USER_SELECT = {
@@ -65,9 +66,8 @@ export async function create(input: CreateUserInput) {
}
export async function update(id: string, input: UpdateUserInput) {
await findById(id); // Ensure user exists
return prisma.user.update({
await findById(id);
const result = await prisma.user.update({
where: { id },
data: {
...(input.displayName !== undefined ? { displayName: input.displayName } : {}),
@@ -76,19 +76,24 @@ export async function update(id: string, input: UpdateUserInput) {
},
select: USER_SELECT
});
invalidateUserCache(id);
return result;
}
export async function remove(id: string) {
await findById(id); // Ensure user exists
await findById(id);
await prisma.user.delete({ where: { id } });
invalidateUserCache(id);
}
export async function updateRole(id: string, role: string) {
return prisma.user.update({
const result = await prisma.user.update({
where: { id },
data: { role },
select: USER_SELECT
});
invalidateUserCache(id);
return result;
}
export async function getUserGroups(userId: string) {
+12 -12
View File
@@ -2,6 +2,7 @@
* Weather service fetches current weather from OpenMeteo API.
* No API key required.
*/
import { safeFetch } from '$lib/server/utils/safeFetch.js';
const OPEN_METEO_BASE = 'https://api.open-meteo.com/v1/forecast';
const CACHE_TTL_MS = 30 * 60 * 1000; // 30 minutes
@@ -54,12 +55,13 @@ export async function fetchWeather(latitude: number, longitude: number): Promise
const url = `${OPEN_METEO_BASE}?latitude=${latitude}&longitude=${longitude}&current_weather=true`;
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
try {
const response = await fetch(url, {
signal: controller.signal,
// trusted: OpenMeteo is a fixed, well-known public host; skip the
// private-net guard so it still works on locked-down deployments where
// ALLOW_PRIVATE_NETWORK_FETCH=false and DNS resolves to an unexpected range.
const response = await safeFetch(url, {
trusted: true,
timeoutMs: FETCH_TIMEOUT_MS,
headers: { 'User-Agent': 'WebAppLauncher/1.0' }
});
@@ -67,7 +69,7 @@ export async function fetchWeather(latitude: number, longitude: number): Promise
throw new Error(`OpenMeteo API returned ${response.status}`);
}
const json = await response.json();
const json = (await response.json()) as { current_weather?: Record<string, unknown> };
const current = json?.current_weather;
if (!current || typeof current.temperature !== 'number') {
@@ -75,11 +77,11 @@ export async function fetchWeather(latitude: number, longitude: number): Promise
}
const data: WeatherData = {
temperature: current.temperature,
windSpeed: current.windspeed ?? 0,
weatherCode: current.weathercode ?? 0,
temperature: current.temperature as number,
windSpeed: (current.windspeed as number) ?? 0,
weatherCode: (current.weathercode as number) ?? 0,
isDay: current.is_day === 1,
time: current.time ?? new Date().toISOString()
time: (current.time as string) ?? new Date().toISOString()
};
setCache(cacheKey, data);
@@ -89,8 +91,6 @@ export async function fetchWeather(latitude: number, longitude: number): Promise
throw new Error('Weather API request timed out');
}
throw err;
} finally {
clearTimeout(timeoutId);
}
}
@@ -0,0 +1,27 @@
import { describe, it, expect } from 'vitest';
import { enforceRateLimit, loginRateLimiter } from '../rateLimit.js';
import { RateLimitError } from '../../errors.js';
describe('enforceRateLimit', () => {
it('allows up to the bucket limit', () => {
const key = `test-${Math.random()}`;
// loginRateLimiter is 10/min — push 10 through, all should pass.
for (let i = 0; i < 10; i++) {
expect(() => enforceRateLimit(loginRateLimiter, key)).not.toThrow();
}
});
it('throws RateLimitError on the 11th attempt within the window', () => {
const key = `test-overflow-${Math.random()}`;
for (let i = 0; i < 10; i++) enforceRateLimit(loginRateLimiter, key);
expect(() => enforceRateLimit(loginRateLimiter, key)).toThrow(RateLimitError);
});
it('isolates buckets per key', () => {
const a = `key-a-${Math.random()}`;
const b = `key-b-${Math.random()}`;
for (let i = 0; i < 10; i++) enforceRateLimit(loginRateLimiter, a);
// Different key should still have full quota.
expect(() => enforceRateLimit(loginRateLimiter, b)).not.toThrow();
});
});
@@ -0,0 +1,100 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { assertSafeUrl } from '../safeFetch.js';
const originalEnv = { ...process.env };
describe('assertSafeUrl', () => {
beforeEach(() => {
// Force production-style guard for these tests (deny private nets).
process.env.ALLOW_PRIVATE_NETWORK_FETCH = 'false';
process.env.NODE_ENV = 'production';
});
afterEach(() => {
process.env = { ...originalEnv };
});
describe('scheme', () => {
it('rejects javascript: URLs', async () => {
await expect(assertSafeUrl('javascript:alert(1)')).rejects.toThrow(/Only http/);
});
it('rejects data: URLs', async () => {
await expect(assertSafeUrl('data:text/html,<script>1</script>')).rejects.toThrow(
/Only http/
);
});
it('rejects file: URLs', async () => {
await expect(assertSafeUrl('file:///etc/passwd')).rejects.toThrow(/Only http/);
});
it('rejects gopher: URLs', async () => {
await expect(assertSafeUrl('gopher://example.com')).rejects.toThrow(/Only http/);
});
});
describe('private IPv4 literals', () => {
it.each([
['127.0.0.1'],
['127.255.255.254'],
['10.0.0.1'],
['10.255.255.254'],
['172.16.0.1'],
['172.31.255.254'],
['192.168.0.1'],
['192.168.255.254'],
['169.254.169.254'], // AWS metadata
['0.0.0.0'],
['100.64.0.1'] // CGNAT
])('rejects %s', async (ip) => {
await expect(assertSafeUrl(`http://${ip}/`)).rejects.toThrow(/private/);
});
it.each([['8.8.8.8'], ['1.1.1.1']])('accepts public IPv4 %s', async (ip) => {
// May still throw if DNS doesn't return the literal — but for literals
// we shortcut. These are public; should pass the syntactic check.
await expect(assertSafeUrl(`http://${ip}/`)).resolves.toBeDefined();
});
});
describe('private IPv6 literals', () => {
it.each([
['::1'],
['::ffff:127.0.0.1'], // mapped IPv4 loopback
['fe80::1'], // link-local
['fc00::1'], // unique local
['fd00::1']
])('rejects %s', async (ip) => {
await expect(assertSafeUrl(`http://[${ip}]/`)).rejects.toThrow(/private/);
});
});
describe('obfuscation', () => {
it('rejects decimal IP form for loopback (http://2130706433)', async () => {
await expect(assertSafeUrl('http://2130706433/')).rejects.toThrow(/private/);
});
it('rejects 0 (resolves to 127.0.0.1 on Linux)', async () => {
await expect(assertSafeUrl('http://0/')).rejects.toThrow(/private/);
});
it('rejects metadata.google.internal', async () => {
await expect(assertSafeUrl('http://metadata.google.internal/')).rejects.toThrow(
/blocked/
);
});
});
describe('with ALLOW_PRIVATE_NETWORK_FETCH=true', () => {
it('accepts 192.168.x.x', async () => {
process.env.ALLOW_PRIVATE_NETWORK_FETCH = 'true';
await expect(assertSafeUrl('http://192.168.1.10/')).resolves.toBeDefined();
});
it('accepts 127.0.0.1', async () => {
process.env.ALLOW_PRIVATE_NETWORK_FETCH = 'true';
await expect(assertSafeUrl('http://127.0.0.1:3000/')).resolves.toBeDefined();
});
});
});
@@ -0,0 +1,71 @@
import { describe, it, expect } from 'vitest';
import { sanitizeSvg } from '../svgSanitize.js';
describe('sanitizeSvg', () => {
it('accepts a plain SVG', () => {
const svg = '<svg xmlns="http://www.w3.org/2000/svg"><circle cx="50" cy="50" r="40"/></svg>';
const out = sanitizeSvg(svg);
expect(out).toContain('<svg');
expect(out).toContain('circle');
});
it('strips <script> tags', () => {
const svg = '<svg><script>alert(1)</script><circle/></svg>';
const out = sanitizeSvg(svg);
expect(out).not.toContain('<script');
expect(out).not.toContain('alert(1)');
});
it('strips onload event handlers', () => {
const svg = '<svg onload="alert(1)"><circle/></svg>';
const out = sanitizeSvg(svg);
expect(out).not.toContain('onload');
});
it('strips onerror event handlers on <image>', () => {
const svg = '<svg><image href="x" onerror="alert(1)"/></svg>';
const out = sanitizeSvg(svg);
expect(out).not.toContain('onerror');
});
it('blocks javascript: in href', () => {
const svg = '<svg><a href="javascript:alert(1)"><circle/></a></svg>';
const out = sanitizeSvg(svg);
expect(out).not.toContain('javascript:');
});
it('blocks data:text in href', () => {
const svg = '<svg><image href="data:text/html,<script>1</script>"/></svg>';
const out = sanitizeSvg(svg);
expect(out).not.toContain('data:text');
});
it('keeps same-document fragment href (#use refs)', () => {
// Real-world icon SVGs use <use href="#path"> for internal references.
// DOMPurify may emit href or xlink:href depending on input/version — either is fine.
const svg =
'<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><defs><path id="p" d="M0 0"/></defs><use xlink:href="#p"/></svg>';
const out = sanitizeSvg(svg);
expect(out).toMatch(/(xlink:)?href="#p"/);
});
it('keeps https:// hrefs', () => {
const svg = '<svg><a href="https://example.com"><circle/></a></svg>';
const out = sanitizeSvg(svg);
expect(out).toContain('https://example.com');
});
it('rejects non-SVG input', () => {
expect(() => sanitizeSvg('<html>not svg</html>')).toThrow(/not.*SVG/i);
});
it('rejects empty result after sanitization', () => {
expect(() => sanitizeSvg('<svg><script>only script</script></svg>')).not.toThrow();
});
it('strips <foreignObject>', () => {
const svg = '<svg><foreignObject><div onclick="x">hi</div></foreignObject></svg>';
const out = sanitizeSvg(svg);
expect(out).not.toContain('foreignObject');
});
});
+84
View File
@@ -0,0 +1,84 @@
import { FORBIDDEN_SECRETS, MIN_SECRET_LENGTH } from '$lib/utils/constants.js';
let validated = false;
function assertStrongSecret(name: string, value: string | undefined): void {
if (!value) {
throw new Error(
`${name} environment variable is not set. Generate one with: openssl rand -hex 32`
);
}
if (FORBIDDEN_SECRETS.has(value.trim().toLowerCase())) {
throw new Error(
`${name} is set to a forbidden placeholder value. ` +
`Generate a strong secret with: openssl rand -hex 32`
);
}
if (value.length < MIN_SECRET_LENGTH) {
throw new Error(
`${name} must be at least ${MIN_SECRET_LENGTH} characters long ` +
`(got ${value.length}). Generate with: openssl rand -hex 32`
);
}
}
/**
* Validate critical environment variables at startup. Throws on misconfiguration.
* Idempotent safe to call from multiple module init points.
*/
export function validateServerEnv(): void {
if (validated) return;
assertStrongSecret('JWT_SECRET', process.env.JWT_SECRET);
// INTEGRATION_ENCRYPTION_KEY is required if any integration is configured,
// but we validate the format here when set. Falling back to JWT_SECRET was
// the old behavior; encryption.ts now requires this var explicitly.
const integrationKey = process.env.INTEGRATION_ENCRYPTION_KEY;
if (integrationKey !== undefined) {
assertStrongSecret('INTEGRATION_ENCRYPTION_KEY', integrationKey);
}
// Loud warning if production runs without HTTPS ORIGIN
const origin = process.env.ORIGIN;
if (
process.env.NODE_ENV === 'production' &&
origin &&
origin.startsWith('http://') &&
!/http:\/\/(localhost|127\.0\.0\.1|0\.0\.0\.0)/.test(origin)
) {
// Cannot block — many users terminate TLS at a reverse proxy and still
// set ORIGIN=http://internal. But warn loudly so misconfigured deploys
// get noticed instead of silently shipping non-secure cookies.
console.warn(
'[startup] WARNING: NODE_ENV=production but ORIGIN is http://. ' +
'Session cookies will NOT have the Secure flag. ' +
'Set ORIGIN=https://your-domain or terminate TLS at a reverse proxy and ' +
'set ORIGIN to the public https URL.'
);
}
validated = true;
}
/**
* Lazily resolve INTEGRATION_ENCRYPTION_KEY. Required when any integration is
* used; throws a clear message if not configured.
*/
export function requireIntegrationEncryptionKey(): string {
const key = process.env.INTEGRATION_ENCRYPTION_KEY;
if (!key) {
throw new Error(
'INTEGRATION_ENCRYPTION_KEY is not configured. Set it to a strong random ' +
'value (openssl rand -hex 32) to enable integrations.'
);
}
if (FORBIDDEN_SECRETS.has(key.trim().toLowerCase()) || key.length < MIN_SECRET_LENGTH) {
throw new Error(
'INTEGRATION_ENCRYPTION_KEY is too weak. Generate a strong value: ' +
'openssl rand -hex 32'
);
}
return key;
}
+86
View File
@@ -0,0 +1,86 @@
/**
* Simple in-memory rate limiter for single-instance self-hosted deployments.
* Each bucket tracks a count of events; on exceed, calls return `{ ok: false }`.
*
* Why not Redis: the launcher is SQLite-bound to a single Node process so an
* in-memory map is fine. Memory bound: entries are pruned when their window
* expires AND opportunistically when the map grows beyond MAX_KEYS.
*/
import { RateLimitError } from '$lib/server/errors.js';
interface Bucket {
count: number;
resetAt: number;
}
const MAX_KEYS = 10_000;
class RateLimiter {
private readonly buckets = new Map<string, Bucket>();
constructor(
readonly windowMs: number,
readonly limit: number,
readonly name: string
) {}
check(key: string): { ok: boolean; retryAfterMs: number } {
const now = Date.now();
const compositeKey = `${this.name}:${key}`;
const entry = this.buckets.get(compositeKey);
if (!entry || now >= entry.resetAt) {
this.buckets.set(compositeKey, { count: 1, resetAt: now + this.windowMs });
this.pruneIfLarge();
return { ok: true, retryAfterMs: 0 };
}
if (entry.count >= this.limit) {
return { ok: false, retryAfterMs: entry.resetAt - now };
}
entry.count += 1;
return { ok: true, retryAfterMs: 0 };
}
private pruneIfLarge(): void {
if (this.buckets.size <= MAX_KEYS) return;
const now = Date.now();
for (const [k, v] of this.buckets) {
if (v.resetAt < now) this.buckets.delete(k);
}
}
}
export const loginRateLimiter = new RateLimiter(60_000, 10, 'login');
export const registerRateLimiter = new RateLimiter(60 * 60_000, 10, 'register');
export const inviteLookupRateLimiter = new RateLimiter(60 * 60_000, 30, 'invite');
export const onboardingRateLimiter = new RateLimiter(60 * 60_000, 5, 'onboarding');
export const passwordResetRateLimiter = new RateLimiter(60 * 60_000, 5, 'passwordReset');
export const refreshRateLimiter = new RateLimiter(60_000, 30, 'refresh');
/**
* Throw RateLimitError if the bucket is exhausted.
*/
export function enforceRateLimit(limiter: RateLimiter, key: string): void {
const result = limiter.check(key);
if (!result.ok) {
throw new RateLimitError(
`Too many ${limiter.name} attempts. Retry in ${Math.ceil(result.retryAfterMs / 1000)}s.`
);
}
}
/**
* Resolve a stable key per request (client IP if available, else 'unknown').
*/
export function rateKeyFromEvent(event: {
getClientAddress?: () => string;
request: { headers: Headers };
}): string {
try {
return event.getClientAddress?.() ?? 'unknown';
} catch {
return 'unknown';
}
}
+353
View File
@@ -0,0 +1,353 @@
import dns from 'dns/promises';
import net from 'net';
import { DEFAULTS } from '$lib/utils/constants.js';
import { IntegrationError, ValidationError } from '$lib/server/errors.js';
/**
* SSRF guard + safe fetch wrapper.
*
* The launcher fetches arbitrary user-supplied URLs in many places: healthcheck,
* RSS, calendars, metrics, integration clients, notification webhooks. Without
* a guard, a low-privileged user (or even a remote attacker on the open
* `/api/integrations/alerts` endpoint before C6) can aim the launcher at
* AWS metadata, internal Kubernetes services, the loopback interface, or any
* other internal host the launcher's network has access to.
*
* This guard:
* 1. Rejects non-http(s) schemes (data:, file:, gopher:, javascript:).
* 2. Resolves the hostname up-front and rejects any address in private,
* loopback, link-local, multicast, or unspecified ranges (IPv4 + IPv6).
* 3. Follows 3xx redirects MANUALLY and re-validates each Location, so a
* public host can't 302-redirect into an internal target.
* 4. Caps the response body size to defeat 1GB-of-JSON OOMs.
*
* Known limitation: we don't pin the resolved IP into the actual TCP connect,
* so a TOCTOU DNS-rebinding attacker could in principle return a public IP to
* `dns.lookup` and a private IP on the subsequent `fetch` connect. Mitigations:
* (a) most attacker hosts can't control authoritative DNS TTLs and Node's
* resolver caches between calls; (b) production deployments typically run
* with ALLOW_PRIVATE_NETWORK_FETCH=false which makes the rebinding pointless
* on cloud-isolated networks. To fully eliminate the rebind window, install
* undici with a custom `connect.lookup` dispatcher that returns the validated IP.
*
* Set `ALLOW_PRIVATE_NETWORK_FETCH=true` to disable the private-range guard
* (necessary on self-hosted setups where the user monitors LAN services like
* 192.168.x.x). Default is false in production.
*/
const SSRF_BLOCKED_HOSTS = new Set([
'metadata.google.internal',
'metadata.azure.com',
'instance-data',
'instance-data.ec2.internal'
]);
function allowPrivate(): boolean {
const v = process.env.ALLOW_PRIVATE_NETWORK_FETCH?.trim().toLowerCase();
if (v === 'true' || v === '1' || v === 'yes') return true;
if (v === 'false' || v === '0' || v === 'no') return false;
// Default: development = allow (homelab); production = block.
return process.env.NODE_ENV !== 'production';
}
function isPrivateIPv4(addr: string): boolean {
const parts = addr.split('.').map(Number);
if (parts.length !== 4 || parts.some((p) => isNaN(p) || p < 0 || p > 255)) return true;
const [a, b] = parts;
if (a === 0) return true; // 0.0.0.0/8 (unspecified, also routes to loopback on Linux)
if (a === 10) return true; // 10.0.0.0/8
if (a === 127) return true; // loopback
if (a === 169 && b === 254) return true; // link-local + cloud metadata 169.254.169.254
if (a === 172 && b >= 16 && b <= 31) return true; // 172.16.0.0/12
if (a === 192 && b === 168) return true; // 192.168.0.0/16
if (a === 100 && b >= 64 && b <= 127) return true; // CGNAT 100.64.0.0/10
if (a >= 224) return true; // multicast + reserved
return false;
}
function isPrivateIPv6(addr: string): boolean {
const lower = addr.toLowerCase();
if (lower === '::' || lower === '::1') return true; // unspecified, loopback
if (lower.startsWith('fe80:') || lower.startsWith('fe8') || lower.startsWith('fe9')) return true; // link-local
if (lower.startsWith('fc') || lower.startsWith('fd')) return true; // unique-local fc00::/7
if (lower.startsWith('ff')) return true; // multicast
// IPv4-mapped IPv6: any `::ffff:…` form (dotted-quad or hex-compressed).
// Node normalizes `::ffff:127.0.0.1` → `::ffff:7f00:1`, so we can't rely on
// a literal regex. We just block the whole mapped range — there's no
// legitimate reason for a public address to route via IPv4-mapped IPv6.
if (lower.startsWith('::ffff:')) return true;
// Old-style "IPv4-compatible" IPv6 (deprecated but still parses): `::a.b.c.d`
if (/^::[0-9a-f]{1,4}(:[0-9a-f]{0,4})?$/.test(lower)) {
// Anything in `::x:y` form with non-zero lower bits — could be a private
// IPv4. Block by default.
return true;
}
return false;
}
function isPrivateAddress(addr: string): boolean {
const family = net.isIP(addr);
if (family === 4) return isPrivateIPv4(addr);
if (family === 6) return isPrivateIPv6(addr);
return true; // unknown → treat as private (refuse)
}
export interface SafeFetchOptions extends Omit<RequestInit, 'signal'> {
readonly timeoutMs?: number;
/** Max response bytes. Default: DEFAULTS.MAX_REMOTE_RESPONSE_BYTES. */
readonly maxBytes?: number;
/** If true, bypass SSRF guard (use ONLY for fixed-host services like weather APIs). */
readonly trusted?: boolean;
}
/**
* Validate a URL string for outbound fetching. Throws ValidationError if the
* URL is unfit (bad scheme, blocked host) or, when private-network fetching
* is disabled, if it resolves to a private/loopback/link-local/cloud-metadata
* address.
*
* Returns the parsed URL and the resolved IP address to connect to.
*/
export async function assertSafeUrl(
urlString: string
): Promise<{ url: URL; resolvedAddress: string }> {
let url: URL;
try {
url = new URL(urlString);
} catch {
throw new ValidationError(`Invalid URL: ${urlString}`);
}
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
throw new ValidationError(
`Only http:// and https:// URLs are allowed (got ${url.protocol})`
);
}
const hostname = url.hostname.toLowerCase().replace(/^\[|\]$/g, '');
if (SSRF_BLOCKED_HOSTS.has(hostname)) {
throw new ValidationError(`URL host ${hostname} is blocked`);
}
// Block decimal IPv4 "http://2130706433" (= 127.0.0.1) and 0 (= 127.0.0.1 on Linux).
if (/^\d+$/.test(hostname)) {
const n = Number(hostname);
const ipv4 =
n <= 0xffffffff
? [(n >>> 24) & 0xff, (n >>> 16) & 0xff, (n >>> 8) & 0xff, n & 0xff].join('.')
: hostname;
if (isPrivateIPv4(ipv4) && !allowPrivate()) {
throw new ValidationError(`URL resolves to a private/internal address: ${ipv4}`);
}
}
// If already an IP literal, check it directly.
const literalFamily = net.isIP(hostname);
if (literalFamily !== 0) {
if (!allowPrivate() && isPrivateAddress(hostname)) {
throw new ValidationError(`URL resolves to a private/internal address: ${hostname}`);
}
return { url, resolvedAddress: hostname };
}
// Resolve DNS up-front so we can validate AND pin the IP into the connect.
let addresses: { address: string; family: number }[];
try {
addresses = await dns.lookup(hostname, { all: true, verbatim: true });
} catch {
throw new ValidationError(`DNS lookup failed for ${hostname}`);
}
if (addresses.length === 0) {
throw new ValidationError(`No DNS records for ${hostname}`);
}
if (!allowPrivate()) {
for (const a of addresses) {
if (isPrivateAddress(a.address)) {
throw new ValidationError(
`URL ${hostname} resolves to a private/internal address (${a.address})`
);
}
}
}
return { url, resolvedAddress: addresses[0].address };
}
async function readBoundedBody(response: Response, maxBytes: number): Promise<Uint8Array> {
const reader = response.body?.getReader();
if (!reader) return new Uint8Array();
const chunks: Uint8Array[] = [];
let received = 0;
for (;;) {
const { done, value } = await reader.read();
if (done) break;
if (!value) continue;
received += value.length;
if (received > maxBytes) {
await reader.cancel();
throw new IntegrationError(
`Response exceeds maximum size of ${maxBytes} bytes`
);
}
chunks.push(value);
}
const out = new Uint8Array(received);
let offset = 0;
for (const chunk of chunks) {
out.set(chunk, offset);
offset += chunk.length;
}
return out;
}
const MAX_REDIRECTS = 5;
/**
* SSRF-guarded fetch with a timeout, a response-size cap, and manual redirect
* handling that re-validates each hop. Use this instead of bare `fetch()` for
* every URL that comes from user input (app URL, RSS feed, webhook target,
* integration endpoint, etc.).
*
* Why manual redirects: a public host can 302-redirect to a private one
* (`http://attacker.example` `http://169.254.169.254/...`). If we let
* fetch follow automatically, the SSRF guard runs only against the initial
* URL and the attacker's redirect target is dialled un-validated. We follow
* 3xx hops ourselves and assertSafeUrl() each Location header.
*/
export async function safeFetch(
urlString: string,
options: SafeFetchOptions = {}
): Promise<SafeResponse> {
const timeoutMs = options.timeoutMs ?? DEFAULTS.REMOTE_FETCH_DEFAULT_TIMEOUT_MS;
const maxBytes = options.maxBytes ?? DEFAULTS.MAX_REMOTE_RESPONSE_BYTES;
const requestedRedirect = options.redirect ?? 'follow';
let currentUrl: string;
if (options.trusted) {
currentUrl = urlString;
} else {
const { url } = await assertSafeUrl(urlString);
currentUrl = url.toString();
}
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
let response: Response;
let method = options.method ?? 'GET';
let body = options.body;
try {
let hops = 0;
// We re-validate every redirect target, so always use 'manual' under the
// hood. If the caller asked for 'manual' or 'error', honour that on the
// first response (return / throw, no follow).
while (true) {
response = await fetch(currentUrl, {
...options,
method,
body,
redirect: 'manual',
signal: controller.signal
});
const isRedirect = response.status >= 300 && response.status < 400;
const location = response.headers.get('location');
if (!isRedirect || !location || requestedRedirect !== 'follow') {
if (isRedirect && requestedRedirect === 'error') {
throw new IntegrationError(
`Unexpected redirect to ${location ?? '(no location)'} (caller requested redirect: 'error')`
);
}
break;
}
if (++hops > MAX_REDIRECTS) {
throw new IntegrationError(`Exceeded ${MAX_REDIRECTS} redirects`);
}
// Resolve relative redirects against the current URL, then re-validate.
const nextUrl = new URL(location, currentUrl).toString();
if (!options.trusted) {
await assertSafeUrl(nextUrl);
}
currentUrl = nextUrl;
// RFC 7231 §6.4: 303 (and conventionally 301/302 from POST) downgrade
// to GET and drop the body. 307/308 preserve method+body.
if (response.status === 303 || (response.status < 307 && method !== 'GET' && method !== 'HEAD')) {
method = 'GET';
body = undefined;
}
}
} finally {
clearTimeout(timeoutId);
}
const contentLength = response.headers.get('content-length');
if (contentLength && Number(contentLength) > maxBytes) {
throw new IntegrationError(
`Response Content-Length ${contentLength} exceeds maximum ${maxBytes}`
);
}
return new SafeResponse(response, maxBytes);
}
export class SafeResponse {
readonly raw: Response;
private readonly maxBytes: number;
private bodyBuffer: Uint8Array | null = null;
constructor(raw: Response, maxBytes: number) {
this.raw = raw;
this.maxBytes = maxBytes;
}
get ok(): boolean {
return this.raw.ok;
}
get status(): number {
return this.raw.status;
}
get statusText(): string {
return this.raw.statusText;
}
get headers(): Headers {
return this.raw.headers;
}
private async getBody(): Promise<Uint8Array> {
if (this.bodyBuffer) return this.bodyBuffer;
this.bodyBuffer = await readBoundedBody(this.raw, this.maxBytes);
return this.bodyBuffer;
}
async text(): Promise<string> {
const body = await this.getBody();
return new TextDecoder('utf-8').decode(body);
}
async json<T = unknown>(): Promise<T> {
const text = await this.text();
return JSON.parse(text) as T;
}
async arrayBuffer(): Promise<ArrayBuffer> {
const body = await this.getBody();
// Copy into a new ArrayBuffer so callers can't mutate our cached view.
const out = new ArrayBuffer(body.byteLength);
new Uint8Array(out).set(body);
return out;
}
async stream(): Promise<ReadableStream<Uint8Array> | null> {
return this.raw.body;
}
}
+48
View File
@@ -0,0 +1,48 @@
/**
* Apply baseline security headers to every response.
* - CSP: locks script/object sources, allows inline styles (Tailwind/CSS variables)
* and images from any HTTPS host (icons from arbitrary URLs are a feature).
* `frame-ancestors 'none'` prevents the launcher from being embedded in another
* site (clickjacking against admin actions).
* - HSTS: only emitted when ORIGIN is https://… to avoid breaking plain-HTTP devs.
* - X-Content-Type-Options: prevents MIME sniffing on user-served content (SVG, etc).
* - Referrer-Policy: do not leak referrers to apps that the dashboard links to.
*/
const SECURITY_HEADERS_BASE: Record<string, string> = {
'Content-Security-Policy': [
"default-src 'self'",
"script-src 'self' 'unsafe-inline'",
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data: blob: https: http:",
"media-src 'self' blob: https: http:",
"font-src 'self' data:",
"connect-src 'self' blob: https: http: ws: wss:",
"frame-src 'self' https: http:",
"object-src 'none'",
"frame-ancestors 'none'",
"form-action 'self'",
"base-uri 'self'"
].join('; '),
'X-Content-Type-Options': 'nosniff',
'Referrer-Policy': 'strict-origin-when-cross-origin',
'X-Frame-Options': 'DENY',
'Permissions-Policy':
'accelerometer=(), camera=(), geolocation=(), gyroscope=(), magnetometer=(), microphone=(), payment=(), usb=()'
};
export function applySecurityHeaders(response: Response, origin: string | undefined): Response {
for (const [name, value] of Object.entries(SECURITY_HEADERS_BASE)) {
if (!response.headers.has(name)) {
response.headers.set(name, value);
}
}
if (origin && origin.startsWith('https://')) {
if (!response.headers.has('Strict-Transport-Security')) {
response.headers.set(
'Strict-Transport-Security',
'max-age=63072000; includeSubDomains'
);
}
}
return response;
}
+48
View File
@@ -0,0 +1,48 @@
import DOMPurify from 'isomorphic-dompurify';
/**
* Sanitize an uploaded SVG. Strips `<script>` and event handlers, blocks
* dangerous URI schemes (`javascript:`, `data:text/...`) inside hrefs, and
* rejects `<foreignObject>` (which can embed HTML).
*
* `href` / `xlink:href` are kept most icon SVGs use `<use href="#path">` for
* internal references; stripping these would render most icons blank. We rely
* on `ALLOWED_URI_REGEXP` to restrict href values to safe schemes.
*
* Returns the cleaned SVG string. Throws if the input doesn't look like SVG.
*/
export function sanitizeSvg(rawSvg: string): string {
const trimmed = rawSvg.trim();
if (!/^<\?xml[\s\S]*?>/.test(trimmed) && !/^<svg[\s>]/i.test(trimmed)) {
throw new Error('File does not appear to be a valid SVG');
}
const clean = DOMPurify.sanitize(rawSvg, {
USE_PROFILES: { svg: true, svgFilters: true },
FORBID_TAGS: ['script', 'foreignObject', 'animateMotion'],
FORBID_ATTR: [
'onload',
'onclick',
'onerror',
'onmouseover',
'onfocus',
'onmouseenter',
'onmouseleave'
],
// Many icon sets (Lucide, simple-icons, hand-rolled multi-path icons) use
// `<use href="#id">` for internal references. The default SVG profile in
// some DOMPurify versions does not include `<use>` and strips the
// `href`/`xlink:href` attributes — re-add both explicitly.
ADD_TAGS: ['use'],
ADD_ATTR: ['href', 'xlink:href'],
// Allow only safe href schemes: same-document fragments (`#id` for <use>),
// relative paths, and http(s)/mailto. Disallow javascript:, data:, file:, etc.
ALLOWED_URI_REGEXP: /^(#|\/|https?:|mailto:)/i
});
if (typeof clean !== 'string' || clean.trim().length === 0) {
throw new Error('SVG content empty after sanitization');
}
return clean;
}
+51
View File
@@ -0,0 +1,51 @@
import { join, resolve, sep, normalize } from 'node:path';
import { mkdir } from 'node:fs/promises';
/**
* Persistent uploads root. Uploads live OUTSIDE the SvelteKit build artifact
* so they survive image rebuilds and are mounted from the runtime volume.
*
* Production: /app/data/uploads (matches docker volume `launcher-data:/app/data`)
* Development: <repo>/data/uploads (matches DATABASE_URL=file:../data/launcher.db)
*
* Override with UPLOADS_DIR env if needed.
*/
export function getUploadsDir(): string {
if (process.env.UPLOADS_DIR) return resolve(process.env.UPLOADS_DIR);
if (process.env.NODE_ENV === 'production') return '/app/data/uploads';
return resolve(process.cwd(), 'data', 'uploads');
}
export function getWallpapersDir(): string {
return join(getUploadsDir(), 'wallpapers');
}
/**
* Resolve a public uploads-relative path (e.g. "abc.svg" or "wallpapers/x.jpg")
* to an absolute file path UNDER the uploads root. Throws if the path tries to
* escape the uploads root via `..` segments.
*/
export function resolveUploadsPath(relPath: string): string {
const root = getUploadsDir();
const normalized = normalize(relPath).replace(/^[/\\]+/, '');
// Reject `..` as a path SEGMENT (so legitimate filenames like
// `my..backup.png` are accepted). The startsWith check below is the
// real defense — this just gives a clearer error.
if (/(^|[/\\])\.\.($|[/\\])/.test(normalized)) {
throw new Error('Invalid upload path');
}
const absolute = resolve(root, normalized);
if (!absolute.startsWith(root + sep) && absolute !== root) {
throw new Error('Upload path escapes uploads root');
}
return absolute;
}
/**
* Ensure the uploads directory exists. Idempotent.
*/
export async function ensureUploadsDir(subdir?: string): Promise<string> {
const dir = subdir ? join(getUploadsDir(), subdir) : getUploadsDir();
await mkdir(dir, { recursive: true });
return dir;
}
+79
View File
@@ -0,0 +1,79 @@
import * as userService from '$lib/server/services/userService.js';
import { UserRole } from '$lib/utils/constants.js';
interface CachedUser {
readonly id: string;
readonly email: string;
readonly displayName: string;
readonly avatarUrl: string | null;
readonly role: 'admin' | 'user';
readonly cachedAt: number;
}
const USER_CACHE_TTL_MS = 30_000;
const USER_CACHE_MAX = 5_000;
const cache = new Map<string, CachedUser>();
function pruneExpired(now: number): void {
for (const [k, v] of cache) {
if (now - v.cachedAt >= USER_CACHE_TTL_MS) cache.delete(k);
if (cache.size <= USER_CACHE_MAX) break;
}
// If still over cap after pruning expired, drop the oldest insert.
while (cache.size > USER_CACHE_MAX) {
const first = cache.keys().next().value;
if (first === undefined) break;
cache.delete(first);
}
}
function narrowRole(role: string): 'admin' | 'user' {
return role === UserRole.ADMIN ? 'admin' : 'user';
}
/**
* Load a user for `event.locals.user`, returning the public shape only.
* Caches results for USER_CACHE_TTL_MS to avoid a Prisma round-trip on every
* authenticated request (the JWT payload is trusted for id/email/role; we hit
* the DB only for displayName + avatarUrl).
*/
export async function loadUserForLocals(userId: string): Promise<App.Locals['user']> {
const now = Date.now();
const hit = cache.get(userId);
if (hit && now - hit.cachedAt < USER_CACHE_TTL_MS) {
return {
id: hit.id,
email: hit.email,
displayName: hit.displayName,
avatarUrl: hit.avatarUrl,
role: hit.role
};
}
const user = await userService.findById(userId);
const entry: CachedUser = {
id: user.id,
email: user.email,
displayName: user.displayName,
avatarUrl: user.avatarUrl ?? null,
role: narrowRole(user.role),
cachedAt: now
};
cache.set(userId, entry);
if (cache.size > USER_CACHE_MAX) pruneExpired(now);
return {
id: entry.id,
email: entry.email,
displayName: entry.displayName,
avatarUrl: entry.avatarUrl,
role: entry.role
};
}
export function invalidateUserCache(userId: string): void {
cache.delete(userId);
}
export function clearUserCache(): void {
cache.clear();
}
+1 -1
View File
@@ -29,7 +29,7 @@ class SearchStore {
/** Grouped results for rendering, preserving group order. */
get grouped(): SearchGroup[] {
// eslint-disable-next-line svelte/prefer-svelte-reactivity
const map = new Map<string, SearchResultItem[]>();
for (const item of this.results) {
const existing = map.get(item.type);
+16 -40
View File
@@ -1,4 +1,19 @@
import type { IconType, HealthcheckMethod, AppStatusValue } from '$lib/utils/constants';
import type { IconType, AppStatusValue } from '$lib/utils/constants';
import type { z } from 'zod';
import type {
createAppSchema,
updateAppSchema
} from '$lib/utils/validators';
/**
* Input types are derived from the Zod schemas so they cannot drift from the
* runtime-validated shape. Output (record) types remain hand-written because
* Prisma's generated types don't directly expose them in a stable way.
*/
export type CreateAppInput = z.infer<typeof createAppSchema> & {
readonly createdById?: string;
};
export type UpdateAppInput = z.infer<typeof updateAppSchema>;
export interface AppRecord {
readonly id: string;
@@ -22,43 +37,6 @@ export interface AppRecord {
readonly updatedAt: Date;
}
export interface CreateAppInput {
readonly name: string;
readonly url: string;
readonly icon?: string;
readonly iconType?: IconType;
readonly description?: string;
readonly category?: string;
readonly tags?: string;
readonly healthcheckEnabled?: boolean;
readonly healthcheckInterval?: number;
readonly healthcheckMethod?: HealthcheckMethod;
readonly healthcheckExpectedStatus?: number;
readonly healthcheckTimeout?: number;
readonly integrationType?: string | null;
readonly integrationConfig?: string | null;
readonly integrationEnabled?: boolean;
readonly createdById?: string;
}
export interface UpdateAppInput {
readonly name?: string;
readonly url?: string;
readonly icon?: string | null;
readonly iconType?: IconType;
readonly description?: string | null;
readonly category?: string | null;
readonly tags?: string;
readonly healthcheckEnabled?: boolean;
readonly healthcheckInterval?: number;
readonly healthcheckMethod?: HealthcheckMethod;
readonly healthcheckExpectedStatus?: number;
readonly healthcheckTimeout?: number;
readonly integrationType?: string | null;
readonly integrationConfig?: string | null;
readonly integrationEnabled?: boolean;
}
export interface AppStatusRecord {
readonly id: string;
readonly appId: string;
@@ -67,8 +45,6 @@ export interface AppStatusRecord {
readonly checkedAt: Date;
}
// --- New types for Phases 4-7 ---
export interface AppLinkRecord {
readonly id: string;
readonly appId: string;
+123
View File
@@ -0,0 +1,123 @@
import { describe, it, expect } from 'vitest';
import { sanitizeCss, scopeCss } from '../cssSanitize.js';
describe('sanitizeCss', () => {
describe('legitimate CSS', () => {
it('keeps simple selectors', () => {
const input = 'body { background: red; color: blue; }';
expect(sanitizeCss(input)).toContain('background: red');
});
it('keeps @media queries', () => {
const input = '@media (max-width: 768px) { .foo { display: none; } }';
const out = sanitizeCss(input);
expect(out).toContain('@media');
expect(out).toContain('display: none');
});
it('strips comments', () => {
const input = '/* secret */ .foo { color: red; }';
expect(sanitizeCss(input)).not.toContain('secret');
});
});
describe('XSS vectors', () => {
it('strips expression()', () => {
const out = sanitizeCss('.foo { width: expression(alert(1)); }');
expect(out).not.toContain('expression(');
});
it('strips Unicode-escaped expression — \\65xpression', () => {
const out = sanitizeCss('.foo { width: \\65xpression(alert(1)); }');
expect(out).not.toContain('expression(');
});
it('strips javascript: URLs', () => {
const out = sanitizeCss('.foo { background: url(javascript:alert(1)); }');
expect(out).not.toContain('javascript:');
});
it('strips behavior:', () => {
const out = sanitizeCss('.foo { behavior: url(#bad); }');
expect(out).not.toContain('behavior:');
});
it('strips -moz-binding:', () => {
const out = sanitizeCss('.foo { -moz-binding: url(#bad); }');
expect(out).not.toContain('-moz-binding:');
});
it('strips <script> tags', () => {
const out = sanitizeCss('<script>alert(1)</script> .foo { color: red }');
expect(out).not.toContain('<script');
});
});
describe('forbidden at-rules', () => {
it('drops @import with whitespace', () => {
const out = sanitizeCss('@import url("evil.css"); body { color: red; }');
expect(out).not.toContain('@import');
expect(out).toContain('color: red');
});
it('drops @import without whitespace', () => {
// Previously a regex with `\s+` would miss this form.
const out = sanitizeCss('@import"evil.css"; body { color: red; }');
expect(out).not.toContain('@import');
expect(out).toContain('color: red');
});
it('drops @charset', () => {
expect(sanitizeCss('@charset "utf-8"; body { color: red }')).not.toContain('@charset');
});
it('drops Unicode-escaped @\\69mport', () => {
const out = sanitizeCss('@\\69mport "evil.css"; body { color: red; }');
expect(out).not.toContain('@import');
});
});
describe('url() handling', () => {
it('rejects data:text inside url()', () => {
const out = sanitizeCss('.foo { background: url(data:text/html,<script>); }');
expect(out).not.toContain('data:text');
});
it('keeps http(s) url()', () => {
const out = sanitizeCss('.foo { background: url(https://example.com/x.png); }');
expect(out).toContain('https://example.com/x.png');
});
it('keeps relative url()', () => {
const out = sanitizeCss('.foo { background: url(/uploads/x.png); }');
expect(out).toContain('/uploads/x.png');
});
});
});
describe('scopeCss', () => {
it('prefixes top-level selectors', () => {
const out = scopeCss('body { display: none; }');
expect(out).toContain('.custom-css-scope body');
});
it('prefixes multiple selectors in a list', () => {
const out = scopeCss('a, button { color: red; }');
expect(out).toContain('.custom-css-scope a');
expect(out).toContain('.custom-css-scope button');
});
it('does not double-prefix already-scoped selectors', () => {
const out = scopeCss('.custom-css-scope .foo { color: red; }');
expect(out).not.toMatch(/\.custom-css-scope\s+\.custom-css-scope/);
});
it('recurses into @media blocks', () => {
const out = scopeCss('@media (max-width: 768px) { body { display: none; } }');
expect(out).toContain('.custom-css-scope body');
});
it('returns empty for empty input', () => {
expect(scopeCss('')).toBe('');
});
});
+46 -2
View File
@@ -133,14 +133,32 @@ export const AuditAction = {
USER_UPDATED: 'user_updated',
BOARD_CREATED: 'board_created',
BOARD_DELETED: 'board_deleted',
BOARD_UPDATED: 'board_updated',
APP_CREATED: 'app_created',
APP_DELETED: 'app_deleted',
APP_UPDATED: 'app_updated',
SETTINGS_UPDATED: 'settings_updated',
IMPORT: 'import',
EXPORT: 'export',
BACKUP_CREATED: 'backup_created',
BACKUP_RESTORED: 'backup_restored',
BACKUP_DELETED: 'backup_deleted'
BACKUP_DELETED: 'backup_deleted',
LOGIN_SUCCESS: 'login_success',
LOGIN_FAILED: 'login_failed',
LOGOUT: 'logout',
SESSION_REVOKED: 'session_revoked',
API_TOKEN_CREATED: 'api_token_created',
API_TOKEN_REVOKED: 'api_token_revoked',
INVITE_CREATED: 'invite_created',
INVITE_USED: 'invite_used',
INVITE_REVOKED: 'invite_revoked',
OAUTH_LOGIN: 'oauth_login',
OAUTH_USER_PROVISIONED: 'oauth_user_provisioned',
PERMISSION_GRANTED: 'permission_granted',
PERMISSION_REVOKED: 'permission_revoked',
PASSWORD_CHANGED: 'password_changed',
PASSWORD_RESET_REQUESTED: 'password_reset_requested',
PASSWORD_RESET_COMPLETED: 'password_reset_completed'
} as const;
export type AuditAction = (typeof AuditAction)[keyof typeof AuditAction];
@@ -160,7 +178,33 @@ export const DEFAULTS = {
HEALTHCHECK_METHOD: 'GET',
JWT_EXPIRY: '15m',
REFRESH_TOKEN_EXPIRY_DAYS: 7,
REMEMBER_ME_REFRESH_TOKEN_EXPIRY_DAYS: 30,
DEFAULT_THEME: 'dark',
DEFAULT_PRIMARY_COLOR: '#6366f1',
SYSTEM_SETTINGS_ID: 'singleton'
SYSTEM_SETTINGS_ID: 'singleton',
SALT_ROUNDS: 12,
INVITE_DEFAULT_EXPIRY_DAYS: 7,
AUDIT_LOG_RETENTION_DAYS: 90,
APP_STATUS_RETENTION_HOURS: 24,
APP_CLICK_RETENTION_DAYS: 90,
NOTIFICATION_RETENTION_DAYS: 30,
HEALTHCHECK_CONCURRENCY: 8,
MIN_PASSWORD_LENGTH: 8,
MIN_ADMIN_PASSWORD_LENGTH: 12,
MAX_PASSWORD_LENGTH: 128,
MAX_UPLOAD_SIZE_BYTES: 5 * 1024 * 1024,
MAX_REMOTE_RESPONSE_BYTES: 1 * 1024 * 1024,
REMOTE_FETCH_DEFAULT_TIMEOUT_MS: 10_000,
BCRYPT_DUMMY_HASH: '$2a$12$abcdefghijklmnopqrstuuvwxyz012345678901234567890123ab'
} as const;
// Forbidden JWT_SECRET / encryption key placeholder values
export const FORBIDDEN_SECRETS = new Set<string>([
'change-me-to-a-random-64-char-string',
'change-me',
'changeme',
'secret',
'jwt-secret'
]);
export const MIN_SECRET_LENGTH = 32;
+258
View File
@@ -0,0 +1,258 @@
/**
* CSS sanitizer for user-supplied custom CSS (board / system settings).
*
* Strategy:
* 1. Decode CSS unicode escapes (`\65`/`\65\20`/`\000065`) BEFORE pattern
* matching so `expr\65ssion(` cannot bypass via escape obfuscation.
* 2. Reject @import / @charset / @namespace / @document outright; allow only
* `@media`, `@supports`, `@keyframes`, `@font-face`, `@page`.
* 3. Block known XSS vectors (`expression(`, `javascript:`, `vbscript:`,
* `behavior:`, `-moz-binding:`, `<script`).
* 4. Strip `url(...)` arguments unless they reference safe schemes (relative,
* data:image/*, https:, http:) this defeats blind exfiltration via
* `background: url(https://attacker.example/log?c=...)`.
* `url(data:text/*)` and `url(data:application/*)` are rejected.
*
* Notes:
* - This isn't a full CSS parser. We accept that complex selectors with
* attribute-value selectors against form fields (e.g. `input[value="a"]`)
* are still possible they're a known CSS-exfiltration vector but require
* a malicious admin actor. Document this explicitly and offer a future
* postcss-based hardening pass.
* - The output is intended to be wrapped in `.custom-css-scope { ... }`
* by the consumer.
*/
const FORBIDDEN_AT_RULES = new Set([
'import',
'charset',
'namespace',
'document',
'-moz-document',
'apply',
'use'
]);
const FORBIDDEN_SUBSTRINGS = [
'expression(',
'javascript:',
'vbscript:',
'behavior:',
'-moz-binding:',
'<script',
'</script',
'<iframe',
'<object',
'<embed'
];
function decodeCssEscapes(input: string): string {
// `\<hex 1-6>` optionally followed by a single whitespace char.
// Decodes \65 → 'e', \000065 → 'e', \65\20 → 'e '.
return input.replace(/\\([0-9a-fA-F]{1,6})(\s)?/g, (_match, hex, ws) => {
const codePoint = parseInt(hex, 16);
if (Number.isNaN(codePoint) || codePoint > 0x10ffff || codePoint === 0) {
return ws ?? '';
}
try {
const ch = String.fromCodePoint(codePoint);
return ch + (ws === undefined ? '' : '');
} catch {
return '';
}
});
}
function stripUrls(input: string): string {
// Replace url(...) with a safe-or-empty equivalent.
// Accept: url(relative), url(/abs), url(data:image/...), url(http(s)://...)
// Reject: anything that decodes to javascript:, file:, data:text, etc.
return input.replace(/url\s*\(\s*(['"]?)([^)'"]+)\1\s*\)/gi, (_match, q, raw) => {
const cleaned = decodeCssEscapes(raw).trim();
if (cleaned.length === 0) return `url(${q}${q})`;
const lower = cleaned.toLowerCase();
if (
lower.startsWith('javascript:') ||
lower.startsWith('vbscript:') ||
lower.startsWith('file:') ||
lower.startsWith('data:text/') ||
lower.startsWith('data:application/')
) {
return 'url("")';
}
// Relative, absolute-path, https, http, data:image/* — allowed.
return `url(${q}${cleaned}${q})`;
});
}
function dropForbiddenAtRules(input: string): string {
// Match `@<name>...;` (single-line at-rules like @import / @charset) AND
// `@<name> { ... }` blocks. We only strip the matching at-rule, keeping
// surrounding content intact.
let out = input;
// At-rules ending with semicolon. Use `\s*` (not `\s+`) so we also catch
// `@import"evil.css";` (no whitespace between name and string).
out = out.replace(/@([\w-]+)\s*[^;{}]*;/g, (m, name) => {
return FORBIDDEN_AT_RULES.has(String(name).toLowerCase()) ? '' : m;
});
// At-rules with blocks. Use a depth counter to find the matching `}`.
const result: string[] = [];
let i = 0;
while (i < out.length) {
if (out[i] === '@') {
// Read at-rule name
const nameMatch = /^@([\w-]+)/.exec(out.slice(i));
if (nameMatch) {
const name = nameMatch[1].toLowerCase();
// Find next `{` or `;`
let j = i + nameMatch[0].length;
while (j < out.length && out[j] !== '{' && out[j] !== ';' && out[j] !== '}') j++;
if (out[j] === '{') {
// Find matching `}`
let depth = 1;
let k = j + 1;
while (k < out.length && depth > 0) {
if (out[k] === '{') depth++;
else if (out[k] === '}') depth--;
k++;
}
if (FORBIDDEN_AT_RULES.has(name)) {
i = k;
continue;
}
result.push(out.slice(i, k));
i = k;
continue;
}
}
}
result.push(out[i]);
i++;
}
return result.join('');
}
/**
* Sanitize untrusted CSS. Renderer should still wrap in a `.custom-css-scope`
* selector at injection time (CustomCssInjector does this). Note: a `<style>`
* tag's rules apply to the WHOLE document, so the wrapper div alone does not
* scope rules the consumer must use the scoping helper below if scoping is
* desired. We keep sanitization separate from scoping so static settings can
* choose to be global.
*/
export function sanitizeCss(input: string): string {
if (!input) return '';
const stripped = input.replace(/\/\*[\s\S]*?\*\//g, ''); // remove comments
let cleaned = decodeCssEscapes(stripped);
const lower = cleaned.toLowerCase();
for (const bad of FORBIDDEN_SUBSTRINGS) {
if (lower.includes(bad)) {
cleaned = cleaned.replace(new RegExp(escapeRegExp(bad), 'gi'), '');
}
}
cleaned = dropForbiddenAtRules(cleaned);
cleaned = stripUrls(cleaned);
if (cleaned.length > 20_000) cleaned = cleaned.slice(0, 20_000);
return cleaned.trim();
}
/**
* Prefix every top-level CSS selector with `.custom-css-scope` so the user's
* CSS only applies inside that wrapper. This is a best-effort prefixer that
* respects nested `@media`/`@supports` blocks. For `@keyframes` and `@font-face`
* the selector list is left untouched (they don't take selectors).
*
* Limitations:
* - CSS nesting (`& > a`) is not normalized; if used, the first selector is
* still prefixed but inner `&` references resolve against the prefixed parent.
* - The function preserves whitespace approximately, not exactly.
*/
export function scopeCss(input: string, scope = '.custom-css-scope'): string {
if (!input) return '';
const out: string[] = [];
let i = 0;
while (i < input.length) {
// Walk to the next selector start: skip leading whitespace.
const wsMatch = /^\s+/.exec(input.slice(i));
if (wsMatch) {
out.push(wsMatch[0]);
i += wsMatch[0].length;
continue;
}
// At-rules with blocks: keep as-is, but recurse into block contents at depth 0.
if (input[i] === '@') {
const ruleMatch = /^@([\w-]+)[^;{}]*([;{])/.exec(input.slice(i));
if (!ruleMatch) {
out.push(input[i]);
i++;
continue;
}
const end = ruleMatch[2];
const ruleLen = ruleMatch[0].length;
out.push(input.slice(i, i + ruleLen));
i += ruleLen;
if (end === ';') continue;
const name = ruleMatch[1].toLowerCase();
const transparent = name === 'media' || name === 'supports' || name === 'document';
// Find matching }
let d = 1;
let j = i;
while (j < input.length && d > 0) {
if (input[j] === '{') d++;
else if (input[j] === '}') d--;
if (d === 0) break;
j++;
}
const blockBody = input.slice(i, j);
out.push(transparent ? scopeCss(blockBody, scope) : blockBody);
out.push(input[j] ?? '');
i = j + 1;
continue;
}
// Selector list ends at `{`.
const braceIdx = input.indexOf('{', i);
if (braceIdx === -1) {
out.push(input.slice(i));
break;
}
const selectorList = input.slice(i, braceIdx);
const scoped = selectorList
.split(',')
.map((s) => {
const t = s.trim();
if (!t) return t;
// Don't double-scope or scope :root / @-rule-like fragments.
if (t.startsWith(scope)) return t;
return `${scope} ${t}`;
})
.join(', ');
out.push(scoped);
// Find matching } to keep block intact.
let d = 1;
let j = braceIdx + 1;
while (j < input.length && d > 0) {
if (input[j] === '{') d++;
else if (input[j] === '}') d--;
if (d === 0) break;
j++;
}
out.push(input.slice(braceIdx, j + 1));
i = j + 1;
}
return out.join('');
}
function escapeRegExp(s: string): string {
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
+70 -9
View File
@@ -11,28 +11,82 @@ import {
CardSize,
NotificationType,
ApiTokenScope,
AuditAction
AuditAction,
DEFAULTS
} from './constants.js';
// --- Auth ---
const COMMON_PASSWORDS = new Set([
'password',
'password1',
'12345678',
'qwerty',
'qwerty123',
'letmein',
'welcome',
'admin',
'administrator',
'changeme',
'iloveyou',
'monkey'
]);
function passwordPolicy(min: number) {
return z
.string()
.min(min, `Password must be at least ${min} characters`)
.max(DEFAULTS.MAX_PASSWORD_LENGTH, `Password must be at most ${DEFAULTS.MAX_PASSWORD_LENGTH} characters`)
.refine((p) => !COMMON_PASSWORDS.has(p.toLowerCase()), {
message: 'Password is too common'
});
}
export const userPasswordSchema = passwordPolicy(DEFAULTS.MIN_PASSWORD_LENGTH);
export const adminPasswordSchema = passwordPolicy(DEFAULTS.MIN_ADMIN_PASSWORD_LENGTH);
/**
* URL schema that only accepts http:// or https://. Use this everywhere we
* accept a URL the server will later fetch or the browser will navigate to,
* to block javascript:/data:/file: vectors.
*/
export const httpUrlSchema = z
.string()
.url('Invalid URL')
.refine(
(u) => {
try {
const p = new URL(u).protocol;
return p === 'http:' || p === 'https:';
} catch {
return false;
}
},
{ message: 'Only http(s) URLs are allowed' }
);
export const loginSchema = z.object({
email: z.string().email('Invalid email address'),
password: z.string().min(1, 'Password is required'),
password: z.string().min(1, 'Password is required').max(DEFAULTS.MAX_PASSWORD_LENGTH),
rememberMe: z.boolean().optional().default(false)
});
export const registerSchema = z.object({
email: z.string().email('Invalid email address'),
password: z.string().min(6, 'Password must be at least 6 characters'),
password: userPasswordSchema,
displayName: z.string().min(1, 'Display name is required').max(100)
});
export const changePasswordSchema = z.object({
currentPassword: z.string().min(1).max(DEFAULTS.MAX_PASSWORD_LENGTH),
newPassword: userPasswordSchema
});
// --- User ---
export const createUserSchema = z.object({
email: z.string().email('Invalid email address'),
password: z.string().min(6).optional(),
password: userPasswordSchema.optional(),
displayName: z.string().min(1).max(100),
avatarUrl: z.string().url().optional(),
authProvider: z.enum([AuthMode.LOCAL, AuthMode.OAUTH]).optional(),
@@ -65,7 +119,7 @@ export const updateGroupSchema = z.object({
export const createAppSchema = z.object({
name: z.string().min(1, 'App name is required').max(200),
url: z.string().url('Invalid URL'),
url: httpUrlSchema,
icon: z.string().max(500).optional(),
iconType: z.enum([IconType.LUCIDE, IconType.SIMPLE, IconType.URL, IconType.EMOJI]).optional(),
description: z.string().max(1000).optional(),
@@ -83,7 +137,7 @@ export const createAppSchema = z.object({
export const updateAppSchema = z.object({
name: z.string().min(1).max(200).optional(),
url: z.string().url().optional(),
url: httpUrlSchema.optional(),
icon: z.string().max(500).nullable().optional(),
iconType: z.enum([IconType.LUCIDE, IconType.SIMPLE, IconType.URL, IconType.EMOJI]).optional(),
description: z.string().max(1000).nullable().optional(),
@@ -150,12 +204,14 @@ export const updateSectionSchema = z.object({
// --- Widget Config Schemas ---
const httpUrl = httpUrlSchema;
export const appWidgetConfigSchema = z.object({
appId: z.string().min(1, 'App ID is required')
});
export const bookmarkWidgetConfigSchema = z.object({
url: z.string().url('Invalid URL'),
url: httpUrl,
label: z.string().min(1, 'Label is required').max(200),
icon: z.string().max(100).optional(),
description: z.string().max(500).optional()
@@ -167,9 +223,14 @@ export const noteWidgetConfigSchema = z.object({
});
export const embedWidgetConfigSchema = z.object({
url: z.string().url('Invalid URL'),
url: httpUrl,
height: z.number().int().min(100).max(2000).default(300),
sandbox: z.string().max(200).optional()
// Default to a strict sandbox; explicit override required to relax.
sandbox: z
.string()
.max(200)
.optional()
.default('allow-scripts allow-same-origin allow-forms allow-popups')
});
export const statusWidgetConfigSchema = z.object({
+64
View File
@@ -0,0 +1,64 @@
<script lang="ts">
import { page } from '$app/stores';
import { t } from 'svelte-i18n';
import AmbientBackground from '$lib/components/background/AmbientBackground.svelte';
import ErrorState from '$lib/components/ui/ErrorState.svelte';
const status = $derived($page.status);
const message = $derived($page.error?.message ?? '');
const code = $derived(($page.error as { code?: string } | null)?.code);
const titleKey = $derived.by(() => {
if (status === 401) return 'error.unauthorized_title';
if (status === 403) return 'error.forbidden_title';
if (status === 404) return 'error.not_found_title';
if (status === 429) return 'error.rate_limited_title';
return 'error.generic_title';
});
const hintKey = $derived.by(() => {
if (status === 401) return 'error.unauthorized_hint';
if (status === 403) return 'error.forbidden_hint';
if (status === 404) return 'error.not_found_hint';
if (status === 429) return 'error.rate_limited_hint';
return 'error.generic_hint';
});
const title = $derived($t(titleKey));
const hint = $derived($t(hintKey));
</script>
<svelte:head>
<title>{status}{$t('app_title')}</title>
</svelte:head>
<AmbientBackground />
<ErrorState {status} {title} {hint} standalone>
{#snippet details()}
{#if message && message !== title}
<details class="rounded-md bg-muted/40">
<summary class="cursor-pointer p-3 text-xs text-muted-foreground hover:text-foreground">
{$t('error.technical_details')}
</summary>
<pre
class="overflow-auto border-t border-border px-3 py-2 text-xs text-muted-foreground">{message}{code ? ` (${code})` : ''}</pre>
</details>
{/if}
{/snippet}
{#snippet actions()}
<a
href="/"
class="rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90"
>
{$t('error.back_to_dashboard')}
</a>
{#if status === 401}
<a
href="/login"
class="rounded-lg border border-border px-4 py-2 text-sm font-medium transition-colors hover:bg-accent"
>
{$t('auth.login_submit')}
</a>
{/if}
{/snippet}
</ErrorState>
+40
View File
@@ -0,0 +1,40 @@
<script lang="ts">
import { page } from '$app/stores';
import { t } from 'svelte-i18n';
import ErrorState from '$lib/components/ui/ErrorState.svelte';
const status = $derived($page.status);
const message = $derived($page.error?.message ?? '');
const title = $derived.by(() => {
if (status === 403) return $t('error.admin_forbidden_title');
if (status === 404) return $t('error.admin_not_found_title');
return $t('error.admin_generic_title');
});
const hint = $derived.by(() => {
if (status === 403) return $t('error.admin_forbidden_hint');
return message || $t('error.generic_hint');
});
</script>
<svelte:head>
<title>{status}{$t('app_title')}</title>
</svelte:head>
<ErrorState {status} {title} {hint}>
{#snippet actions()}
<a
href="/"
class="rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90"
>
{$t('error.back_to_dashboard')}
</a>
<a
href="/admin/users"
class="rounded-lg border border-border px-4 py-2 text-sm font-medium transition-colors hover:bg-accent"
>
{$t('admin.users') ?? 'Admin users'}
</a>
{/snippet}
</ErrorState>
+26 -20
View File
@@ -8,10 +8,11 @@
const navItems = $derived([
{ href: '/admin/users', labelKey: 'admin.users' },
{ href: '/admin/invites', label: 'Invites' },
{ href: '/admin/invites', labelKey: 'admin.invites' },
{ href: '/admin/password-resets', labelKey: 'admin.password_resets' },
{ href: '/admin/groups', labelKey: 'admin.groups' },
{ href: '/admin/tags', label: 'Tags' },
{ href: '/admin/audit-log', label: 'Audit Log' },
{ href: '/admin/tags', labelKey: 'admin.tags' },
{ href: '/admin/audit-log', labelKey: 'admin.audit_log' },
{ href: '/admin/settings', labelKey: 'admin.settings' }
]);
@@ -22,23 +23,28 @@
<div class="p-6">
<div class="mx-auto max-w-6xl">
<!-- Admin header -->
<div class="mb-6 flex flex-wrap items-center gap-4 rounded-xl border border-border bg-card p-4 shadow-sm">
<span class="text-sm font-semibold text-foreground">{$t('admin.panel')}</span>
<div class="flex gap-1">
{#each navItems as item (item.href)}
<a
href={item.href}
class="rounded-lg px-3 py-1.5 text-sm font-medium transition-colors {isActive(item.href)
? 'bg-primary text-primary-foreground'
: 'text-muted-foreground hover:bg-accent hover:text-foreground'}"
>
{item.labelKey ? $t(item.labelKey) : item.label}
</a>
{/each}
</div>
<div class="ml-auto text-xs text-muted-foreground">
{data.user.displayName} ({$t('admin.role_admin').toLowerCase()})
<!-- Admin header. On md+: single-row, nav scrolls horizontally if needed.
Below md: stacks so username doesn't squeeze the nav onto two lines. -->
<div class="mb-6 rounded-xl border border-border bg-card p-4 shadow-sm">
<div class="flex flex-col gap-3 md:flex-row md:items-center md:gap-4">
<span class="text-sm font-semibold text-foreground">{$t('admin.panel')}</span>
<div class="-mx-1 flex gap-1 overflow-x-auto px-1">
{#each navItems as item (item.href)}
<a
href={item.href}
class="shrink-0 rounded-lg px-3 py-2 text-sm font-medium transition-colors {isActive(
item.href
)
? 'bg-primary text-primary-foreground'
: 'text-muted-foreground hover:bg-accent hover:text-foreground'}"
>
{$t(item.labelKey)}
</a>
{/each}
</div>
<div class="text-xs text-muted-foreground md:ml-auto">
{data.user.displayName} ({$t('admin.role_admin').toLowerCase()})
</div>
</div>
</div>
+1 -1
View File
@@ -7,7 +7,7 @@
</script>
<svelte:head>
<title>Audit Log — {$t('admin.panel')}</title>
<title>{$t('admin.audit_log') ?? 'Audit Log'}{$t('admin.panel')}</title>
</svelte:head>
<div>
+5 -2
View File
@@ -1,5 +1,6 @@
<script lang="ts">
import { invalidateAll } from '$app/navigation';
import { t } from 'svelte-i18n';
import type { PageData } from './$types.js';
let { data }: { data: PageData } = $props();
@@ -82,7 +83,7 @@
</script>
<svelte:head>
<title>Invites</title>
<title>{$t('admin.invites') ?? 'Invites'}{$t('admin.panel') ?? 'Admin'}</title>
</svelte:head>
<div class="mx-auto max-w-4xl space-y-6 px-4 py-8">
@@ -211,7 +212,9 @@
<th class="px-4 py-3 text-left font-medium">Status</th>
<th class="px-4 py-3 text-left font-medium">Expires</th>
<th class="px-4 py-3 text-left font-medium">Created</th>
<th class="px-4 py-3"></th>
<th class="px-4 py-3 text-right">
<span class="sr-only">Actions</span>
</th>
</tr>
</thead>
<tbody>
@@ -0,0 +1,19 @@
import type { PageServerLoad } from './$types.js';
import { requireAdmin } from '$lib/server/middleware/authorize.js';
import * as passwordResetService from '$lib/server/services/passwordResetService.js';
export const load: PageServerLoad = async (event) => {
requireAdmin(event);
const resets = await passwordResetService.listPendingResets();
return {
resets: resets.map((r) => ({
id: r.id,
userId: r.userId,
userEmail: r.user.email,
userDisplayName: r.user.displayName,
expiresAt: r.expiresAt.toISOString(),
createdAt: r.createdAt.toISOString(),
createdById: r.createdById
}))
};
};
@@ -0,0 +1,233 @@
<script lang="ts">
import { invalidateAll } from '$app/navigation';
import { t } from 'svelte-i18n';
import ConfirmDialog from '$lib/components/ui/ConfirmDialog.svelte';
import type { PageData } from './$types.js';
let { data }: { data: PageData } = $props();
let email = $state('');
let issuing = $state(false);
let issuedLink = $state<{ url: string; expiresAt: string } | null>(null);
let infoMessage = $state<string | null>(null);
let errorMessage = $state<string | null>(null);
let copyToast = $state(false);
let pendingRevokeId = $state<string | null>(null);
async function issueReset(e: Event) {
e.preventDefault();
if (issuing || !email.trim()) return;
issuing = true;
issuedLink = null;
errorMessage = null;
infoMessage = null;
try {
const res = await fetch('/api/admin/password-resets', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: email.trim() })
});
const json = await res.json();
if (!res.ok || !json.success) {
errorMessage = json.error ?? 'Failed to issue reset link.';
return;
}
if (json.data?.resetUrl) {
issuedLink = { url: json.data.resetUrl, expiresAt: json.data.expiresAt };
email = '';
} else {
// Account doesn't exist or is OAuth-only — we don't tell the admin which.
infoMessage =
'Request processed. If a matching local account exists, a reset link is now active.';
email = '';
}
await invalidateAll();
} catch {
errorMessage = 'Network error. Please try again.';
} finally {
issuing = false;
}
}
async function confirmRevoke() {
if (!pendingRevokeId) return;
const id = pendingRevokeId;
pendingRevokeId = null;
const res = await fetch(`/api/admin/password-resets/${id}`, { method: 'DELETE' });
if (res.ok) await invalidateAll();
}
async function copyLink(url: string) {
try {
await navigator.clipboard.writeText(url);
copyToast = true;
setTimeout(() => (copyToast = false), 1500);
} catch {
window.prompt('Copy reset link:', url);
}
}
function formatRelative(iso: string): string {
const ms = new Date(iso).getTime() - Date.now();
if (ms <= 0) return 'expired';
const hours = Math.floor(ms / (60 * 60 * 1000));
if (hours > 0) return `expires in ${hours}h`;
const mins = Math.max(1, Math.floor(ms / (60 * 1000)));
return `expires in ${mins}m`;
}
</script>
<svelte:head>
<title>{$t('admin.password_resets') ?? 'Password Resets'}{$t('admin.panel') ?? 'Admin'}</title>
</svelte:head>
<div class="space-y-6">
<header>
<h1 class="text-xl font-semibold">{$t('admin.password_resets') ?? 'Password resets'}</h1>
<p class="mt-1 text-sm text-muted-foreground">
Issue a reset link for a user, then share it with them through your preferred channel.
Links expire after 24 hours and become single-use once the user sets a new password.
</p>
</header>
<!-- Issue new reset -->
<section class="rounded-xl border border-border bg-card p-5">
<h2 class="mb-3 text-sm font-semibold">Issue a reset link</h2>
<form onsubmit={issueReset} class="flex flex-col gap-3 sm:flex-row sm:items-end">
<label class="flex-1">
<span class="mb-1 block text-xs font-medium text-muted-foreground">User email</span>
<input
type="email"
required
bind:value={email}
placeholder="user@example.com"
class="w-full rounded-lg border border-input bg-background px-3 py-2 text-sm"
/>
</label>
<button
type="submit"
disabled={issuing}
class="w-full rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 disabled:opacity-50 sm:w-auto"
>
{issuing ? 'Issuing…' : 'Issue link'}
</button>
</form>
<!-- Success: a real link to copy -->
{#if issuedLink}
<div class="mt-4 rounded-lg border border-primary/30 bg-primary/5 p-4 text-sm">
<p class="mb-2 font-medium">Share this link with the user (shown once)</p>
<div class="flex items-center gap-2">
<input
type="text"
readonly
value={issuedLink.url}
class="flex-1 rounded-md border border-input bg-background px-2 py-1.5 font-mono text-xs"
onclick={(e) => (e.currentTarget as HTMLInputElement).select()}
/>
<button
type="button"
onclick={() => issuedLink && copyLink(issuedLink.url)}
class="rounded-md border border-input bg-background px-3 py-1.5 text-xs font-medium hover:bg-accent"
>
{copyToast ? 'Copied!' : 'Copy'}
</button>
</div>
<p class="mt-2 text-xs text-muted-foreground">
Expires {new Date(issuedLink.expiresAt).toLocaleString()} ({formatRelative(
issuedLink.expiresAt
)}).
</p>
</div>
{/if}
<!-- Silent success (account didn't exist or is OAuth-only) — info, not error -->
{#if infoMessage}
<div class="mt-4 rounded-lg border border-primary/20 bg-primary/5 p-3 text-sm text-foreground">
{infoMessage}
</div>
{/if}
<!-- Real failure -->
{#if errorMessage}
<div
class="mt-4 rounded-lg border border-destructive/30 bg-destructive/5 p-3 text-sm text-destructive"
>
{errorMessage}
</div>
{/if}
</section>
<!-- Pending resets table -->
<section class="overflow-hidden rounded-xl border border-border bg-card">
<header class="border-b border-border p-4">
<h2 class="text-sm font-semibold">Pending reset links ({data.resets.length})</h2>
<p class="mt-1 text-xs text-muted-foreground">
The raw token is only shown once — at the moment of issue. Re-issue if you lost the link.
</p>
</header>
{#if data.resets.length === 0}
<p class="p-6 text-center text-sm text-muted-foreground">No pending resets.</p>
{:else}
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead class="bg-muted/40 text-left text-xs uppercase tracking-wide text-muted-foreground">
<tr>
<th class="px-4 py-3 font-medium">User</th>
<th class="px-4 py-3 font-medium">Issued</th>
<th class="px-4 py-3 font-medium">Expires</th>
<th class="px-4 py-3 text-right font-medium">
<span class="sr-only">Actions</span>
</th>
</tr>
</thead>
<tbody>
{#each data.resets as r (r.id)}
<tr class="border-t border-border">
<td class="px-4 py-3">
<div class="font-medium">{r.userDisplayName}</div>
<div class="text-xs text-muted-foreground">{r.userEmail}</div>
</td>
<td class="px-4 py-3 text-xs text-muted-foreground">
{new Date(r.createdAt).toLocaleString()}
{#if r.createdById}
<div class="text-[10px] uppercase tracking-wide">by admin</div>
{:else}
<div class="text-[10px] uppercase tracking-wide">self-service</div>
{/if}
</td>
<td class="px-4 py-3 text-xs text-muted-foreground">
{new Date(r.expiresAt).toLocaleString()}
<div class="text-[10px] text-muted-foreground/80">
{formatRelative(r.expiresAt)}
</div>
</td>
<td class="px-4 py-3 text-right">
<button
type="button"
onclick={() => (pendingRevokeId = r.id)}
class="rounded-md border border-destructive/30 px-3 py-1 text-xs font-medium text-destructive transition-colors hover:bg-destructive/10"
>
Revoke
</button>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</section>
</div>
{#if pendingRevokeId}
<ConfirmDialog
title="Revoke reset link?"
message="The user will no longer be able to use this link to reset their password. They can request a new one if needed."
confirmLabel="Revoke"
onConfirm={confirmRevoke}
onCancel={() => (pendingRevokeId = null)}
/>
{/if}

Some files were not shown because too many files have changed in this diff Show More