diff --git a/.dockerignore b/.dockerignore index 5f5dc3d..5f7c707 100644 --- a/.dockerignore +++ b/.dockerignore @@ -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 diff --git a/.env.example b/.env.example index 79902c6..3b9ed01 100644 --- a/.env.example +++ b/.env.example @@ -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 `. When unset, the endpoint is open (typical +# when the scraper lives on the same private network). +METRICS_TOKEN="" + # Node environment NODE_ENV="production" diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index e012432..8e024cf 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -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" \ diff --git a/.gitea/workflows/test.yml b/.gitea/workflows/test.yml index 1a48973..485a39b 100644 --- a/.gitea/workflows/test.yml +++ b/.gitea/workflows/test.yml @@ -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 diff --git a/.vex.toml b/.vex.toml new file mode 100644 index 0000000..b612962 --- /dev/null +++ b/.vex.toml @@ -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 `/.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 diff --git a/Dockerfile b/Dockerfile index c95534c..1ee1311 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md index 5ad74b8..3badfe2 100644 --- a/README.md +++ b/README.md @@ -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 ``` diff --git a/docker-compose.yml b/docker-compose.yml index 835a138..150d14a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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: diff --git a/eslint.config.js b/eslint.config.js index eac33c7..cc894f5 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -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' } }, { diff --git a/package.json b/package.json index 16927fa..2b20c6a 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/prisma/migrations/20260526000000_hardening_indexes_and_session_reuse/migration.sql b/prisma/migrations/20260526000000_hardening_indexes_and_session_reuse/migration.sql new file mode 100644 index 0000000..649b765 --- /dev/null +++ b/prisma/migrations/20260526000000_hardening_indexes_and_session_reuse/migration.sql @@ -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"); diff --git a/prisma/migrations/20260527000000_add_password_reset/migration.sql b/prisma/migrations/20260527000000_add_password_reset/migration.sql new file mode 100644 index 0000000..449781b --- /dev/null +++ b/prisma/migrations/20260527000000_add_password_reset/migration.sql @@ -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"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 8effe83..473c5ee 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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]) } diff --git a/src/app.d.ts b/src/app.d.ts index 46dcd8e..f42c5b0 100644 --- a/src/app.d.ts +++ b/src/app.d.ts @@ -12,6 +12,7 @@ declare global { id: string; email: string; displayName: string; + avatarUrl: string | null; role: 'admin' | 'user'; } | null; session: { diff --git a/src/hooks.server.ts b/src/hooks.server.ts index 43bfc37..b4e7ffa 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -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 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) + }; }; diff --git a/src/lib/components/admin/AuditLogTable.svelte b/src/lib/components/admin/AuditLogTable.svelte index 1b9833f..98c38be 100644 --- a/src/lib/components/admin/AuditLogTable.svelte +++ b/src/lib/components/admin/AuditLogTable.svelte @@ -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 }); diff --git a/src/lib/components/app/AppForm.svelte b/src/lib/components/app/AppForm.svelte index 9877762..0e9a780 100644 --- a/src/lib/components/app/AppForm.svelte +++ b/src/lib/components/app/AppForm.svelte @@ -42,7 +42,22 @@ }) .catch(() => {}); }); - let availableIntegrations = $state>([]); + 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>({}); let testingConnection = $state(false); let testResult = $state<{ success: boolean; message: string } | null>(null); diff --git a/src/lib/components/board/BoardShareDialog.svelte b/src/lib/components/board/BoardShareDialog.svelte index 906bd4c..8a9591c 100644 --- a/src/lib/components/board/BoardShareDialog.svelte +++ b/src/lib/components/board/BoardShareDialog.svelte @@ -148,7 +148,6 @@ -
+ 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 {#if sanitizedCss} diff --git a/src/lib/components/notifications/NotificationChannelForm.svelte b/src/lib/components/notifications/NotificationChannelForm.svelte index 8ab3dcd..988577c 100644 --- a/src/lib/components/notifications/NotificationChannelForm.svelte +++ b/src/lib/components/notifications/NotificationChannelForm.svelte @@ -1,4 +1,5 @@ {#if search.open} -
- interface Props { - count?: number; - } - - let { count = 3 }: Props = $props(); - - const items = $derived(Array.from({ length: count }, (_, i) => i)); - - -{#each items as i (i)} -
-
-
-
-
-
-
-
-
-
-{/each} diff --git a/src/lib/components/skeleton/CardSkeleton.svelte b/src/lib/components/skeleton/CardSkeleton.svelte deleted file mode 100644 index 423b837..0000000 --- a/src/lib/components/skeleton/CardSkeleton.svelte +++ /dev/null @@ -1,21 +0,0 @@ - - -{#each items as i (i)} -
-
-
-
-
-
-
-
-
-{/each} diff --git a/src/lib/components/skeleton/SectionSkeleton.svelte b/src/lib/components/skeleton/SectionSkeleton.svelte deleted file mode 100644 index 6fcdefa..0000000 --- a/src/lib/components/skeleton/SectionSkeleton.svelte +++ /dev/null @@ -1,32 +0,0 @@ - - -{#each sections as s (s)} -
- -
-
-
-
- - -
- {#each widgets as w (w)} -
-
-
-
-
- {/each} -
-
-{/each} diff --git a/src/lib/components/ui/AutocompleteInput.svelte b/src/lib/components/ui/AutocompleteInput.svelte index b3abfe9..7ba9aed 100644 --- a/src/lib/components/ui/AutocompleteInput.svelte +++ b/src/lib/components/ui/AutocompleteInput.svelte @@ -92,7 +92,7 @@ {#if open && filtered.length > 0}
- {#each filtered as item, i} + {#each filtered as item, i (item)}
- - +
{$t('widget.apps') ?? 'Apps'}
({ value: a.id, label: a.name, icon: a.icon, iconType: a.iconType }))} bind:values={statusAppIds} @@ -349,13 +347,11 @@ {:else if widgetType === 'system_stats'}
-
- @@ -373,13 +368,11 @@ {:else if widgetType === 'rss'}
-
- @@ -391,9 +384,8 @@ {:else if widgetType === 'calendar'}
- - - {#each calendarUrlsRaw as cal, i} +
iCal URLs
+ {#each calendarUrlsRaw as cal, i (i)}
@@ -477,9 +469,8 @@ {:else if widgetType === 'link_group'}
- - - {#each linkGroupLinks as link, i} +
Links
+ {#each linkGroupLinks as link, i (i)}
@@ -531,7 +522,7 @@
- +
+ + + {$t('auth.forgot_password')} + +
{#if data.invite} -
- You have been invited to join - {#if data.invite.role === 'admin'} - as an administrator - {/if}. +
+

+ {#if data.invite.role === 'admin'} + {$t('auth.invite_banner_admin') ?? "You've been invited to join as an"} + {$t('admin.role_admin') ?? 'administrator'}. + {:else} + {$t('auth.invite_banner_user') ?? "You've been invited to join."} + {/if} +

{#if data.invite.lockedEmail} - This invite is locked to {data.invite.lockedEmail}. +

+ {$t('auth.invite_banner_locked') ?? 'This invite is locked to'} + {data.invite.lockedEmail}. +

{/if}
{/if} diff --git a/src/routes/reset-password/+page.server.ts b/src/routes/reset-password/+page.server.ts new file mode 100644 index 0000000..5c9dcde --- /dev/null +++ b/src/routes/reset-password/+page.server.ts @@ -0,0 +1,89 @@ +import { fail, redirect } from '@sveltejs/kit'; +import type { Actions, PageServerLoad } from './$types.js'; +import * as passwordResetService from '$lib/server/services/passwordResetService.js'; +import { logAction } from '$lib/server/services/auditLogService.js'; +import { userPasswordSchema } from '$lib/utils/validators.js'; +import { AuditAction } from '$lib/utils/constants.js'; +import { + passwordResetRateLimiter, + enforceRateLimit, + rateKeyFromEvent +} from '$lib/server/utils/rateLimit.js'; +import { RateLimitError } from '$lib/server/errors.js'; +import { z } from 'zod'; + +export const load: PageServerLoad = async ({ locals, url }) => { + if (locals.user) throw redirect(302, '/'); + + const token = url.searchParams.get('token'); + if (!token) { + return { tokenValid: false, email: null }; + } + + const reset = await passwordResetService.findResetByToken(token); + if (!reset) { + return { tokenValid: false, email: null }; + } + + return { + tokenValid: true, + email: reset.user.email + }; +}; + +const applySchema = z.object({ + token: z.string().min(1), + password: userPasswordSchema, + confirmPassword: z.string().min(1) +}); + +export const actions: Actions = { + default: async (event) => { + const data = await event.request.formData(); + const parsed = applySchema.safeParse({ + token: data.get('token'), + password: data.get('password'), + confirmPassword: data.get('confirmPassword') + }); + + if (!parsed.success) { + return fail(400, { + error: parsed.error.errors[0]?.message ?? 'Invalid input' + }); + } + + if (parsed.data.password !== parsed.data.confirmPassword) { + return fail(400, { error: 'Passwords do not match' }); + } + + try { + enforceRateLimit(passwordResetRateLimiter, rateKeyFromEvent(event)); + } catch (err) { + if (err instanceof RateLimitError) { + return fail(429, { error: err.message }); + } + throw err; + } + + const reset = await passwordResetService.findResetByToken(parsed.data.token); + if (!reset) { + return fail(400, { + error: 'Reset link is invalid, expired, or has already been used.' + }); + } + + try { + await passwordResetService.applyReset(parsed.data.token, parsed.data.password); + } catch (err) { + return fail(400, { + error: err instanceof Error ? err.message : 'Reset failed' + }); + } + + logAction(reset.userId, AuditAction.PASSWORD_RESET_COMPLETED, 'user', reset.userId, { + ip: rateKeyFromEvent(event) + }); + + throw redirect(302, '/login?reset=success'); + } +}; diff --git a/src/routes/reset-password/+page.svelte b/src/routes/reset-password/+page.svelte new file mode 100644 index 0000000..8a1c011 --- /dev/null +++ b/src/routes/reset-password/+page.svelte @@ -0,0 +1,112 @@ + + + + {$t('auth.reset_password_title') ?? 'Choose a new password'} — {$t('app_title')} + + + + +
+
+
+
+ +
+

+ {$t('auth.reset_password_title') ?? 'Choose a new password'} +

+ {#if data.tokenValid && data.email} +

+ {$t('auth.reset_password_for') ?? 'Resetting password for'} {data.email} +

+ {/if} +
+ + {#if !data.tokenValid} +
+

+ {$t('auth.reset_invalid_title') ?? 'Reset link is invalid'} +

+

+ {$t('auth.reset_invalid_hint') ?? + 'The link may have expired, already been used, or copied incorrectly. Ask your admin to issue a new one.'} +

+
+

+ + {$t('auth.request_new_reset') ?? 'Request a new reset link'} + +

+ {:else} +
{ + submitting = true; + return async ({ update }) => { + await update({ reset: false }); + submitting = false; + }; + }} + > + + + + + + + {#if form?.error} +

{form.error}

+ {/if} + + +
+ {/if} +
+
diff --git a/src/routes/status/+page.server.ts b/src/routes/status/+page.server.ts index 1596005..6e38f2f 100644 --- a/src/routes/status/+page.server.ts +++ b/src/routes/status/+page.server.ts @@ -1,20 +1,56 @@ import type { PageServerLoad } from './$types.js'; import * as uptimeService from '$lib/server/services/uptimeService.js'; +import { prisma } from '$lib/server/prisma.js'; +import { DEFAULTS } from '$lib/utils/constants.js'; -export const load: PageServerLoad = async ({ url }) => { +export const load: PageServerLoad = async ({ url, locals }) => { const timeRange = (url.searchParams.get('range') as '24h' | '7d' | '30d') ?? '24h'; const validRanges = ['24h', '7d', '30d']; const range = validRanges.includes(timeRange) ? timeRange : '24h'; + // Guard: when not authenticated, only show apps that appear on at least one + // guest-accessible board. This prevents the public /status page from leaking + // the complete inventory of internal services to unauthenticated visitors. + let visibleAppIds: Set | null = null; + if (!locals.user) { + const guestBoards = await prisma.board.findMany({ + where: { isGuestAccessible: true }, + select: { + sections: { + select: { + widgets: { select: { appId: true } } + } + } + } + }); + visibleAppIds = new Set(); + for (const b of guestBoards) { + for (const s of b.sections) { + for (const w of s.widgets) { + if (w.appId) visibleAppIds.add(w.appId); + } + } + } + } + + void DEFAULTS; + try { const [allAppsUptime, incidents] = await Promise.all([ uptimeService.getAllAppsUptime(range as '24h' | '7d' | '30d'), uptimeService.getIncidents(undefined, range as '24h' | '7d' | '30d') ]); - // Enrich each app with timeline data for sparkline charts + const filteredApps = visibleAppIds + ? allAppsUptime.filter((a) => visibleAppIds!.has(a.appId)) + : allAppsUptime; + + const filteredIncidents = visibleAppIds + ? incidents.filter((i) => visibleAppIds!.has(i.appId)) + : incidents; + const appsWithTimelines = await Promise.all( - allAppsUptime.map(async (app) => { + filteredApps.map(async (app) => { const timeline = await uptimeService.getUptimeTimeline( app.appId, range as '24h' | '7d' | '30d' @@ -23,7 +59,6 @@ export const load: PageServerLoad = async ({ url }) => { }) ); - // Compute summary stats const totalApps = appsWithTimelines.length; const appsOnline = appsWithTimelines.filter((a) => a.currentStatus === 'online').length; const uptimeValues = appsWithTimelines @@ -37,7 +72,7 @@ export const load: PageServerLoad = async ({ url }) => { return { apps: appsWithTimelines, - incidents, + incidents: filteredIncidents, summary: { totalApps, appsOnline, overallUptime }, range }; diff --git a/src/routes/uploads/[...path]/+server.ts b/src/routes/uploads/[...path]/+server.ts new file mode 100644 index 0000000..3909eba --- /dev/null +++ b/src/routes/uploads/[...path]/+server.ts @@ -0,0 +1,68 @@ +import type { RequestHandler } from './$types'; +import { error } from '@sveltejs/kit'; +import { stat, readFile } from 'node:fs/promises'; +import { extname } from 'node:path'; +import { resolveUploadsPath } from '$lib/server/utils/uploads.js'; + +const MIME_BY_EXT: Record = { + '.svg': 'image/svg+xml', + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.webp': 'image/webp', + '.gif': 'image/gif' +}; + +/** + * GET /uploads/ — serve a stored upload (icon, wallpaper, etc). + * + * Serves from the persistent uploads volume (NOT the static build artifact), + * so files survive container rebuilds. Path-traversal protected via + * resolveUploadsPath. + * + * Security headers: + * - Content-Disposition: attachment for SVG, so even if a malicious SVG slips + * through the upload-time sanitizer it cannot execute in the launcher's + * origin via direct navigation. + * - X-Content-Type-Options: nosniff + * - Content-Security-Policy: default-src 'none'; sandbox — defense in depth + * for SVG. + */ +export const GET: RequestHandler = async ({ params }) => { + const relPath = params.path ?? ''; + if (!relPath) throw error(404, 'Not found'); + + let abs: string; + try { + abs = resolveUploadsPath(relPath); + } catch { + throw error(404, 'Not found'); + } + + let info; + try { + info = await stat(abs); + } catch { + throw error(404, 'Not found'); + } + if (!info.isFile()) throw error(404, 'Not found'); + + const ext = extname(abs).toLowerCase(); + const contentType = MIME_BY_EXT[ext] ?? 'application/octet-stream'; + + const data = await readFile(abs); + const headers: Record = { + 'Content-Type': contentType, + 'Content-Length': String(info.size), + 'Cache-Control': 'public, max-age=86400, immutable', + 'X-Content-Type-Options': 'nosniff' + }; + + if (ext === '.svg') { + // SVG can carry script; we sanitize on upload, but defense-in-depth: + // serve sandboxed so even a bypass cannot execute against the launcher origin. + headers['Content-Security-Policy'] = "default-src 'none'; style-src 'unsafe-inline'; sandbox"; + } + + return new Response(new Uint8Array(data), { status: 200, headers }); +}; diff --git a/src/service-worker.ts b/src/service-worker.ts index c4c2b29..d1c9fe6 100644 --- a/src/service-worker.ts +++ b/src/service-worker.ts @@ -48,16 +48,17 @@ self.addEventListener('fetch', (event: FetchEvent) => { // Skip cross-origin requests if (url.origin !== self.location.origin) return; - // Sensitive API paths: never cache, always go to network - const sensitiveApiPrefixes = ['/api/users/', '/api/admin/', '/api/auth/']; - if (sensitiveApiPrefixes.some((prefix) => url.pathname.startsWith(prefix))) { - event.respondWith(fetch(request)); - return; - } - - // API calls: network-first with cache fallback + // API calls: network-only by default. Most /api/* responses carry per-user + // data (notifications, sessions, tokens, favorites). Caching them on disk + // risks leaking one user's data to another via the same browser profile. + // We opt specific public endpoints into SWR caching via the allow-list. if (url.pathname.startsWith('/api/')) { - event.respondWith(networkFirst(request)); + const cacheableApiPrefixes = ['/api/health', '/api/uptime/public']; + if (cacheableApiPrefixes.some((prefix) => url.pathname.startsWith(prefix))) { + event.respondWith(networkFirst(request)); + return; + } + event.respondWith(fetch(request)); return; } @@ -98,13 +99,19 @@ async function cacheFirst(request: Request): Promise { /** * Network-first strategy: try network, fall back to cache. + * Honours server Cache-Control: no-store / private — never caches those. */ async function networkFirst(request: Request): Promise { try { const response = await fetch(request); if (response.ok) { - const cache = await caches.open(CACHE_NAME); - cache.put(request, response.clone()); + const cc = response.headers.get('cache-control') ?? ''; + const lower = cc.toLowerCase(); + const isPrivate = lower.includes('no-store') || lower.includes('private'); + if (!isPrivate) { + const cache = await caches.open(CACHE_NAME); + cache.put(request, response.clone()); + } } return response; } catch { diff --git a/svelte.config.js b/svelte.config.js index 4944713..707234b 100644 --- a/svelte.config.js +++ b/svelte.config.js @@ -4,6 +4,14 @@ import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'; /** @type {import('@sveltejs/kit').Config} */ const config = { preprocess: vitePreprocess(), + compilerOptions: { + // `state_referenced_locally` fires for the intentional Svelte 5 pattern + // `let local = $state(props.x)` where the component owns subsequent + // mutations of `local` (form fields, configurators, lazy-fetched lists). + // We use this pattern intentionally across ~25 components; per-line + // suppression would add ~99 noise comments. Disable project-wide. + warningFilter: (warning) => warning.code !== 'state_referenced_locally' + }, kit: { adapter: adapter({ out: 'build', diff --git a/tsconfig.json b/tsconfig.json index a8f10c8..51962ad 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -9,6 +9,7 @@ "skipLibCheck": true, "sourceMap": true, "strict": true, + "noImplicitOverride": true, "moduleResolution": "bundler" } }