diff --git a/.env.example b/.env.example index 8db203d..c6e4659 100644 --- a/.env.example +++ b/.env.example @@ -1,27 +1,68 @@ # Script Manager - Environment Configuration -# Copy this file to .env and adjust values as needed +# Copy this file to .env and adjust values as needed. -# Backend Configuration +# ── Backend ─────────────────────────────────────────────────────────────────── API_PORT=8000 DATABASE_PATH=/app/data/scripts.db -# Security Configuration -# IMPORTANT: Change this in production! Generate with: openssl rand -hex 32 -SECRET_KEY=your-secret-key-change-this-in-production +# Start uvicorn with auto-reload. Development only; never enable in production. +# API_RELOAD=false -# CORS Configuration -# Comma-separated list of allowed origins for CORS +# ── Security ────────────────────────────────────────────────────────────────── +# Signing key for JWT access tokens. Generate one with: openssl rand -hex 32 +# +# Leave this blank and the backend generates a random key on first start and +# persists it next to the database. Set it explicitly whenever you run more +# than one backend process, since every worker must sign with the same key. +SECRET_KEY= + +# Enforce authentication and RBAC on the API. Turning this off makes every +# endpoint reachable anonymously; only do that for local, single-user setups. +# REQUIRE_AUTH=true + +# Allow anyone to create their own account. Off by default: administrators +# create accounts from the Team page. +# ALLOW_SELF_REGISTRATION=false + +# How long an access token stays valid, in minutes (default: 24 hours). +# ACCESS_TOKEN_EXPIRE_MINUTES=1440 + +# ── CORS ────────────────────────────────────────────────────────────────────── +# Comma-separated list of allowed browser origins. Only needed when the API is +# served from a different origin than the UI; the bundled nginx proxies /api on +# the same origin, so the default is usually correct. ALLOWED_ORIGINS=http://localhost:3000,http://localhost:5173 -# Logging Configuration +# ── Scheduler and monitors ──────────────────────────────────────────────────── +# Runs due cron jobs and evaluates heartbeat monitors in-process. Disable it on +# replicas so only one instance executes jobs. +# ENABLE_SCHEDULER=true + +# How often the scheduler wakes up, in seconds. +# SCHEDULER_TICK_SECONDS=30 + +# A run whose scheduled time slipped further into the past than this is skipped +# rather than fired, so a backend that was offline does not stampede on restart. +# SCHEDULER_MAX_CATCHUP_SECONDS=3600 + +# Maximum captured stdout/stderr per job execution, in characters. +# JOB_LOG_MAX_CHARS=200000 + +# ── Storage limits ──────────────────────────────────────────────────────────── +# Where uploaded attachments are stored, and the per-file limit in bytes. +# ATTACHMENTS_DIR=/app/data/attachments +# MAX_ATTACHMENT_SIZE=10485760 + +# Largest script body returned by the content endpoint, in bytes. +# MAX_SCRIPT_CONTENT_BYTES=2097152 + +# ── Logging ─────────────────────────────────────────────────────────────────── # Log level: DEBUG, INFO, WARNING, ERROR, CRITICAL LOG_LEVEL=INFO -# Log format: text or json (json recommended for production with log aggregation) +# Log format: text or json (json recommended when shipping to log aggregation) LOG_FORMAT=text -# Frontend Configuration -VITE_API_URL=http://localhost:8000 - -# Optional: Script scanning directories -# These can be mounted as volumes in docker-compose.yml +# ── Script scanning ─────────────────────────────────────────────────────────── +# Directories to mount into the backend container so folder roots can reach +# them. Folder roots themselves are configured in the UI. SCRIPTS_ROOT_PATHS=/scripts:/opt/scripts:/mnt/scripts diff --git a/.github/workflows/build-test.yml b/.github/workflows/build-test.yml index c72a5c8..463fc37 100644 --- a/.github/workflows/build-test.yml +++ b/.github/workflows/build-test.yml @@ -8,37 +8,58 @@ permissions: contents: read jobs: - build-test: + frontend: + name: Frontend build runs-on: ubuntu-latest steps: - name: Checkout repository uses: actions/checkout@v4 + - name: Set up Node.js uses: actions/setup-node@v4 with: - node-version: "18" - - name: Install frontend dependencies - run: npm ci --prefix frontend - - name: Build frontend - run: npm --prefix frontend run build + # Vite 7 requires ^20.19 || >=22.12; the previous "18" here could + # never install, so this job failed on every run. + node-version: "22" + cache: npm + cache-dependency-path: frontend/package-lock.json + + - name: Install dependencies + run: npm ci + working-directory: frontend + + - name: Build + run: npm run build + working-directory: frontend + + backend: + name: Backend tests + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + - name: Set up Python uses: actions/setup-python@v5 with: python-version: "3.11" - - name: Install backend dependencies - run: | - if [ -f backend/requirements.txt ]; then - python -m pip install -r backend/requirements.txt - else - echo "No backend requirements found." - fi - - name: Backend syntax check - run: python -m compileall backend - - name: Run backend tests if present - run: | - if [ -d backend/tests ]; then - python -m pip install pytest pytest-asyncio httpx - python -m pytest backend/tests - else - echo "No backend tests found." - fi + cache: pip + cache-dependency-path: backend/requirements-dev.txt + + - name: Install dependencies + run: python -m pip install -r requirements-dev.txt + working-directory: backend + + - name: Syntax check + run: python -m compileall -q app main.py + working-directory: backend + + - name: Run tests + # Run from backend/ so pytest.ini (asyncio_mode = auto) and the + # package layout resolve the same way they do locally. + run: python -m pytest -q + working-directory: backend + env: + # The scheduler would otherwise start background subprocesses during + # the run; conftest sets this too, belt and braces. + ENABLE_SCHEDULER: "false" diff --git a/.gitignore b/.gitignore index 2f298af..eeba786 100644 --- a/.gitignore +++ b/.gitignore @@ -61,3 +61,6 @@ backend/data/ *.sqlite *.sqlite3 *.pid + +# Runtime state when the backend is started from the repository root +data/ diff --git a/Dockerfile.frontend b/Dockerfile.frontend index 288b234..ab710d9 100644 --- a/Dockerfile.frontend +++ b/Dockerfile.frontend @@ -29,7 +29,7 @@ EXPOSE 3000 # Health check HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ - CMD wget --no-verbose --tries=1 --spider http://localhost:3000/ || exit 1 + CMD wget --no-verbose --tries=1 --spider http://localhost:3000/healthz || exit 1 # Start nginx CMD ["nginx", "-g", "daemon off;"] diff --git a/README.md b/README.md index 7c66922..bc8b36a 100644 --- a/README.md +++ b/README.md @@ -27,10 +27,11 @@ A web application for managing large collections of script files. Index, search, - **Saved Searches**: Pin and reuse frequently used search queries - **Watch Mode**: Automatically detect filesystem changes in real time - **Heartbeat Monitors**: Track external cron jobs and services with fail-safe alerts -- **Schedule Jobs**: Run and manage cron-scheduled commands with execution history -- **Notifications**: Send alerts via Slack, Discord, email, webhook, PagerDuty, or SMS -- **Incident Management**: Automatically group and track failures as incidents -- **Authentication & RBAC**: JWT-based auth with role-based access control (admin, viewer, editor) +- **Built-in Scheduler**: Runs cron jobs in-process, with retries, overlap prevention, timeouts and full log capture +- **Notifications**: Real delivery via Slack, Discord, email (SMTP), generic webhook, PagerDuty or Twilio SMS +- **Incident Management**: Failures raise incidents automatically and resolve on recovery +- **Authentication & RBAC**: JWT auth enforced on every endpoint, with admin, editor and viewer roles +- **Dark Mode & Responsive UI**: Follows your system theme, works on phones and tablets ## Installation Wizard @@ -140,9 +141,17 @@ Heartbeat Monitors track external cron jobs, backup scripts, or any scheduled pr ### How it works -1. Create a monitor and note the generated `ping_key` -2. Add a curl call to the end of your cron job: `curl -s https://your-host/api/monitors/ping/` -3. Script Manager tracks pings and raises an incident if one is missed +1. Create a monitor. Its ping URL is shown once on creation, and can be fetched + again from `GET /api/monitors/{id}/ping-url` or the monitor's detail dialog. +2. Add a curl call to the end of your cron job: + `curl -fsS -m 10 --retry 3 -o /dev/null https://your-host/api/monitors/ping/` +3. A background task evaluates every monitor on a timer, so an incident is raised + and the configured channels are alerted whether or not anyone has the UI open. + A monitor that never receives its first ping is measured from its creation time. + +The ping endpoint is deliberately unauthenticated: the random ping key is the +credential, so a cron job needs nothing but the URL. For that reason the key is +kept out of monitor listings. ### Monitor API @@ -156,19 +165,32 @@ Heartbeat Monitors track external cron jobs, backup scripts, or any scheduled pr | `/api/monitors/ping/{ping_key}` | POST | Record a heartbeat ping | | `/api/monitors/{id}/pings` | GET | List recent ping history | | `/api/monitors/{id}/incidents` | GET | List incidents for a monitor | +| `/api/monitors/{id}/ping-url` | GET | Reveal the monitor's ping key | ## Schedule Jobs -Schedule Jobs let you define cron-scheduled tasks that run shell commands or indexed scripts. Execution history is captured (stdout, stderr, exit code, duration) and performance metrics are available for trend analysis. +Schedule Jobs let you define cron-scheduled tasks that run shell commands or +indexed scripts. The backend runs them itself: a background scheduler wakes on a +timer, fires jobs whose `next_run_at` has passed and records the result. No +external cron is required. + +Execution history is captured (stdout, stderr, exit code, duration) and +performance metrics are available for trend analysis. + +> **Note:** a job runs an arbitrary shell command with the backend's own +> privileges. Creating, editing and running jobs each require an explicit +> permission, and only administrators hold them by default. ### Features -- Cron expression scheduling with timezone support +- Cron expression scheduling with timezone support, validated on write +- A live preview of the next few run times while you are editing the schedule - Overlap prevention (a job won't start a second instance while still running) - Auto-retry on failure (configurable retries and delay) -- Timeout enforcement +- Timeout enforcement, which kills the whole process group rather than just the shell - Full stdout/stderr capture per execution -- Notification channel integration (alert on failure or success) +- Failure raises an incident and alerts the job's notification channels +- Executions stranded by a backend restart are reaped on the next start ### Schedule API @@ -182,6 +204,7 @@ Schedule Jobs let you define cron-scheduled tasks that run shell commands or ind | `/api/schedules/{id}/trigger` | POST | Manually trigger a job immediately | | `/api/schedules/{id}/executions` | GET | List execution history | | `/api/schedules/{id}/metrics` | GET | Performance metrics for a job | +| `/api/schedules/preview/cron` | GET | Validate an expression and preview its next runs | ## Notifications @@ -191,12 +214,12 @@ Notification Channels deliver alerts when monitors fail, schedule jobs error, or | Type | Description | |------|-------------| -| `slack` | Post messages to a Slack channel via Incoming Webhooks or Bot tokens | +| `slack` | Post messages to a Slack channel via an Incoming Webhook (`webhook_url`) | | `discord` | Send messages to a Discord channel via webhooks | -| `email` | Send SMTP email notifications | +| `email` | SMTP email (`smtp_host`, `smtp_port`, `to`, optional `smtp_user`/`smtp_pass`) | | `webhook` | HTTP POST to any generic webhook URL | | `pagerduty` | Create PagerDuty incidents via Events API v2 | -| `sms` | SMS via Twilio (account_sid + auth_token) | +| `sms` | SMS via Twilio (`account_sid`, `auth_token`, `from`, `to`) | ### Notifications API @@ -204,23 +227,55 @@ Notification Channels deliver alerts when monitors fail, schedule jobs error, or |----------|--------|-------------| | `/api/notifications/channels/` | GET / POST | List or create channels | | `/api/notifications/channels/{id}` | GET / PUT / DELETE | Read, update, or delete a channel | -| `/api/notifications/channels/{id}/test` | POST | Send a test notification (auth required) | +| `/api/notifications/channels/types` | GET | Describe each channel type's config fields | +| `/api/notifications/channels/{id}/test` | POST | Send a real test message and report the result | +| `/api/notifications/incidents/stats` | GET | Incident counts by status and severity | | `/api/notifications/incidents/` | GET | List all incidents | | `/api/notifications/incidents/{id}` | GET / PUT / DELETE | Read, update, or delete an incident | -> **Security note:** Secret config keys (`token`, `webhook_url`, `auth_token`, etc.) are always redacted (`***`) in API responses. +A channel's configuration is validated when it is saved, so a channel that could +never deliver is rejected rather than failing silently at alert time. "Send test" +performs a real delivery and reports the provider's response. + +> **Security note:** secret config keys (`webhook_url`, `token`, `auth_token`, +> `routing_key`, `smtp_pass`, ...) are never returned by the API; they appear as +> `***`. Submitting `***` back on an update keeps the stored value, so editing a +> channel's name cannot wipe its credentials. ## Authentication & RBAC -Script Manager uses **JWT Bearer tokens** for authentication and **role-based access control** for authorization. +Script Manager uses **JWT Bearer tokens** for authentication and **role-based +access control** for authorization. Every API endpoint is gated except the setup +wizard, the login and auth-config endpoints, `/health`, and the monitor ping +endpoint (whose secret key is its own credential). + +Set `REQUIRE_AUTH=false` to open the API for a local single-user setup; it is on +by default. + +### Signing key + +`SECRET_KEY` signs access tokens. Leave it unset and the backend generates a +random key on first start and persists it beside the database. Set it explicitly +whenever you run more than one backend process, since all workers must agree. + +### Accounts + +The setup wizard creates the first administrator. After that, administrators +create accounts from the Team page. Anonymous self-registration is off unless +`ALLOW_SELF_REGISTRATION=true`. The last remaining administrator cannot be +deleted, deactivated or demoted, so an installation cannot be locked out. ### Default Roles | Role | Permissions | |------|-------------| -| `admin` | Full access — manage users, roles, and all resources | -| `editor` | Create, update, and delete scripts, tags, notes, and searches | -| `viewer` | Read-only access to scripts and tags | +| `admin` | Full access — manage users, roles, and every resource | +| `editor` | Create, update and delete scripts, tags, notes, searches, folder roots, monitors and schedules | +| `viewer` | Read-only access across the application | + +Permissions are named `.`, and `.*` or `superuser` +act as wildcards. The UI hides whatever the signed-in account cannot reach, so a +viewer is never shown a button the API would refuse. ### Auth API @@ -229,7 +284,8 @@ Script Manager uses **JWT Bearer tokens** for authentication and **role-based ac | `/api/auth/login` | POST | Log in and receive an access token (form data) | | `/api/auth/me` | GET | Get the current authenticated user | | `/api/auth/register` | POST | Register a new user (admin only) | -| `/api/auth/change-password` | PUT | Change password for the current user | +| `/api/auth/change-password` | PUT | Change the current user's password (JSON body) | +| `/api/auth/config` | GET | Public: whether auth is enforced and self-registration is on | | `/api/auth/users` | GET | List all users (admin only) | | `/api/auth/roles` | GET | List all roles | @@ -448,10 +504,27 @@ When running with Docker Compose, the following services are orchestrated: ## Configuration -Configuration options can be set via environment variables: - -- `DATABASE_PATH`: Path to SQLite database (default: `./data/scripts.db`) -- `API_PORT`: Backend API port (default: `8000`) +Configuration is read from environment variables. `.env.example` at the +repository root documents every option; the ones you are most likely to set are: + +| Variable | Default | Purpose | +|----------|---------|---------| +| `DATABASE_PATH` | `./data/scripts.db` | SQLite database file | +| `API_PORT` | `8000` | Backend listen port | +| `SECRET_KEY` | generated | JWT signing key. Required when running more than one backend process | +| `REQUIRE_AUTH` | `true` | Enforce authentication and RBAC on the API | +| `ALLOW_SELF_REGISTRATION` | `false` | Let anonymous visitors create accounts | +| `ACCESS_TOKEN_EXPIRE_MINUTES` | `1440` | Access-token lifetime | +| `ALLOWED_ORIGINS` | `localhost:3000,localhost:5173` | CORS origins, when the UI is on another origin | +| `ENABLE_SCHEDULER` | `true` | Run due jobs and evaluate monitors in this process | +| `SCHEDULER_TICK_SECONDS` | `30` | How often the scheduler wakes up | +| `ATTACHMENTS_DIR` | `./data/attachments` | Where uploaded attachments are stored | +| `MAX_ATTACHMENT_SIZE` | `10485760` | Per-file upload limit, in bytes | +| `LOG_LEVEL` / `LOG_FORMAT` | `INFO` / `text` | Logging verbosity and format (`json` for aggregation) | + +Run the scheduler in exactly one process. If you scale the backend +horizontally, set `ENABLE_SCHEDULER=false` on every replica but one, or jobs +will run more than once per schedule. ## API Reference @@ -475,8 +548,16 @@ The full interactive API documentation is available at **http://localhost:8000/d | `/api/watch` | Real-time filesystem watch mode | | `/api/monitors` | Heartbeat monitor management | | `/api/schedules` | Scheduled job management | +| `/api/folders` | Folder tree and per-folder notes | | `/api/notifications` | Notification channels and incidents | +Every endpoint requires a bearer token and the matching permission, except +`/api/setup/*`, `/api/auth/login`, `/api/auth/config`, `/health`, and +`POST /api/monitors/ping/{ping_key}`. + +Interactive API documentation is served at `/docs` (OpenAPI) once the backend +is running. + ## Documentation - [Makefile Commands](./docs/MAKEFILE.md) - **Complete command reference for development** diff --git a/backend/.env.example b/backend/.env.example index d5775cc..f552cc8 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -1,22 +1,48 @@ -# Script Manager Configuration Example +# Script Manager backend configuration example +# +# Copy to backend/.env, uncomment what you want to change, and restart the +# backend. The repository root .env.example documents the same settings for a +# Docker deployment. -# Database Configuration -# Path to SQLite database file +# ── Database ────────────────────────────────────────────────────────────────── # DATABASE_PATH=./data/scripts.db -# API Configuration -# Port for the backend API server +# ── API ─────────────────────────────────────────────────────────────────────── # API_PORT=8000 +# API_RELOAD=false -# Scanning Configuration -# Maximum file size to index in bytes (default: 10MB) -# MAX_FILE_SIZE=10485760 +# ── Security ────────────────────────────────────────────────────────────────── +# JWT signing key. Leave unset and one is generated and persisted next to the +# database on first start; set it explicitly for multi-process deployments. +# Generate with: openssl rand -hex 32 +# SECRET_KEY= -# CORS Configuration -# Allowed origins for CORS (comma-separated) +# Enforce authentication and RBAC. Turning this off opens every endpoint. +# REQUIRE_AUTH=true + +# Let anonymous visitors create their own accounts. +# ALLOW_SELF_REGISTRATION=false + +# ACCESS_TOKEN_EXPIRE_MINUTES=1440 + +# One-time password for the bootstrap admin account, used only when an existing +# installation has no users at all. A random one is generated and logged if unset. +# BOOTSTRAP_ADMIN_PASSWORD= + +# ── CORS ────────────────────────────────────────────────────────────────────── # ALLOWED_ORIGINS=http://localhost:3000,http://localhost:5173 -# Instructions: -# 1. Copy this file to .env in the backend directory -# 2. Uncomment and modify the values you want to change -# 3. Restart the backend server for changes to take effect +# ── Scheduler and monitors ──────────────────────────────────────────────────── +# ENABLE_SCHEDULER=true +# SCHEDULER_TICK_SECONDS=30 +# SCHEDULER_MAX_CATCHUP_SECONDS=3600 +# JOB_LOG_MAX_CHARS=200000 + +# ── Storage limits ──────────────────────────────────────────────────────────── +# ATTACHMENTS_DIR=./data/attachments +# MAX_ATTACHMENT_SIZE=10485760 +# MAX_SCRIPT_CONTENT_BYTES=2097152 + +# ── Logging ─────────────────────────────────────────────────────────────────── +# LOG_LEVEL=INFO +# LOG_FORMAT=text diff --git a/backend/app/db/database.py b/backend/app/db/database.py index c4f39c3..fc63405 100644 --- a/backend/app/db/database.py +++ b/backend/app/db/database.py @@ -2,25 +2,133 @@ Database configuration and initialization """ import os -import aiosqlite +from contextlib import asynccontextmanager from pathlib import Path +import aiosqlite + # Database configuration DB_PATH = os.getenv("DATABASE_PATH", "./data/scripts.db") Path(DB_PATH).parent.mkdir(parents=True, exist_ok=True) +# Applied to every connection the application opens. Without foreign_keys the +# ON DELETE CASCADE clauses in the schema silently do nothing, which orphans +# scripts, notes, tags, pings and executions whenever a parent row is deleted. +CONNECTION_PRAGMAS = ( + "PRAGMA foreign_keys = ON", + "PRAGMA busy_timeout = 5000", +) + + +async def apply_connection_pragmas(db: aiosqlite.Connection): + """Apply the per-connection pragmas every code path relies on.""" + for pragma in CONNECTION_PRAGMAS: + await db.execute(pragma) + + +@asynccontextmanager +async def connection(db_path: str = None): + """ + Open a configured connection for the duration of the block. + + An async context manager rather than a coroutine returning a connection: + aiosqlite's Connection is both awaitable and a context manager, so + `async with await connect()` starts its worker thread twice and deadlocks. + """ + async with aiosqlite.connect(db_path or DB_PATH) as db: + db.row_factory = aiosqlite.Row + await apply_connection_pragmas(db) + yield db + + async def get_db(): - """Get database connection""" + """Get database connection (FastAPI dependency)""" async with aiosqlite.connect(DB_PATH) as db: db.row_factory = aiosqlite.Row + await apply_connection_pragmas(db) yield db + +async def _ensure_column(db, table: str, column: str, ddl: str): + """ + Add a column to an existing table if it is missing. + + `CREATE TABLE IF NOT EXISTS` never alters an existing table, so databases + created by an older release would otherwise never receive new columns. + """ + async with db.execute(f"PRAGMA table_info({table})") as cursor: + existing = {row[1] for row in await cursor.fetchall()} + if column not in existing: + await db.execute(f"ALTER TABLE {table} ADD COLUMN {column} {ddl}") + + +async def _run_migrations(db): + """Apply additive schema migrations to databases created by older versions.""" + await _ensure_column(db, "folder_roots", "enable_content_indexing", "BOOLEAN DEFAULT 0") + await _ensure_column(db, "folder_roots", "enable_watch_mode", "BOOLEAN DEFAULT 0") + await _ensure_column(db, "schedule_jobs", "next_run_at", "TIMESTAMP") + await _ensure_column(db, "schedule_jobs", "notify_channel_ids", "TEXT DEFAULT '[]'") + await _ensure_column(db, "monitors", "notify_channel_ids", "TEXT DEFAULT '[]'") + await _ensure_column(db, "incidents", "acknowledged_by", "TEXT") + await _ensure_column(db, "users", "last_login_at", "TIMESTAMP") + await _ensure_column(db, "job_executions", "overlap_key", "INTEGER") + + +async def cleanup_orphans(db) -> dict: + """ + Remove rows orphaned by deletes that ran while foreign_keys was OFF. + + Earlier releases opened request connections without `PRAGMA foreign_keys`, + so deleting a folder root (or script, monitor, job) left its children + behind. New installs are unaffected; this is a one-shot repair for + existing databases. + """ + statements = { + "folders": "DELETE FROM folders WHERE root_id NOT IN (SELECT id FROM folder_roots)", + "scripts": "DELETE FROM scripts WHERE root_id NOT IN (SELECT id FROM folder_roots)", + "script_notes": "DELETE FROM script_notes WHERE script_id NOT IN (SELECT id FROM scripts)", + "script_tags": ( + "DELETE FROM script_tags WHERE script_id NOT IN (SELECT id FROM scripts) " + "OR tag_id NOT IN (SELECT id FROM tags)" + ), + "script_status": "DELETE FROM script_status WHERE script_id NOT IN (SELECT id FROM scripts)", + "script_fields": "DELETE FROM script_fields WHERE script_id NOT IN (SELECT id FROM scripts)", + "change_log": "DELETE FROM change_log WHERE script_id NOT IN (SELECT id FROM scripts)", + "scan_events": "DELETE FROM scan_events WHERE root_id NOT IN (SELECT id FROM folder_roots)", + "attachments": ( + "DELETE FROM attachments WHERE (script_id IS NOT NULL AND script_id NOT IN (SELECT id FROM scripts)) " + "OR (note_id IS NOT NULL AND note_id NOT IN (SELECT id FROM script_notes))" + ), + "monitor_pings": "DELETE FROM monitor_pings WHERE monitor_id NOT IN (SELECT id FROM monitors)", + "job_executions": "DELETE FROM job_executions WHERE job_id NOT IN (SELECT id FROM schedule_jobs)", + "user_roles": ( + "DELETE FROM user_roles WHERE user_id NOT IN (SELECT id FROM users) " + "OR role_id NOT IN (SELECT id FROM roles)" + ), + "scripts_fts": "DELETE FROM scripts_fts WHERE script_id NOT IN (SELECT id FROM scripts)", + } + removed = {} + for table, sql in statements.items(): + cursor = await db.execute(sql) + if cursor.rowcount and cursor.rowcount > 0: + removed[table] = cursor.rowcount + await db.commit() + return removed + + async def init_db(): """Initialize database with schema""" async with aiosqlite.connect(DB_PATH) as db: # Enable foreign keys - await db.execute("PRAGMA foreign_keys = ON") - + await apply_connection_pragmas(db) + # Write-Ahead Logging keeps readers from blocking the writer, which + # matters because scans, the scheduler and requests all write. + try: + await db.execute("PRAGMA journal_mode = WAL") + except aiosqlite.Error: + # Not supported on some filesystems (e.g. certain network mounts). + pass + # Create folder_roots table await db.execute(""" CREATE TABLE IF NOT EXISTS folder_roots ( @@ -39,7 +147,7 @@ async def init_db(): updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) """) - + # Create folders table await db.execute(""" CREATE TABLE IF NOT EXISTS folders ( @@ -53,7 +161,7 @@ async def init_db(): FOREIGN KEY (parent_id) REFERENCES folders(id) ON DELETE CASCADE ) """) - + # Create scripts table await db.execute(""" CREATE TABLE IF NOT EXISTS scripts ( @@ -75,7 +183,7 @@ async def init_db(): FOREIGN KEY (folder_id) REFERENCES folders(id) ON DELETE SET NULL ) """) - + # Create script_notes table await db.execute(""" CREATE TABLE IF NOT EXISTS script_notes ( @@ -88,7 +196,7 @@ async def init_db(): FOREIGN KEY (script_id) REFERENCES scripts(id) ON DELETE CASCADE ) """) - + # Create tags table await db.execute(""" CREATE TABLE IF NOT EXISTS tags ( @@ -99,7 +207,7 @@ async def init_db(): created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) """) - + # Create script_tags table await db.execute(""" CREATE TABLE IF NOT EXISTS script_tags ( @@ -111,7 +219,7 @@ async def init_db(): FOREIGN KEY (tag_id) REFERENCES tags(id) ON DELETE CASCADE ) """) - + # Create script_fields table for custom metadata await db.execute(""" CREATE TABLE IF NOT EXISTS script_fields ( @@ -123,7 +231,7 @@ async def init_db(): FOREIGN KEY (script_id) REFERENCES scripts(id) ON DELETE CASCADE ) """) - + # Create script_status table await db.execute(""" CREATE TABLE IF NOT EXISTS script_status ( @@ -138,7 +246,7 @@ async def init_db(): FOREIGN KEY (script_id) REFERENCES scripts(id) ON DELETE CASCADE ) """) - + # Create scan_events table await db.execute(""" CREATE TABLE IF NOT EXISTS scan_events ( @@ -155,7 +263,7 @@ async def init_db(): FOREIGN KEY (root_id) REFERENCES folder_roots(id) ON DELETE CASCADE ) """) - + # Create change_log table await db.execute(""" CREATE TABLE IF NOT EXISTS change_log ( @@ -169,7 +277,7 @@ async def init_db(): FOREIGN KEY (script_id) REFERENCES scripts(id) ON DELETE CASCADE ) """) - + # Create attachments table await db.execute(""" CREATE TABLE IF NOT EXISTS attachments ( @@ -186,7 +294,7 @@ async def init_db(): FOREIGN KEY (note_id) REFERENCES script_notes(id) ON DELETE CASCADE ) """) - + # Create users table await db.execute(""" CREATE TABLE IF NOT EXISTS users ( @@ -197,11 +305,12 @@ async def init_db(): hashed_password TEXT NOT NULL, is_active BOOLEAN DEFAULT 1, is_superuser BOOLEAN DEFAULT 0, + last_login_at TIMESTAMP, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) """) - + # Create roles table await db.execute(""" CREATE TABLE IF NOT EXISTS roles ( @@ -212,7 +321,7 @@ async def init_db(): created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) """) - + # Create user_roles junction table await db.execute(""" CREATE TABLE IF NOT EXISTS user_roles ( @@ -224,7 +333,7 @@ async def init_db(): FOREIGN KEY (role_id) REFERENCES roles(id) ON DELETE CASCADE ) """) - + # Create saved_searches table await db.execute(""" CREATE TABLE IF NOT EXISTS saved_searches ( @@ -237,7 +346,7 @@ async def init_db(): updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) """) - + # Create app_settings table for wizard/configuration state await db.execute(""" CREATE TABLE IF NOT EXISTS app_settings ( @@ -246,7 +355,7 @@ async def init_db(): updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) """) - + # Create monitors table for heartbeat/fail-safe monitoring await db.execute(""" CREATE TABLE IF NOT EXISTS monitors ( @@ -314,6 +423,7 @@ async def init_db(): duration_seconds REAL, retry_attempt INTEGER NOT NULL DEFAULT 0, triggered_by TEXT NOT NULL DEFAULT 'scheduler', + overlap_key INTEGER, FOREIGN KEY (job_id) REFERENCES schedule_jobs(id) ON DELETE CASCADE ) """) @@ -360,22 +470,46 @@ async def init_db(): tokenize='porter unicode61' ) """) - + + # Migrations run before the index block: an index over a column that a + # migration adds cannot be created while that column is still missing. + await _run_migrations(db) + # Create indexes for performance await db.execute("CREATE INDEX IF NOT EXISTS idx_scripts_name ON scripts(name)") await db.execute("CREATE INDEX IF NOT EXISTS idx_scripts_extension ON scripts(extension)") await db.execute("CREATE INDEX IF NOT EXISTS idx_scripts_language ON scripts(language)") await db.execute("CREATE INDEX IF NOT EXISTS idx_scripts_hash ON scripts(hash)") await db.execute("CREATE INDEX IF NOT EXISTS idx_scripts_mtime ON scripts(mtime)") + await db.execute("CREATE INDEX IF NOT EXISTS idx_scripts_root ON scripts(root_id)") + await db.execute("CREATE INDEX IF NOT EXISTS idx_scripts_missing ON scripts(missing_flag)") + await db.execute("CREATE INDEX IF NOT EXISTS idx_scripts_path ON scripts(path)") await db.execute("CREATE INDEX IF NOT EXISTS idx_script_tags_script ON script_tags(script_id)") await db.execute("CREATE INDEX IF NOT EXISTS idx_script_tags_tag ON script_tags(tag_id)") + await db.execute("CREATE INDEX IF NOT EXISTS idx_script_notes_script ON script_notes(script_id)") await db.execute("CREATE INDEX IF NOT EXISTS idx_change_log_script ON change_log(script_id)") await db.execute("CREATE INDEX IF NOT EXISTS idx_change_log_time ON change_log(event_time)") await db.execute("CREATE INDEX IF NOT EXISTS idx_monitor_pings_monitor ON monitor_pings(monitor_id)") await db.execute("CREATE INDEX IF NOT EXISTS idx_job_executions_job ON job_executions(job_id)") await db.execute("CREATE INDEX IF NOT EXISTS idx_job_executions_started ON job_executions(started_at)") + await db.execute("CREATE INDEX IF NOT EXISTS idx_job_executions_status ON job_executions(status)") + # Overlap prevention was a check-then-act race: two concurrent triggers + # could both see no running execution and both insert one. A partial + # unique index makes the database refuse the second insert. + # + # The guard is keyed on overlap_key rather than job_id so it applies + # only to jobs that asked for it: a job with prevent_overlap = false + # writes NULL, and NULLs never collide in a SQLite unique index. + await db.execute("DROP INDEX IF EXISTS idx_job_executions_single_running") + await db.execute( + "CREATE UNIQUE INDEX IF NOT EXISTS idx_job_executions_overlap_guard " + "ON job_executions(overlap_key) " + "WHERE status = 'running' AND overlap_key IS NOT NULL" + ) + await db.execute("CREATE INDEX IF NOT EXISTS idx_schedule_jobs_next_run ON schedule_jobs(next_run_at)") await db.execute("CREATE INDEX IF NOT EXISTS idx_incidents_source ON incidents(source_type, source_id)") await db.execute("CREATE INDEX IF NOT EXISTS idx_incidents_status ON incidents(status)") - + await db.execute("CREATE INDEX IF NOT EXISTS idx_attachments_script ON attachments(script_id)") + await db.execute("CREATE INDEX IF NOT EXISTS idx_attachments_note ON attachments(note_id)") + await db.commit() - print("Database initialized successfully") diff --git a/backend/app/db/sql.py b/backend/app/db/sql.py new file mode 100644 index 0000000..3d371e1 --- /dev/null +++ b/backend/app/db/sql.py @@ -0,0 +1,34 @@ +""" +Small shared SQL fragments. + +Kept in one place so the several list endpoints that return a script's tags +agree on how those tags are aggregated and split apart again. +""" + +# ASCII unit separator. SQLite's `GROUP_CONCAT(DISTINCT x)` only supports the +# single-argument form, which joins with a comma, so a tag named "prod, eu" +# came back as two tags. The correlated subquery below can pass an explicit +# separator, and a control character cannot appear in a tag name. +TAG_SEPARATOR = "\x1f" + +# Correlated subquery returning a script's tag names. +# Besides fixing the separator, this removes the script_tags/tags joins from +# the outer SELECT, so a script with N tags no longer produces N duplicate rows +# that GROUP BY then has to collapse. +TAGS_SUBQUERY = """( + SELECT GROUP_CONCAT(tag_names.name, char(31)) + FROM ( + SELECT DISTINCT t2.name AS name + FROM script_tags st2 + JOIN tags t2 ON t2.id = st2.tag_id + WHERE st2.script_id = s.id + ORDER BY t2.name + ) AS tag_names +)""" + + +def split_tags(value) -> list: + """Split an aggregated tag string back into a list.""" + if not value: + return [] + return [tag for tag in str(value).split(TAG_SEPARATOR) if tag] diff --git a/backend/app/models/schemas.py b/backend/app/models/schemas.py index 328afbe..db610df 100644 --- a/backend/app/models/schemas.py +++ b/backend/app/models/schemas.py @@ -1,9 +1,26 @@ """ Pydantic models for API request/response """ -from pydantic import BaseModel, Field -from typing import Optional, List -from datetime import datetime +from datetime import datetime, timezone +from typing import Annotated, List, Literal, Optional + +from pydantic import AfterValidator, BaseModel, EmailStr, Field + + +def _as_utc(value: datetime) -> datetime: + """ + Treat a naive timestamp as UTC and always serialize with an offset. + + SQLite's CURRENT_TIMESTAMP writes naive UTC strings. Serialized without an + offset, `new Date(value)` in the browser parses them as local time, so + every timestamp in the UI was shifted by the viewer's UTC offset. + """ + if value.tzinfo is None: + return value.replace(tzinfo=timezone.utc) + return value.astimezone(timezone.utc) + + +UTCDateTime = Annotated[datetime, AfterValidator(_as_utc)] class FolderRootCreate(BaseModel): path: str @@ -27,9 +44,21 @@ class FolderRootResponse(BaseModel): max_file_size: int enable_content_indexing: bool enable_watch_mode: bool - last_scan_time: Optional[datetime] - created_at: datetime - updated_at: datetime + last_scan_time: Optional[UTCDateTime] + created_at: UTCDateTime + updated_at: UTCDateTime + +class FolderRootUpdate(BaseModel): + """Partial update for a folder root. The path is immutable: changing it + would orphan every script already indexed under the old location.""" + name: Optional[str] = None + recursive: Optional[bool] = None + include_patterns: Optional[str] = None + exclude_patterns: Optional[str] = None + follow_symlinks: Optional[bool] = None + max_file_size: Optional[int] = None + enable_content_indexing: Optional[bool] = None + enable_watch_mode: Optional[bool] = None class ScriptResponse(BaseModel): id: int @@ -40,18 +69,18 @@ class ScriptResponse(BaseModel): extension: Optional[str] language: Optional[str] size: Optional[int] - mtime: Optional[datetime] + mtime: Optional[UTCDateTime] hash: Optional[str] line_count: Optional[int] missing_flag: bool - created_at: datetime - updated_at: datetime + created_at: UTCDateTime + updated_at: UTCDateTime tags: List[str] = [] status: Optional[str] = None classification: Optional[str] = None owner: Optional[str] = None environment: Optional[str] = None - deprecated_date: Optional[datetime] = None + deprecated_date: Optional[UTCDateTime] = None migration_note: Optional[str] = None notes: Optional[str] = None @@ -62,7 +91,7 @@ class ScriptListResponse(BaseModel): extension: Optional[str] language: Optional[str] size: Optional[int] - mtime: Optional[datetime] + mtime: Optional[UTCDateTime] status: Optional[str] tags: List[str] = [] @@ -76,7 +105,7 @@ class TagResponse(BaseModel): name: str group_name: Optional[str] color: Optional[str] - created_at: datetime + created_at: UTCDateTime class NoteCreate(BaseModel): content: str @@ -87,15 +116,21 @@ class NoteResponse(BaseModel): script_id: int content: str is_markdown: bool - created_at: datetime - updated_at: datetime + created_at: UTCDateTime + updated_at: UTCDateTime + +# The lifecycle states the UI offers and the docs describe. Typing the field +# means an unknown value is rejected with a 422 instead of being stored and +# then never matching any filter. +ScriptStatus = Literal["active", "draft", "deprecated", "archived"] + class StatusUpdate(BaseModel): - status: Optional[str] = None + status: Optional[ScriptStatus] = None classification: Optional[str] = None owner: Optional[str] = None environment: Optional[str] = None - deprecated_date: Optional[datetime] = None + deprecated_date: Optional[UTCDateTime] = None migration_note: Optional[str] = None class ScanRequest(BaseModel): @@ -108,8 +143,8 @@ class ScanResponse(BaseModel): updated_count: int deleted_count: int error_count: int - started_at: datetime - ended_at: Optional[datetime] + started_at: UTCDateTime + ended_at: Optional[UTCDateTime] class SearchRequest(BaseModel): query: Optional[str] = None @@ -122,8 +157,8 @@ class SearchRequest(BaseModel): classification: Optional[str] = None min_size: Optional[int] = None max_size: Optional[int] = None - modified_after: Optional[datetime] = None - modified_before: Optional[datetime] = None + modified_after: Optional[UTCDateTime] = None + modified_before: Optional[UTCDateTime] = None sort_by: str = "name" sort_order: str = "asc" page: int = 1 @@ -142,7 +177,7 @@ class FolderResponse(BaseModel): path: str parent_id: Optional[int] note: Optional[str] - created_at: datetime + created_at: UTCDateTime class FolderNoteUpdate(BaseModel): note: str @@ -153,11 +188,16 @@ class BulkTagRequest(BaseModel): class BulkStatusRequest(BaseModel): script_ids: List[int] - status: Optional[str] = None + status: Optional[ScriptStatus] = None classification: Optional[str] = None owner: Optional[str] = None environment: Optional[str] = None +class ExportRequest(BaseModel): + """Body for POST /api/scripts/export. Omit script_ids to export everything.""" + script_ids: Optional[List[int]] = None + + class SavedSearchCreate(BaseModel): name: str description: Optional[str] = None @@ -170,8 +210,8 @@ class SavedSearchResponse(BaseModel): description: Optional[str] query_params: dict is_pinned: bool - created_at: datetime - updated_at: datetime + created_at: UTCDateTime + updated_at: UTCDateTime class FTSSearchRequest(BaseModel): query: str @@ -189,16 +229,16 @@ class AttachmentResponse(BaseModel): file_path: str file_size: int mime_type: Optional[str] - created_at: datetime + created_at: UTCDateTime # ── Heartbeat Monitors ────────────────────────────────────────────────────── class MonitorCreate(BaseModel): - name: str + name: str = Field(min_length=1, max_length=200) description: Optional[str] = None - expected_interval_seconds: int = 300 - grace_period_seconds: int = 60 + expected_interval_seconds: int = Field(default=300, ge=10, le=2678400) + grace_period_seconds: int = Field(default=60, ge=0, le=2678400) notify_channel_ids: List[int] = [] class MonitorResponse(BaseModel): @@ -207,18 +247,21 @@ class MonitorResponse(BaseModel): description: Optional[str] expected_interval_seconds: int grace_period_seconds: int - ping_key: str - last_ping_at: Optional[datetime] + # Omitted from list and detail responses: the key is the credential that + # lets anything forge a heartbeat. Fetch it deliberately from + # GET /api/monitors/{id}/ping-url instead. + ping_key: Optional[str] = None + last_ping_at: Optional[UTCDateTime] status: str notify_channel_ids: List[int] = [] - created_at: datetime - updated_at: datetime + created_at: UTCDateTime + updated_at: UTCDateTime class MonitorUpdate(BaseModel): - name: Optional[str] = None + name: Optional[str] = Field(default=None, min_length=1, max_length=200) description: Optional[str] = None - expected_interval_seconds: Optional[int] = None - grace_period_seconds: Optional[int] = None + expected_interval_seconds: Optional[int] = Field(default=None, ge=10, le=2678400) + grace_period_seconds: Optional[int] = Field(default=None, ge=0, le=2678400) notify_channel_ids: Optional[List[int]] = None @@ -232,10 +275,11 @@ class ScheduleJobCreate(BaseModel): cron_expression: str timezone: str = "UTC" enabled: bool = True - max_retries: int = 0 - retry_delay_seconds: int = 60 + max_retries: int = Field(default=0, ge=0, le=10) + retry_delay_seconds: int = Field(default=60, ge=1, le=86400) prevent_overlap: bool = True - timeout_seconds: Optional[int] = None + # ge=1 so timeout_seconds=0 cannot silently mean "no timeout". + timeout_seconds: Optional[int] = Field(default=None, ge=1, le=86400) notify_channel_ids: List[int] = [] class ScheduleJobResponse(BaseModel): @@ -252,11 +296,11 @@ class ScheduleJobResponse(BaseModel): prevent_overlap: bool timeout_seconds: Optional[int] notify_channel_ids: List[int] = [] - last_run_at: Optional[datetime] - next_run_at: Optional[datetime] + last_run_at: Optional[UTCDateTime] + next_run_at: Optional[UTCDateTime] last_status: Optional[str] - created_at: datetime - updated_at: datetime + created_at: UTCDateTime + updated_at: UTCDateTime class ScheduleJobUpdate(BaseModel): name: Optional[str] = None @@ -266,17 +310,17 @@ class ScheduleJobUpdate(BaseModel): cron_expression: Optional[str] = None timezone: Optional[str] = None enabled: Optional[bool] = None - max_retries: Optional[int] = None - retry_delay_seconds: Optional[int] = None + max_retries: Optional[int] = Field(default=None, ge=0, le=10) + retry_delay_seconds: Optional[int] = Field(default=None, ge=1, le=86400) prevent_overlap: Optional[bool] = None - timeout_seconds: Optional[int] = None + timeout_seconds: Optional[int] = Field(default=None, ge=1, le=86400) notify_channel_ids: Optional[List[int]] = None class JobExecutionResponse(BaseModel): id: int job_id: int - started_at: datetime - ended_at: Optional[datetime] + started_at: UTCDateTime + ended_at: Optional[UTCDateTime] status: str exit_code: Optional[int] stdout: Optional[str] @@ -300,8 +344,8 @@ class NotificationChannelResponse(BaseModel): type: str config: dict enabled: bool - created_at: datetime - updated_at: datetime + created_at: UTCDateTime + updated_at: UTCDateTime class NotificationChannelUpdate(BaseModel): name: Optional[str] = None @@ -320,11 +364,11 @@ class IncidentResponse(BaseModel): status: str severity: str description: Optional[str] - acknowledged_at: Optional[datetime] + acknowledged_at: Optional[UTCDateTime] acknowledged_by: Optional[str] - resolved_at: Optional[datetime] - created_at: datetime - updated_at: datetime + resolved_at: Optional[UTCDateTime] + created_at: UTCDateTime + updated_at: UTCDateTime class IncidentUpdate(BaseModel): status: Optional[str] = None @@ -336,11 +380,27 @@ class IncidentUpdate(BaseModel): # ── User management request bodies ────────────────────────────────────────── class UserRegister(BaseModel): - username: str - email: str - password: str - full_name: Optional[str] = None + username: str = Field(min_length=1, max_length=64) + email: EmailStr + password: str = Field(min_length=8, max_length=256) + full_name: Optional[str] = Field(default=None, max_length=200) + # Honoured only for requests made by an administrator. + role_ids: Optional[List[int]] = None class UserUpdate(BaseModel): + email: Optional[EmailStr] = None + full_name: Optional[str] = Field(default=None, max_length=200) is_active: Optional[bool] = None + is_superuser: Optional[bool] = None role_ids: Optional[List[int]] = None + # Admin-initiated password reset. + password: Optional[str] = Field(default=None, min_length=8, max_length=256) + +class PasswordChange(BaseModel): + """Body for PUT /api/auth/change-password. + + Credentials belong in the body: as query parameters they end up in access + logs, browser history and proxy caches. + """ + old_password: str = Field(min_length=1, max_length=256) + new_password: str = Field(min_length=8, max_length=256) diff --git a/backend/app/routes/attachments.py b/backend/app/routes/attachments.py index 4c1e8fd..f157a1c 100644 --- a/backend/app/routes/attachments.py +++ b/backend/app/routes/attachments.py @@ -8,22 +8,57 @@ import os import uuid from pathlib import Path +import logging import mimetypes +import time from app.db.database import get_db from app.models.schemas import AttachmentResponse +from app.routes.deps import require_permission + +logger = logging.getLogger(__name__) router = APIRouter() +read_access = Depends(require_permission("attachments.read")) +upload_access = Depends(require_permission("attachments.upload")) +delete_access = Depends(require_permission("attachments.delete")) + # Attachments directory ATTACHMENTS_DIR = os.getenv("ATTACHMENTS_DIR", "./data/attachments") Path(ATTACHMENTS_DIR).mkdir(parents=True, exist_ok=True) -# Max file size (10MB) -MAX_ATTACHMENT_SIZE = 10 * 1024 * 1024 +# Max file size (configurable; 10MB default) +MAX_ATTACHMENT_SIZE = int(os.getenv("MAX_ATTACHMENT_SIZE", str(10 * 1024 * 1024))) +CHUNK_SIZE = 64 * 1024 +# Files younger than this are never pruned; see prune_orphaned_files. +PRUNE_MIN_AGE_SECONDS = int(os.getenv("ATTACHMENT_PRUNE_MIN_AGE", str(3600))) + +# Extensions that are safe to preserve on the stored file. Anything else is +# saved without an extension so the file can never be served as active content. +ALLOWED_EXTENSIONS = { + ".txt", ".md", ".log", ".csv", ".tsv", ".json", ".yaml", ".yml", ".xml", ".ini", + ".conf", ".cfg", ".pdf", ".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", ".bmp", + ".zip", ".gz", ".tar", ".tgz", ".7z", + ".py", ".sh", ".ps1", ".psm1", ".sql", ".js", ".ts", ".bat", ".cmd", ".rb", ".pl", +} + + +def _safe_original_name(filename: Optional[str]) -> str: + """Strip any directory components a client may have put in the filename.""" + name = os.path.basename((filename or "").replace("\\", "/").strip()) or "attachment" + # Windows-style paths and stray separators are handled above; drop control + # characters that would corrupt the Content-Disposition header on download. + return "".join(ch for ch in name if ch.isprintable())[:255] or "attachment" + + +def _safe_extension(filename: str) -> str: + ext = Path(filename).suffix.lower() + return ext if ext in ALLOWED_EXTENSIONS else "" -@router.post("/upload", response_model=AttachmentResponse) +@router.post("/upload", response_model=AttachmentResponse, status_code=201, + dependencies=[upload_access]) async def upload_attachment( file: UploadFile = File(...), script_id: Optional[int] = None, @@ -31,58 +66,77 @@ async def upload_attachment( db: aiosqlite.Connection = Depends(get_db) ): """ - Upload an attachment file - Can be attached to a script or a note + Upload an attachment file. + Can be attached to a script or a note. + + The upload is streamed to disk in chunks and aborted as soon as it exceeds + the size limit, so a large upload cannot exhaust memory before the check. """ if not script_id and not note_id: raise HTTPException(status_code=400, detail="Must specify either script_id or note_id") - + # Verify script or note exists if script_id: async with db.execute("SELECT id FROM scripts WHERE id = ?", (script_id,)) as cursor: if not await cursor.fetchone(): raise HTTPException(status_code=404, detail="Script not found") - + if note_id: async with db.execute("SELECT id FROM script_notes WHERE id = ?", (note_id,)) as cursor: if not await cursor.fetchone(): raise HTTPException(status_code=404, detail="Note not found") - - # Read file content - content = await file.read() - file_size = len(content) - - # Check file size - if file_size > MAX_ATTACHMENT_SIZE: - raise HTTPException( - status_code=413, - detail=f"File too large. Maximum size is {MAX_ATTACHMENT_SIZE / (1024*1024)}MB" - ) - - # Generate unique filename - file_extension = Path(file.filename).suffix - unique_filename = f"{uuid.uuid4()}{file_extension}" + + original_filename = _safe_original_name(file.filename) + + # The stored name is a random UUID, so a filename like "../../etc/passwd" + # can never influence where the file lands; only the extension is reused + # and it is validated against an allowlist first. + file_extension = _safe_extension(original_filename) + unique_filename = f"{uuid.uuid4().hex}{file_extension}" file_path = os.path.join(ATTACHMENTS_DIR, unique_filename) - - # Save file - with open(file_path, 'wb') as f: - f.write(content) - - # Detect MIME type - mime_type, _ = mimetypes.guess_type(file.filename) - - # Save to database + + file_size = 0 + try: + with open(file_path, "wb") as fh: + while True: + chunk = await file.read(CHUNK_SIZE) + if not chunk: + break + file_size += len(chunk) + if file_size > MAX_ATTACHMENT_SIZE: + raise HTTPException( + status_code=413, + detail=f"File too large. Maximum size is " + f"{MAX_ATTACHMENT_SIZE // (1024 * 1024)}MB", + ) + fh.write(chunk) + except Exception: + # Never leave a partial file behind when the upload is rejected. + try: + os.remove(file_path) + except OSError: + pass + raise + + if file_size == 0: + try: + os.remove(file_path) + except OSError: + pass + raise HTTPException(status_code=400, detail="Uploaded file is empty") + + mime_type, _ = mimetypes.guess_type(original_filename) + cursor = await db.execute( """ INSERT INTO attachments (script_id, note_id, filename, original_filename, file_path, file_size, mime_type) VALUES (?, ?, ?, ?, ?, ?, ?) """, - (script_id, note_id, unique_filename, file.filename, file_path, file_size, mime_type) + (script_id, note_id, unique_filename, original_filename, file_path, file_size, mime_type) ) await db.commit() - - # Return attachment details + async with db.execute( "SELECT * FROM attachments WHERE id = ?", (cursor.lastrowid,) @@ -91,7 +145,7 @@ async def upload_attachment( return dict(row) -@router.get("/script/{script_id}", response_model=List[AttachmentResponse]) +@router.get("/script/{script_id}", response_model=List[AttachmentResponse], dependencies=[read_access]) async def list_script_attachments( script_id: int, db: aiosqlite.Connection = Depends(get_db) @@ -109,7 +163,7 @@ async def list_script_attachments( return [dict(row) for row in rows] -@router.get("/note/{note_id}", response_model=List[AttachmentResponse]) +@router.get("/note/{note_id}", response_model=List[AttachmentResponse], dependencies=[read_access]) async def list_note_attachments( note_id: int, db: aiosqlite.Connection = Depends(get_db) @@ -127,7 +181,7 @@ async def list_note_attachments( return [dict(row) for row in rows] -@router.get("/{attachment_id}/download") +@router.get("/{attachment_id}/download", dependencies=[read_access]) async def download_attachment( attachment_id: int, db: aiosqlite.Connection = Depends(get_db) @@ -155,7 +209,7 @@ async def download_attachment( ) -@router.get("/{attachment_id}", response_model=AttachmentResponse) +@router.get("/{attachment_id}", response_model=AttachmentResponse, dependencies=[read_access]) async def get_attachment( attachment_id: int, db: aiosqlite.Connection = Depends(get_db) @@ -171,7 +225,7 @@ async def get_attachment( return dict(row) -@router.delete("/{attachment_id}") +@router.delete("/{attachment_id}", dependencies=[delete_access]) async def delete_attachment( attachment_id: int, db: aiosqlite.Connection = Depends(get_db) @@ -191,8 +245,8 @@ async def delete_attachment( try: if os.path.exists(file_path): os.remove(file_path) - except Exception as e: - print(f"Error deleting attachment file: {e}") + except OSError as e: + logger.warning("Error deleting attachment file %s: %s", file_path, e) # Delete from database await db.execute("DELETE FROM attachments WHERE id = ?", (attachment_id,)) @@ -201,7 +255,82 @@ async def delete_attachment( return {"message": "Attachment deleted successfully"} -@router.get("/stats/all") +async def purge_attachment_files(db, *, script_ids=None, note_ids=None): + """ + Delete the on-disk files for attachments belonging to the given parents. + + The database rows disappear via ON DELETE CASCADE, but the files they point + at have no such relationship and were left behind forever. + """ + clauses, params = [], [] + if script_ids: + clauses.append(f"script_id IN ({','.join('?' * len(script_ids))})") + params.extend(script_ids) + if note_ids: + clauses.append(f"note_id IN ({','.join('?' * len(note_ids))})") + params.extend(note_ids) + if not clauses: + return 0 + + async with db.execute( + f"SELECT file_path FROM attachments WHERE {' OR '.join(clauses)}", tuple(params) + ) as cursor: + rows = await cursor.fetchall() + + removed = 0 + for row in rows: + try: + if row[0] and os.path.exists(row[0]): + os.remove(row[0]) + removed += 1 + except OSError as exc: + logger.warning("Could not delete attachment file %s: %s", row[0], exc) + return removed + + +@router.post("/maintenance/prune", dependencies=[delete_access]) +async def prune_orphaned_files(db: aiosqlite.Connection = Depends(get_db)): + """ + Delete files in the attachments directory that no attachment row references. + + Cleans up after deletes that happened before parent cascades removed the + files, and after any interrupted upload. + """ + async with db.execute("SELECT filename FROM attachments") as cursor: + known = {row[0] for row in await cursor.fetchall()} + + removed, freed = 0, 0 + try: + entries = os.listdir(ATTACHMENTS_DIR) + except OSError as exc: + raise HTTPException(status_code=500, detail=f"Cannot read attachments directory: {exc}") + + # An upload writes its file before inserting the row that names it, so a + # file younger than this window may simply be an upload still in progress. + cutoff = time.time() - PRUNE_MIN_AGE_SECONDS + + for name in entries: + if name in known: + continue + path = os.path.join(ATTACHMENTS_DIR, name) + if not os.path.isfile(path): + continue + try: + if os.path.getmtime(path) > cutoff: + continue + except OSError: + continue + try: + freed += os.path.getsize(path) + os.remove(path) + removed += 1 + except OSError as exc: + logger.warning("Could not prune %s: %s", path, exc) + + return {"removed_files": removed, "freed_bytes": freed} + + +@router.get("/stats/all", dependencies=[read_access]) async def get_attachment_stats(db: aiosqlite.Connection = Depends(get_db)): """Get attachment statistics""" # Count total attachments diff --git a/backend/app/routes/auth.py b/backend/app/routes/auth.py index cc28bc7..71a5585 100644 --- a/backend/app/routes/auth.py +++ b/backend/app/routes/auth.py @@ -1,236 +1,273 @@ """ Authentication and User Management API endpoints """ -from fastapi import APIRouter, Depends, HTTPException, status -from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm -from typing import Optional, List +import os +from datetime import timedelta +from typing import List, Optional + import aiosqlite -import json +from fastapi import APIRouter, Depends, HTTPException, status +from fastapi.security import OAuth2PasswordRequestForm from app.db.database import get_db +from app.models.schemas import PasswordChange, UserRegister, UserUpdate +from app.routes.deps import ( + get_current_user, get_optional_user, is_admin, oauth2_scheme, require_admin, +) from app.services.auth import ( - verify_password, get_password_hash, create_access_token, - decode_access_token, validate_password_strength + ACCESS_TOKEN_EXPIRE_MINUTES, create_access_token, get_password_hash, + validate_password_strength, verify_password, ) -from app.models.schemas import UserRegister, UserUpdate router = APIRouter() -# OAuth2 scheme -oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login") +admin_only = Depends(require_admin()) -async def get_current_user( - token: str = Depends(oauth2_scheme), - db: aiosqlite.Connection = Depends(get_db) -) -> dict: - """Get current authenticated user""" - credentials_exception = HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Could not validate credentials", - headers={"WWW-Authenticate": "Bearer"}, - ) - - # Decode token - payload = decode_access_token(token) - if payload is None: - raise credentials_exception - - username: str = payload.get("sub") - if username is None: - raise credentials_exception - - # Get user from database +def self_registration_enabled() -> bool: + """ + Whether anonymous users may create their own account. + + Off by default: an internet-reachable instance should not let strangers + create accounts. Administrators can always create users from the Team page. + """ + return os.getenv("ALLOW_SELF_REGISTRATION", "false").strip().lower() in ("1", "true", "yes", "on") + + +async def _count_active_admins(db: aiosqlite.Connection, exclude_user_id: Optional[int] = None) -> int: + """Count active users that still hold superuser privileges.""" + query = """ + SELECT COUNT(DISTINCT u.id) + FROM users u + LEFT JOIN user_roles ur ON u.id = ur.user_id + LEFT JOIN roles r ON r.id = ur.role_id + WHERE u.is_active = 1 + AND (u.is_superuser = 1 OR r.permissions LIKE '%superuser%') + """ + params: tuple = () + if exclude_user_id is not None: + query += " AND u.id != ?" + params = (exclude_user_id,) + async with db.execute(query, params) as cursor: + return (await cursor.fetchone())[0] + + +async def _load_user_with_roles(db: aiosqlite.Connection, user_id: int) -> Optional[dict]: async with db.execute( - "SELECT id, username, email, full_name, is_active, is_superuser FROM users WHERE username = ?", - (username,) + "SELECT id, username, email, full_name, is_active, is_superuser, last_login_at, created_at " + "FROM users WHERE id = ?", + (user_id,), ) as cursor: - user = await cursor.fetchone() - if not user: - raise credentials_exception - - user_dict = dict(user) - - # Get user permissions + row = await cursor.fetchone() + if not row: + return None + user = dict(row) async with db.execute( """ - SELECT r.permissions - FROM roles r - JOIN user_roles ur ON r.id = ur.role_id + SELECT r.id, r.name, r.description + FROM roles r JOIN user_roles ur ON r.id = ur.role_id WHERE ur.user_id = ? + ORDER BY r.name """, - (user_dict['id'],) + (user_id,), ) as cursor: - roles = await cursor.fetchall() - - permissions = [] - for role in roles: - role_perms = json.loads(role[0]) - permissions.extend(role_perms) - - user_dict['permissions'] = list(set(permissions)) - - if not user_dict['is_active']: - raise HTTPException(status_code=400, detail="Inactive user") - - return user_dict + user["roles"] = [dict(r) for r in await cursor.fetchall()] + return user @router.post("/login") async def login( form_data: OAuth2PasswordRequestForm = Depends(), - db: aiosqlite.Connection = Depends(get_db) + db: aiosqlite.Connection = Depends(get_db), ): """Login and get access token""" - # Get user async with db.execute( "SELECT id, username, hashed_password, is_active FROM users WHERE username = ?", - (form_data.username,) + (form_data.username,), ) as cursor: user = await cursor.fetchone() - + if not user or not verify_password(form_data.password, user[2]): raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Incorrect username or password", headers={"WWW-Authenticate": "Bearer"}, ) - + if not user[3]: # is_active - raise HTTPException(status_code=400, detail="Inactive user") - - # Create access token - access_token = create_access_token(data={"sub": user[1]}) - + raise HTTPException(status_code=403, detail="This account has been deactivated") + + await db.execute( + "UPDATE users SET last_login_at = CURRENT_TIMESTAMP WHERE id = ?", (user[0],) + ) + await db.commit() + + access_token = create_access_token( + data={"sub": user[1]}, expires_delta=timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES) + ) + return { "access_token": access_token, "token_type": "bearer", - "username": user[1] + "username": user[1], + # Lets the client refresh or warn before the session silently expires. + "expires_in": ACCESS_TOKEN_EXPIRE_MINUTES * 60, } @router.get("/me") async def read_users_me(current_user: dict = Depends(get_current_user)): - """Get current user info""" + """Get current user info, including the flattened permission set.""" return current_user -@router.post("/register") +@router.get("/config") +async def auth_config(db: aiosqlite.Connection = Depends(get_db)): + """ + Public description of how authentication is configured. + + The sign-in screen uses this to decide whether to offer self-registration + and to tell a brand-new install that no accounts exist yet. + """ + from app.routes.deps import auth_required + + async with db.execute("SELECT COUNT(*) FROM users") as cursor: + user_count = (await cursor.fetchone())[0] + return { + "auth_required": auth_required(), + "self_registration_enabled": self_registration_enabled(), + "has_users": user_count > 0, + } + + +@router.post("/register", status_code=201) async def register_user( data: UserRegister, - db: aiosqlite.Connection = Depends(get_db) + db: aiosqlite.Connection = Depends(get_db), + current_user: Optional[dict] = Depends(get_optional_user), ): """ - Register a new user. - Credentials are sent in the request body (never in the URL/query string). - - WARNING: Public self-registration is enabled. For production use, - consider disabling this endpoint or implementing invitation-based registration. + Create a user account. + + Administrators can always create accounts. Anonymous self-registration is + only permitted when ALLOW_SELF_REGISTRATION is enabled, so a public + deployment does not hand out accounts by default. """ - username = data.username - email = data.email - password = data.password - full_name = data.full_name - # Validate password strength - is_valid, error_msg = validate_password_strength(password) + from app.routes.deps import auth_required + + # With enforcement off there is no user object to be an admin, so the + # Team page's "New user" action would be permanently unusable. + if auth_required() and not is_admin(current_user) and not self_registration_enabled(): + raise HTTPException( + status_code=403, + detail="Self-registration is disabled. Ask an administrator to create your account.", + ) + + username = data.username.strip() + email = data.email.strip() + if not username: + raise HTTPException(status_code=400, detail="Username cannot be empty") + + is_valid, error_msg = validate_password_strength(data.password) if not is_valid: raise HTTPException(status_code=400, detail=error_msg) - - # Check if username exists - async with db.execute( - "SELECT id FROM users WHERE username = ?", - (username,) - ) as cursor: + + async with db.execute("SELECT id FROM users WHERE username = ?", (username,)) as cursor: if await cursor.fetchone(): raise HTTPException(status_code=400, detail="Username already exists") - - # Check if email exists - async with db.execute( - "SELECT id FROM users WHERE email = ?", - (email,) - ) as cursor: + + async with db.execute("SELECT id FROM users WHERE email = ?", (email,)) as cursor: if await cursor.fetchone(): raise HTTPException(status_code=400, detail="Email already exists") - - # Create user - hashed_password = get_password_hash(password) + + # Only an administrator may pick the new account's roles; everyone else + # gets the read-only viewer role. + from app.routes.deps import auth_required as _auth_required + + may_assign_roles = is_admin(current_user) or not _auth_required() + requested_role_ids = data.role_ids if (data.role_ids and may_assign_roles) else None + + hashed_password = get_password_hash(data.password) cursor = await db.execute( """ INSERT INTO users (username, email, full_name, hashed_password, is_active, is_superuser) VALUES (?, ?, ?, ?, ?, ?) """, - (username, email, full_name, hashed_password, True, False) + (username, email, data.full_name, hashed_password, True, False), ) user_id = cursor.lastrowid - - # Assign default viewer role - async with db.execute("SELECT id FROM roles WHERE name = ?", ("viewer",)) as cursor: - role_row = await cursor.fetchone() - if role_row: + + if requested_role_ids: + for role_id in requested_role_ids: await db.execute( - "INSERT INTO user_roles (user_id, role_id) VALUES (?, ?)", - (user_id, role_row[0]) + "INSERT OR IGNORE INTO user_roles (user_id, role_id) VALUES (?, ?)", + (user_id, role_id), ) - + else: + async with db.execute("SELECT id FROM roles WHERE name = ?", ("viewer",)) as cursor: + role_row = await cursor.fetchone() + if role_row: + await db.execute( + "INSERT INTO user_roles (user_id, role_id) VALUES (?, ?)", + (user_id, role_row[0]), + ) + await db.commit() - + return { "message": "User created successfully", "user_id": user_id, - "username": username + "username": username, } @router.put("/change-password") async def change_password( - old_password: str, - new_password: str, + data: PasswordChange, current_user: dict = Depends(get_current_user), - db: aiosqlite.Connection = Depends(get_db) + db: aiosqlite.Connection = Depends(get_db), ): - """Change current user's password""" - # Validate new password strength - is_valid, error_msg = validate_password_strength(new_password) + """ + Change the signed-in user's password. + + Credentials are read from the request body; sending them as query + parameters (as this endpoint previously required) leaks them into access + logs, browser history and proxy caches. + """ + is_valid, error_msg = validate_password_strength(data.new_password) if not is_valid: raise HTTPException(status_code=400, detail=error_msg) - - # Verify old password + + if data.new_password == data.old_password: + raise HTTPException( + status_code=400, detail="The new password must differ from the current one" + ) + async with db.execute( - "SELECT hashed_password FROM users WHERE id = ?", - (current_user['id'],) + "SELECT hashed_password FROM users WHERE id = ?", (current_user["id"],) ) as cursor: row = await cursor.fetchone() - if not row or not verify_password(old_password, row[0]): + if not row or not verify_password(data.old_password, row[0]): raise HTTPException(status_code=400, detail="Incorrect password") - - # Update password - new_hashed = get_password_hash(new_password) + + new_hashed = get_password_hash(data.new_password) await db.execute( - "UPDATE users SET hashed_password = ? WHERE id = ?", - (new_hashed, current_user['id']) + "UPDATE users SET hashed_password = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?", + (new_hashed, current_user["id"]), ) await db.commit() - + return {"message": "Password changed successfully"} # ── User management (admin) ─────────────────────────────────────────────────── -def _is_admin(user: dict) -> bool: - """Return True if the user has superuser privileges (via role or is_superuser flag).""" - return bool(user.get("is_superuser")) or "superuser" in user.get("permissions", []) - - -@router.get("/users") -async def list_users( - current_user: dict = Depends(get_current_user), - db: aiosqlite.Connection = Depends(get_db), -): +@router.get("/users", dependencies=[admin_only]) +async def list_users(db: aiosqlite.Connection = Depends(get_db)): """List all users with their assigned roles (admin only).""" - if not _is_admin(current_user): - raise HTTPException(status_code=403, detail="Admin access required") async with db.execute( - "SELECT id, username, email, full_name, is_active, is_superuser, created_at FROM users ORDER BY username" + "SELECT id, username, email, full_name, is_active, is_superuser, last_login_at, created_at " + "FROM users ORDER BY username" ) as cursor: users = await cursor.fetchall() result = [] @@ -241,11 +278,11 @@ async def list_users( SELECT r.id, r.name, r.description FROM roles r JOIN user_roles ur ON r.id = ur.role_id WHERE ur.user_id = ? + ORDER BY r.name """, (user_dict["id"],), ) as cur2: - roles = await cur2.fetchall() - user_dict["roles"] = [dict(r) for r in roles] + user_dict["roles"] = [dict(r) for r in await cur2.fetchall()] result.append(user_dict) return result @@ -257,50 +294,84 @@ async def get_user( db: aiosqlite.Connection = Depends(get_db), ): """Get a single user with roles (admin or self).""" - if current_user["id"] != user_id and not _is_admin(current_user): + if current_user["id"] != user_id and not is_admin(current_user): raise HTTPException(status_code=403, detail="Admin access required") - async with db.execute( - "SELECT id, username, email, full_name, is_active, is_superuser, created_at FROM users WHERE id = ?", - (user_id,), - ) as cursor: - row = await cursor.fetchone() - if not row: + user = await _load_user_with_roles(db, user_id) + if not user: raise HTTPException(status_code=404, detail="User not found") - user_dict = dict(row) - async with db.execute( - """ - SELECT r.id, r.name, r.description - FROM roles r JOIN user_roles ur ON r.id = ur.role_id - WHERE ur.user_id = ? - """, - (user_dict["id"],), - ) as cur2: - roles = await cur2.fetchall() - user_dict["roles"] = [dict(r) for r in roles] - return user_dict + return user -@router.put("/users/{user_id}") +@router.put("/users/{user_id}", dependencies=[admin_only]) async def update_user( user_id: int, data: UserUpdate, - current_user: dict = Depends(get_current_user), + current_user: Optional[dict] = Depends(get_optional_user), db: aiosqlite.Connection = Depends(get_db), ): - """Update a user's active status or role assignments (admin only).""" - if not _is_admin(current_user): - raise HTTPException(status_code=403, detail="Admin access required") - async with db.execute("SELECT id FROM users WHERE id = ?", (user_id,)) as cursor: - if not await cursor.fetchone(): + """Update a user's profile, active status or role assignments (admin only).""" + async with db.execute( + "SELECT id, is_superuser FROM users WHERE id = ?", (user_id,) + ) as cursor: + target = await cursor.fetchone() + if not target: raise HTTPException(status_code=404, detail="User not found") + # Losing the last administrator would lock everyone out of user management, + # so a change that would strip the final admin is rejected up front. + if await _count_active_admins(db, exclude_user_id=user_id) == 0: + current = await _admin_state(db, user_id) + if current["is_admin"]: + will_be_active = data.is_active if data.is_active is not None else current["is_active"] + will_be_super = ( + data.is_superuser if data.is_superuser is not None else current["is_superuser"] + ) + will_have_admin_role = ( + await _role_ids_include_admin(db, data.role_ids) + if data.role_ids is not None else current["has_admin_role"] + ) + if not (will_be_active and (will_be_super or will_have_admin_role)): + raise HTTPException( + status_code=400, + detail="This is the last administrator account; " + "it cannot be demoted or deactivated.", + ) + + if data.full_name is not None: + await db.execute( + "UPDATE users SET full_name = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?", + (data.full_name, user_id), + ) + if data.email is not None: + async with db.execute( + "SELECT id FROM users WHERE email = ? AND id != ?", (data.email, user_id) + ) as cursor: + if await cursor.fetchone(): + raise HTTPException(status_code=400, detail="Email already exists") + await db.execute( + "UPDATE users SET email = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?", + (data.email, user_id), + ) if data.is_active is not None: await db.execute( "UPDATE users SET is_active = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?", (int(data.is_active), user_id), ) + if data.is_superuser is not None: + await db.execute( + "UPDATE users SET is_superuser = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?", + (int(data.is_superuser), user_id), + ) if data.role_ids is not None: + async with db.execute("SELECT id FROM roles") as cursor: + valid_role_ids = {row[0] for row in await cursor.fetchall()} + unknown = sorted(set(data.role_ids) - valid_role_ids) + if unknown: + raise HTTPException( + status_code=400, + detail=f"Unknown role id(s): {', '.join(str(r) for r in unknown)}", + ) await db.execute("DELETE FROM user_roles WHERE user_id = ?", (user_id,)) for rid in data.role_ids: await db.execute( @@ -308,36 +379,93 @@ async def update_user( (user_id, rid), ) + if data.password is not None: + is_valid, error_msg = validate_password_strength(data.password) + if not is_valid: + raise HTTPException(status_code=400, detail=error_msg) + await db.execute( + "UPDATE users SET hashed_password = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?", + (get_password_hash(data.password), user_id), + ) + await db.commit() - return await get_user(user_id, current_user, db) + return await _load_user_with_roles(db, user_id) -@router.delete("/users/{user_id}", status_code=204) +async def _admin_state(db: aiosqlite.Connection, user_id: int) -> dict: + """Describe how a user currently qualifies (or not) as an administrator.""" + async with db.execute( + "SELECT is_active, is_superuser FROM users WHERE id = ?", (user_id,) + ) as cursor: + row = await cursor.fetchone() + if not row: + return {"is_active": False, "is_superuser": False, "has_admin_role": False, "is_admin": False} + async with db.execute( + """ + SELECT 1 FROM user_roles ur + JOIN roles r ON r.id = ur.role_id + WHERE ur.user_id = ? AND r.permissions LIKE '%superuser%' + """, + (user_id,), + ) as cursor: + has_admin_role = await cursor.fetchone() is not None + is_active = bool(row[0]) + is_superuser = bool(row[1]) + return { + "is_active": is_active, + "is_superuser": is_superuser, + "has_admin_role": has_admin_role, + "is_admin": is_active and (is_superuser or has_admin_role), + } + + +async def _role_ids_include_admin(db: aiosqlite.Connection, role_ids: Optional[List[int]]) -> bool: + if not role_ids: + return False + placeholders = ",".join("?" * len(role_ids)) + async with db.execute( + f"SELECT 1 FROM roles WHERE id IN ({placeholders}) AND permissions LIKE '%superuser%'", + tuple(role_ids), + ) as cursor: + return await cursor.fetchone() is not None + + +@router.delete("/users/{user_id}", status_code=204, dependencies=[admin_only]) async def delete_user( user_id: int, - current_user: dict = Depends(get_current_user), + current_user: Optional[dict] = Depends(get_optional_user), db: aiosqlite.Connection = Depends(get_db), ): - """Delete a user (admin only; cannot delete yourself).""" - if not _is_admin(current_user): - raise HTTPException(status_code=403, detail="Admin access required") - if user_id == current_user["id"]: + """Delete a user (admin only; cannot delete yourself or the last admin).""" + if current_user and user_id == current_user["id"]: raise HTTPException(status_code=400, detail="Cannot delete your own account") async with db.execute("SELECT id FROM users WHERE id = ?", (user_id,)) as cursor: if not await cursor.fetchone(): raise HTTPException(status_code=404, detail="User not found") + if await _count_active_admins(db, exclude_user_id=user_id) == 0: + raise HTTPException( + status_code=400, + detail="This is the last administrator account; it cannot be deleted.", + ) await db.execute("DELETE FROM users WHERE id = ?", (user_id,)) await db.commit() -@router.get("/roles") -async def list_roles( - current_user: dict = Depends(get_current_user), - db: aiosqlite.Connection = Depends(get_db), -): - """List all roles (admin only).""" - if not _is_admin(current_user): - raise HTTPException(status_code=403, detail="Admin access required") - async with db.execute("SELECT id, name, description, permissions FROM roles ORDER BY name") as cursor: +@router.get("/roles", dependencies=[admin_only]) +async def list_roles(db: aiosqlite.Connection = Depends(get_db)): + """List all roles (admin only). Permissions are returned as a JSON array.""" + import json + + async with db.execute( + "SELECT id, name, description, permissions FROM roles ORDER BY name" + ) as cursor: rows = await cursor.fetchall() - return [dict(r) for r in rows] + roles = [] + for row in rows: + role = dict(row) + try: + role["permissions"] = json.loads(role["permissions"]) + except (json.JSONDecodeError, TypeError): + role["permissions"] = [] + roles.append(role) + return roles diff --git a/backend/app/routes/deps.py b/backend/app/routes/deps.py new file mode 100644 index 0000000..bc0f98e --- /dev/null +++ b/backend/app/routes/deps.py @@ -0,0 +1,189 @@ +""" +Shared FastAPI dependencies for authentication and authorization. + +The application ships a full RBAC model (roles, permissions, wildcards) but +before this module existed almost no endpoint consulted it, so every route was +reachable anonymously. `require_permission` turns that model into an actual +gate that routers can attach with one line. + +Enforcement can be disabled with ``REQUIRE_AUTH=false`` for local development +and for the test suite; it is on by default so a deployment is never +accidentally left open. +""" +from __future__ import annotations + +import json +import os +from typing import Optional + +import aiosqlite +from fastapi import Depends, HTTPException, Request, status +from fastapi.security import OAuth2PasswordBearer + +from app.db.database import get_db +from app.services.auth import check_permissions, decode_access_token + +# tokenUrl is relative to the app root; auto_error=False lets us return a +# clearer message than "Not authenticated" and lets anonymous access work when +# enforcement is switched off. +oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login", auto_error=False) + +CREDENTIALS_EXCEPTION = HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Not authenticated", + headers={"WWW-Authenticate": "Bearer"}, +) + + +def auth_required() -> bool: + """Whether API authorization is enforced (read at call time so tests can flip it).""" + return os.getenv("REQUIRE_AUTH", "true").strip().lower() not in ("0", "false", "no", "off") + + +async def load_user(db: aiosqlite.Connection, username: str) -> Optional[dict]: + """Load a user plus their flattened permission set, or None if unknown.""" + async with db.execute( + "SELECT id, username, email, full_name, is_active, is_superuser, created_at " + "FROM users WHERE username = ?", + (username,), + ) as cursor: + row = await cursor.fetchone() + if not row: + return None + + user = dict(row) + async with db.execute( + """ + SELECT r.permissions + FROM roles r + JOIN user_roles ur ON r.id = ur.role_id + WHERE ur.user_id = ? + """, + (user["id"],), + ) as cursor: + roles = await cursor.fetchall() + + permissions: set = set() + for role in roles: + try: + permissions.update(json.loads(role[0])) + except (json.JSONDecodeError, TypeError): + continue + if user.get("is_superuser"): + permissions.add("superuser") + user["permissions"] = sorted(permissions) + return user + + +async def get_current_user( + token: Optional[str] = Depends(oauth2_scheme), + db: aiosqlite.Connection = Depends(get_db), +) -> dict: + """Resolve the authenticated user, raising 401 when the token is absent or bad.""" + if not token: + raise CREDENTIALS_EXCEPTION + + payload = decode_access_token(token) + if payload is None: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Token is invalid or has expired", + headers={"WWW-Authenticate": "Bearer"}, + ) + + username = payload.get("sub") + if not username: + raise CREDENTIALS_EXCEPTION + + user = await load_user(db, username) + if user is None: + raise CREDENTIALS_EXCEPTION + if not user["is_active"]: + raise HTTPException(status_code=403, detail="This account has been deactivated") + return user + + +async def get_optional_user( + token: Optional[str] = Depends(oauth2_scheme), + db: aiosqlite.Connection = Depends(get_db), +) -> Optional[dict]: + """ + Resolve the current user when a valid token is present, else None. + + Used for audit attribution on endpoints that stay reachable without auth. + """ + if not token: + return None + payload = decode_access_token(token) + if payload is None: + return None + username = payload.get("sub") + if not username: + return None + user = await load_user(db, username) + if user is None or not user["is_active"]: + return None + return user + + +def require_permission(permission: str): + """ + Build a dependency that requires `permission` (or a wildcard covering it). + + Use as a route/router dependency: + + router = APIRouter(dependencies=[Depends(require_permission("tags.read"))]) + + When ``REQUIRE_AUTH`` is disabled the dependency resolves to None and the + endpoint stays open, which keeps single-user local setups frictionless. + """ + + async def dependency( + request: Request, + token: Optional[str] = Depends(oauth2_scheme), + db: aiosqlite.Connection = Depends(get_db), + ) -> Optional[dict]: + if not auth_required(): + # Still attach the user when a token happens to be supplied so that + # audit trails record an actor. + return await get_optional_user(token, db) + + user = await get_current_user(token, db) + if not check_permissions(user["permissions"], permission): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=f"Missing required permission: {permission}", + ) + request.state.user = user + return user + + return dependency + + +def require_admin(): + """Dependency requiring superuser privileges.""" + + async def dependency( + token: Optional[str] = Depends(oauth2_scheme), + db: aiosqlite.Connection = Depends(get_db), + ) -> Optional[dict]: + if not auth_required(): + return await get_optional_user(token, db) + user = await get_current_user(token, db) + if not is_admin(user): + raise HTTPException(status_code=403, detail="Admin access required") + return user + + return dependency + + +def is_admin(user: Optional[dict]) -> bool: + """True when the user has superuser privileges via flag or role.""" + if not user: + return False + return bool(user.get("is_superuser")) or "superuser" in user.get("permissions", []) + + +def actor_name(user: Optional[dict]) -> Optional[str]: + """Username to record in audit trails, or None for anonymous requests.""" + return user.get("username") if user else None diff --git a/backend/app/routes/folder_roots.py b/backend/app/routes/folder_roots.py index b36f548..ef12fff 100644 --- a/backend/app/routes/folder_roots.py +++ b/backend/app/routes/folder_roots.py @@ -1,17 +1,42 @@ """ Folder roots API endpoints """ -from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks -from typing import List -from datetime import datetime +import logging +import os +from datetime import datetime, timezone +from typing import List, Optional + import aiosqlite +from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query +from app.db import database as db_module from app.db.database import get_db, DB_PATH -from app.models.schemas import FolderRootCreate, FolderRootResponse, ScanRequest, ScanResponse -from app.services.scanner import scan_directory +from app.models.schemas import ( + FolderRootCreate, FolderRootResponse, FolderRootUpdate, ScanRequest, ScanResponse, +) +from app.routes.deps import require_permission +from app.services.scanner import scan_directory_detailed + +logger = logging.getLogger(__name__) router = APIRouter() +read_access = Depends(require_permission("roots.read")) +create_access = Depends(require_permission("roots.create")) +update_access = Depends(require_permission("roots.update")) +delete_access = Depends(require_permission("roots.delete")) +scan_access = Depends(require_permission("roots.scan")) + +# Reading file contents into the FTS index is optional per root; cap what we +# store so one enormous script cannot bloat the index. +FTS_CONTENT_LIMIT = 100_000 + + +def _utc_now() -> datetime: + """Timestamps are stored as UTC everywhere; naive local time would make + every duration and comparison wrong for non-UTC servers.""" + return datetime.now(timezone.utc) + def _normalize_mtime(value): """Normalize SQLite/Python datetime values for consistent comparisons.""" @@ -27,30 +52,96 @@ def _normalize_mtime(value): return value return str(value) -@router.get("/", response_model=List[FolderRootResponse]) + +def _root_from_row(row) -> dict: + d = dict(row) + for flag in ("recursive", "follow_symlinks", "enable_content_indexing", "enable_watch_mode"): + if flag in d: + d[flag] = bool(d[flag]) + return d + + +@router.get("/", response_model=List[FolderRootResponse], dependencies=[read_access]) async def list_folder_roots(db: aiosqlite.Connection = Depends(get_db)): """List all folder roots""" async with db.execute("SELECT * FROM folder_roots ORDER BY name") as cursor: rows = await cursor.fetchall() - return [dict(row) for row in rows] + return [_root_from_row(row) for row in rows] + -@router.post("/", response_model=FolderRootResponse) +@router.get("/stats", dependencies=[read_access]) +async def folder_root_stats(db: aiosqlite.Connection = Depends(get_db)): + """ + Per-root script counts and last scan outcome. + + The UI shows these next to each root so an operator can tell at a glance + whether a root has ever been scanned successfully. + """ + async with db.execute( + """ + SELECT fr.id, fr.name, fr.path, fr.last_scan_time, + COUNT(CASE WHEN s.missing_flag = 0 THEN 1 END) AS script_count, + COUNT(CASE WHEN s.missing_flag = 1 THEN 1 END) AS missing_count + FROM folder_roots fr + LEFT JOIN scripts s ON s.root_id = fr.id + GROUP BY fr.id + ORDER BY fr.name + """ + ) as cursor: + rows = await cursor.fetchall() + + stats = [] + for row in rows: + entry = dict(row) + async with db.execute( + """ + SELECT status, error_message, started_at, ended_at + FROM scan_events WHERE root_id = ? + ORDER BY started_at DESC, id DESC LIMIT 1 + """, + (entry["id"],), + ) as cur: + last = await cur.fetchone() + entry["last_scan"] = dict(last) if last else None + entry["path_exists"] = os.path.isdir(entry["path"]) + stats.append(entry) + return stats + + +@router.post("/", response_model=FolderRootResponse, status_code=201, + dependencies=[create_access]) async def create_folder_root( folder_root: FolderRootCreate, db: aiosqlite.Connection = Depends(get_db) ): """Create a new folder root""" + path = folder_root.path.strip() + if not path: + raise HTTPException(status_code=400, detail="Path cannot be empty") + if not folder_root.name.strip(): + raise HTTPException(status_code=400, detail="Name cannot be empty") + if folder_root.max_file_size <= 0: + raise HTTPException(status_code=400, detail="max_file_size must be greater than zero") + + # Store an absolute path so scans and the content-path security check agree + # regardless of the working directory the backend was started from. + path = os.path.abspath(os.path.expanduser(path)) + if not os.path.exists(path): + raise HTTPException(status_code=400, detail=f"Path does not exist: {path}") + if not os.path.isdir(path): + raise HTTPException(status_code=400, detail=f"Path is not a directory: {path}") + try: cursor = await db.execute( """ - INSERT INTO folder_roots (path, name, recursive, include_patterns, - exclude_patterns, follow_symlinks, max_file_size, + INSERT INTO folder_roots (path, name, recursive, include_patterns, + exclude_patterns, follow_symlinks, max_file_size, enable_content_indexing, enable_watch_mode) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) """, ( - folder_root.path, - folder_root.name, + path, + folder_root.name.strip(), folder_root.recursive, folder_root.include_patterns, folder_root.exclude_patterns, @@ -61,44 +152,187 @@ async def create_folder_root( ) ) await db.commit() - - # Fetch the created folder root + async with db.execute( "SELECT * FROM folder_roots WHERE id = ?", (cursor.lastrowid,) ) as cursor: row = await cursor.fetchone() - return dict(row) + return _root_from_row(row) except aiosqlite.IntegrityError: raise HTTPException(status_code=400, detail="Folder root with this path already exists") -@router.get("/{root_id}", response_model=FolderRootResponse) + +@router.get("/{root_id}", response_model=FolderRootResponse, dependencies=[read_access]) async def get_folder_root(root_id: int, db: aiosqlite.Connection = Depends(get_db)): """Get a specific folder root""" async with db.execute("SELECT * FROM folder_roots WHERE id = ?", (root_id,)) as cursor: row = await cursor.fetchone() if not row: raise HTTPException(status_code=404, detail="Folder root not found") - return dict(row) + return _root_from_row(row) + + +@router.put("/{root_id}", response_model=FolderRootResponse, dependencies=[update_access]) +async def update_folder_root( + root_id: int, + data: FolderRootUpdate, + db: aiosqlite.Connection = Depends(get_db), +): + """ + Update a folder root's settings. + + Previously the only way to change a root (for example to turn on content + indexing or watch mode) was to delete it and rescan from scratch. + """ + async with db.execute("SELECT * FROM folder_roots WHERE id = ?", (root_id,)) as cursor: + if not await cursor.fetchone(): + raise HTTPException(status_code=404, detail="Folder root not found") + + fields, params = [], [] + if data.name is not None: + if not data.name.strip(): + raise HTTPException(status_code=400, detail="Name cannot be empty") + fields.append("name = ?"); params.append(data.name.strip()) + if data.recursive is not None: + fields.append("recursive = ?"); params.append(int(data.recursive)) + if data.include_patterns is not None: + fields.append("include_patterns = ?"); params.append(data.include_patterns or None) + if data.exclude_patterns is not None: + fields.append("exclude_patterns = ?"); params.append(data.exclude_patterns or None) + if data.follow_symlinks is not None: + fields.append("follow_symlinks = ?"); params.append(int(data.follow_symlinks)) + if data.max_file_size is not None: + if data.max_file_size <= 0: + raise HTTPException(status_code=400, detail="max_file_size must be greater than zero") + fields.append("max_file_size = ?"); params.append(data.max_file_size) + if data.enable_content_indexing is not None: + fields.append("enable_content_indexing = ?"); params.append(int(data.enable_content_indexing)) + if data.enable_watch_mode is not None: + fields.append("enable_watch_mode = ?"); params.append(int(data.enable_watch_mode)) + + if fields: + fields.append("updated_at = CURRENT_TIMESTAMP") + params.append(root_id) + await db.execute( + f"UPDATE folder_roots SET {', '.join(fields)} WHERE id = ?", params + ) + await db.commit() + + async with db.execute("SELECT * FROM folder_roots WHERE id = ?", (root_id,)) as cursor: + row = await cursor.fetchone() + return _root_from_row(row) -@router.delete("/{root_id}") + +@router.delete("/{root_id}", dependencies=[delete_access]) async def delete_folder_root(root_id: int, db: aiosqlite.Connection = Depends(get_db)): """Delete a folder root and all its scripts""" async with db.execute("SELECT * FROM folder_roots WHERE id = ?", (root_id,)) as cursor: row = await cursor.fetchone() if not row: raise HTTPException(status_code=404, detail="Folder root not found") - + + # Stop any active watcher first so it cannot resurrect rows mid-delete. + try: + from app.services.watch import get_watch_manager + + manager = get_watch_manager(DB_PATH) + if manager.is_watching(root_id): + await manager.stop_watching(root_id) + except Exception as exc: # noqa: BLE001 - never block the delete + logger.warning("Could not stop watcher for root %s: %s", root_id, exc) + + # Attachment files live on disk with no foreign key to follow, so collect + # and unlink them before the cascade removes the rows that name them. + from app.routes.attachments import purge_attachment_files + + async with db.execute( + "SELECT id FROM scripts WHERE root_id = ?", (root_id,) + ) as cursor: + script_ids = [r[0] for r in await cursor.fetchall()] + if script_ids: + await purge_attachment_files(db, script_ids=script_ids) + + # scripts_fts is a virtual table with no foreign keys, so its rows must be + # removed explicitly before the cascade drops the scripts they point at. + await db.execute( + "DELETE FROM scripts_fts WHERE script_id IN (SELECT id FROM scripts WHERE root_id = ?)", + (root_id,), + ) await db.execute("DELETE FROM folder_roots WHERE id = ?", (root_id,)) await db.commit() return {"message": "Folder root deleted successfully"} -async def _perform_scan_background(root_id: int, root_data: dict, scan_id: int): + +async def _index_folders(db: aiosqlite.Connection, root_id: int, folder_paths: List[str]): + """ + Record the directory tree for a root so the Folders API has data. + + Nothing populated this table before, so folder listings, folder notes and + the folder tree endpoint always came back empty. + """ + if not folder_paths: + return + + ordered = sorted(set(folder_paths)) + for path in ordered: + await db.execute( + "INSERT OR IGNORE INTO folders (root_id, path) VALUES (?, ?)", (root_id, path) + ) + + # Link each folder to its parent in a second pass now that every row exists. + async with db.execute( + "SELECT id, path FROM folders WHERE root_id = ?", (root_id,) + ) as cursor: + rows = await cursor.fetchall() + by_path = {row[1]: row[0] for row in rows} + + for path, folder_id in by_path.items(): + parent_path = os.path.dirname(path) + parent_id = by_path.get(parent_path) + await db.execute( + "UPDATE folders SET parent_id = ? WHERE id = ?", + (parent_id if parent_id != folder_id else None, folder_id), + ) + + # Drop folders that no longer exist on disk. + known = set(ordered) + for path, folder_id in by_path.items(): + if path not in known: + await db.execute("DELETE FROM folders WHERE id = ?", (folder_id,)) + + +async def _index_script_content(db: aiosqlite.Connection, script_id: int, + name: str, path: str, read_content: bool): + """Refresh a script's row in the FTS index.""" + content = "" + if read_content: + try: + with open(path, "r", encoding="utf-8", errors="ignore") as fh: + content = fh.read(FTS_CONTENT_LIMIT) + except OSError: + content = "" + + async with db.execute( + "SELECT GROUP_CONCAT(content, ' ') FROM script_notes WHERE script_id = ?", + (script_id,), + ) as cursor: + row = await cursor.fetchone() + notes = row[0] if row and row[0] else "" + + await db.execute("DELETE FROM scripts_fts WHERE script_id = ?", (script_id,)) + await db.execute( + "INSERT INTO scripts_fts (script_id, name, path, content, notes) VALUES (?, ?, ?, ?, ?)", + (script_id, name, path, content, notes), + ) + + +async def _perform_scan_background(root_id: int, root_data: dict, scan_id: int, + full_scan: bool = False): """Background task to perform the actual scanning""" try: - async with aiosqlite.connect(DB_PATH) as db: - # Scan directory - scripts = await scan_directory( + async with db_module.connection(DB_PATH) as db: + scripts, folders = await scan_directory_detailed( root_data['path'], root_data['recursive'], root_data['include_patterns'], @@ -106,63 +340,82 @@ async def _perform_scan_background(root_id: int, root_data: dict, scan_id: int): root_data['follow_symlinks'], root_data['max_file_size'] ) - + + index_content = bool(root_data.get('enable_content_indexing')) new_count = 0 updated_count = 0 deleted_count = 0 - - # Process scanned scripts + + # Include the root directory itself so scripts sitting directly in + # it are still attached to a folder row. + await _index_folders(db, root_id, [root_data['path']] + folders) + + # Map folders by path so each script can be attached to its directory. + async with db.execute( + "SELECT id, path FROM folders WHERE root_id = ?", (root_id,) + ) as cursor: + folder_ids = {row[1]: row[0] for row in await cursor.fetchall()} + for script in scripts: - # Check if script already exists + folder_id = folder_ids.get(os.path.dirname(script['path'])) async with db.execute( "SELECT id, hash, mtime FROM scripts WHERE path = ?", (script['path'],) ) as cursor: existing = await cursor.fetchone() - + if existing: - # Update if changed - if ( + changed = ( existing[1] != script['hash'] or _normalize_mtime(existing[2]) != _normalize_mtime(script['mtime']) - ): + ) + if changed or full_scan: await db.execute( """ - UPDATE scripts - SET name = ?, extension = ?, language = ?, size = ?, - mtime = ?, hash = ?, line_count = ?, missing_flag = 0, + UPDATE scripts + SET root_id = ?, folder_id = ?, name = ?, extension = ?, + language = ?, size = ?, mtime = ?, hash = ?, + line_count = ?, missing_flag = 0, updated_at = CURRENT_TIMESTAMP WHERE id = ? """, ( + root_id, folder_id, script['name'], script['extension'], script['language'], script['size'], script['mtime'], script['hash'], script['line_count'], existing[0] ) ) - updated_count += 1 + if changed: + updated_count += 1 + if index_content: + await _index_script_content( + db, existing[0], script['name'], script['path'], True + ) else: - # Mark as not missing await db.execute( - "UPDATE scripts SET missing_flag = 0 WHERE id = ?", - (existing[0],) + "UPDATE scripts SET missing_flag = 0, folder_id = ? WHERE id = ?", + (folder_id, existing[0],) ) else: - # Insert new script - await db.execute( + cursor = await db.execute( """ - INSERT INTO scripts (root_id, path, name, extension, language, + INSERT INTO scripts (root_id, folder_id, path, name, extension, language, size, mtime, hash, line_count) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, ( - root_id, script['path'], script['name'], script['extension'], - script['language'], script['size'], script['mtime'], - script['hash'], script['line_count'] + root_id, folder_id, script['path'], script['name'], + script['extension'], script['language'], script['size'], + script['mtime'], script['hash'], script['line_count'] ) ) new_count += 1 - + if index_content: + await _index_script_content( + db, cursor.lastrowid, script['name'], script['path'], True + ) + # Mark missing scripts scanned_paths = {s['path'] for s in scripts} async with db.execute( @@ -170,16 +423,18 @@ async def _perform_scan_background(root_id: int, root_data: dict, scan_id: int): (root_id,) ) as cursor: rows = await cursor.fetchall() - for row in rows: - if row[1] not in scanned_paths: - await db.execute( - "UPDATE scripts SET missing_flag = 1 WHERE id = ?", - (row[0],) - ) - deleted_count += 1 - - # Update scan event with success - ended_at = datetime.now() + for row in rows: + if row[1] not in scanned_paths: + await db.execute( + "UPDATE scripts SET missing_flag = 1 WHERE id = ?", + (row[0],) + ) + await db.execute( + "DELETE FROM scripts_fts WHERE script_id = ?", (row[0],) + ) + deleted_count += 1 + + ended_at = _utc_now() await db.execute( """ UPDATE scan_events @@ -187,31 +442,34 @@ async def _perform_scan_background(root_id: int, root_data: dict, scan_id: int): new_count = ?, updated_count = ?, deleted_count = ? WHERE id = ? """, - (ended_at, new_count, updated_count, deleted_count, scan_id) - ) - - # Update folder root scan time - await db.execute( - "UPDATE folder_roots SET last_scan_time = ? WHERE id = ?", - (ended_at, root_id) + (ended_at.isoformat(), new_count, updated_count, deleted_count, scan_id) ) - - await db.commit() - - except Exception as e: - # Update scan event with error - async with aiosqlite.connect(DB_PATH) as db: await db.execute( - """ - UPDATE scan_events - SET ended_at = ?, status = 'failed', error_message = ? - WHERE id = ? - """, - (datetime.now(), str(e), scan_id) + "UPDATE folder_roots SET last_scan_time = ?, updated_at = CURRENT_TIMESTAMP " + "WHERE id = ?", + (ended_at.isoformat(), root_id) ) await db.commit() -@router.post("/{root_id}/scan", response_model=ScanResponse) + except Exception as exc: # noqa: BLE001 - the failure belongs in the scan event + logger.exception("Scan %s for root %s failed", scan_id, root_id) + try: + async with db_module.connection(DB_PATH) as db: + await db.execute( + """ + UPDATE scan_events + SET ended_at = ?, status = 'failed', error_count = 1, error_message = ? + WHERE id = ? + """, + (_utc_now().isoformat(), str(exc), scan_id) + ) + await db.commit() + except Exception: # noqa: BLE001 + logger.exception("Could not record scan failure for scan %s", scan_id) + + +@router.post("/{root_id}/scan", response_model=ScanResponse, status_code=202, + dependencies=[scan_access]) async def scan_folder_root( root_id: int, scan_request: ScanRequest, @@ -219,29 +477,41 @@ async def scan_folder_root( db: aiosqlite.Connection = Depends(get_db) ): """Scan a folder root for scripts (returns immediately, runs in background)""" - # Get folder root details async with db.execute("SELECT * FROM folder_roots WHERE id = ?", (root_id,)) as cursor: root_row = await cursor.fetchone() if not root_row: raise HTTPException(status_code=404, detail="Folder root not found") - root = dict(root_row) - - # Create scan event - started_at = datetime.now() + root = _root_from_row(root_row) + + if not os.path.isdir(root["path"]): + raise HTTPException( + status_code=400, + detail=f"Path is no longer accessible: {root['path']}", + ) + + # Refuse to queue a second scan of the same root; two concurrent walks + # would fight over the same rows and double-count the results. + async with db.execute( + "SELECT id FROM scan_events WHERE root_id = ? AND status = 'running'", (root_id,) + ) as cursor: + if await cursor.fetchone(): + raise HTTPException(status_code=409, detail="A scan is already running for this root") + + started_at = _utc_now() cursor = await db.execute( """ INSERT INTO scan_events (root_id, started_at, status) VALUES (?, ?, 'running') """, - (root_id, started_at) + (root_id, started_at.isoformat()) ) scan_id = cursor.lastrowid await db.commit() - - # Schedule scan as background task - background_tasks.add_task(_perform_scan_background, root_id, root, scan_id) - - # Return immediately with scan ID + + background_tasks.add_task( + _perform_scan_background, root_id, root, scan_id, scan_request.full_scan + ) + return { 'scan_id': scan_id, 'status': 'running', @@ -253,34 +523,53 @@ async def scan_folder_root( 'ended_at': None } -@router.get("/{root_id}/scan/{scan_id}") + +@router.get("/{root_id}/scans", dependencies=[read_access]) +async def list_scans( + root_id: int, + limit: int = Query(20, ge=1, le=200), + db: aiosqlite.Connection = Depends(get_db), +): + """Recent scan history for a root.""" + async with db.execute("SELECT id FROM folder_roots WHERE id = ?", (root_id,)) as cursor: + if not await cursor.fetchone(): + raise HTTPException(status_code=404, detail="Folder root not found") + async with db.execute( + "SELECT * FROM scan_events WHERE root_id = ? ORDER BY started_at DESC, id DESC LIMIT ?", + (root_id, limit), + ) as cursor: + rows = await cursor.fetchall() + return [dict(r) for r in rows] + + +@router.get("/{root_id}/scan/{scan_id}", dependencies=[read_access]) async def get_scan_status( root_id: int, scan_id: int, db: aiosqlite.Connection = Depends(get_db) ): """Get the status of a scan operation""" - # Verify root exists async with db.execute("SELECT id FROM folder_roots WHERE id = ?", (root_id,)) as cursor: if not await cursor.fetchone(): raise HTTPException(status_code=404, detail="Folder root not found") - - # Get scan event + async with db.execute( - "SELECT id, status, new_count, updated_count, deleted_count, error_message, started_at, ended_at FROM scan_events WHERE id = ? AND root_id = ?", + "SELECT id, status, new_count, updated_count, deleted_count, error_count, " + "error_message, started_at, ended_at FROM scan_events WHERE id = ? AND root_id = ?", (scan_id, root_id) ) as cursor: row = await cursor.fetchone() if not row: raise HTTPException(status_code=404, detail="Scan not found") - + return { 'scan_id': row[0], 'status': row[1], 'new_count': row[2] or 0, 'updated_count': row[3] or 0, 'deleted_count': row[4] or 0, - 'error_message': row[5], - 'started_at': row[6], - 'ended_at': row[7] + 'error_count': row[5] or 0, + 'error_message': row[6], + 'started_at': row[7], + 'ended_at': row[8] } diff --git a/backend/app/routes/folders.py b/backend/app/routes/folders.py index b1f5659..311a019 100644 --- a/backend/app/routes/folders.py +++ b/backend/app/routes/folders.py @@ -7,10 +7,14 @@ from app.db.database import get_db from app.models.schemas import FolderResponse, FolderNoteUpdate +from app.routes.deps import require_permission router = APIRouter() -@router.get("/", response_model=List[FolderResponse]) +read_access = Depends(require_permission("folders.read")) +update_access = Depends(require_permission("folders.update")) + +@router.get("/", response_model=List[FolderResponse], dependencies=[read_access]) async def list_folders( root_id: int = None, db: aiosqlite.Connection = Depends(get_db) @@ -27,7 +31,7 @@ async def list_folders( rows = await cursor.fetchall() return [dict(row) for row in rows] -@router.get("/{folder_id}", response_model=FolderResponse) +@router.get("/{folder_id}", response_model=FolderResponse, dependencies=[read_access]) async def get_folder(folder_id: int, db: aiosqlite.Connection = Depends(get_db)): """Get a specific folder""" async with db.execute("SELECT * FROM folders WHERE id = ?", (folder_id,)) as cursor: @@ -36,7 +40,7 @@ async def get_folder(folder_id: int, db: aiosqlite.Connection = Depends(get_db)) raise HTTPException(status_code=404, detail="Folder not found") return dict(row) -@router.put("/{folder_id}/note") +@router.put("/{folder_id}/note", dependencies=[update_access]) async def update_folder_note( folder_id: int, note_update: FolderNoteUpdate, @@ -57,7 +61,7 @@ async def update_folder_note( return {"message": "Folder note updated successfully"} -@router.delete("/{folder_id}/note") +@router.delete("/{folder_id}/note", dependencies=[update_access]) async def delete_folder_note(folder_id: int, db: aiosqlite.Connection = Depends(get_db)): """Delete a folder note""" # Check if folder exists @@ -74,7 +78,7 @@ async def delete_folder_note(folder_id: int, db: aiosqlite.Connection = Depends( return {"message": "Folder note deleted successfully"} -@router.get("/tree/{root_id}") +@router.get("/tree/{root_id}", dependencies=[read_access]) async def get_folder_tree(root_id: int, db: aiosqlite.Connection = Depends(get_db)): """Get folder tree for a specific root in hierarchical structure""" # Get all folders for this root diff --git a/backend/app/routes/fts.py b/backend/app/routes/fts.py index 60fa96a..d575e24 100644 --- a/backend/app/routes/fts.py +++ b/backend/app/routes/fts.py @@ -6,11 +6,15 @@ from app.db.database import get_db from app.models.schemas import FTSSearchRequest, PaginatedResponse +from app.routes.deps import require_permission from app.services.fts import search_fts, rebuild_fts_index router = APIRouter() -@router.post("/", response_model=PaginatedResponse) +read_access = Depends(require_permission("search.read")) +rebuild_access = Depends(require_permission("roots.scan")) + +@router.post("/", response_model=PaginatedResponse, dependencies=[read_access]) async def fts_search( search: FTSSearchRequest, db: aiosqlite.Connection = Depends(get_db) @@ -29,10 +33,13 @@ async def fts_search( search.page_size ) return result + except ValueError as exc: + # Bad user input (e.g. an empty query) is a 400, not a server error. + raise HTTPException(status_code=400, detail=str(exc)) except Exception as e: raise HTTPException(status_code=500, detail=f"FTS search failed: {str(e)}") -@router.post("/rebuild") +@router.post("/rebuild", dependencies=[rebuild_access]) async def rebuild_index( root_id: int = None, db: aiosqlite.Connection = Depends(get_db) @@ -51,7 +58,7 @@ async def rebuild_index( except Exception as e: raise HTTPException(status_code=500, detail=f"Index rebuild failed: {str(e)}") -@router.get("/status") +@router.get("/status", dependencies=[read_access]) async def fts_status(db: aiosqlite.Connection = Depends(get_db)): """Get FTS index status and statistics""" # Count indexed scripts diff --git a/backend/app/routes/monitors.py b/backend/app/routes/monitors.py index ba9583e..81b63f7 100644 --- a/backend/app/routes/monitors.py +++ b/backend/app/routes/monitors.py @@ -3,11 +3,15 @@ Monitors listen for periodic pings from external jobs (servers, cron scripts, etc.). If a ping does not arrive within the expected interval + grace period, the monitor -transitions to 'failing' and an incident is created. +transitions to 'failing', an incident is created and the configured notification +channels are alerted. + +Overdue detection runs on a timer in app.services.scheduler, so alerts fire even +when nobody has the UI open. Listing monitors also evaluates them so the page is +never stale. """ import json import secrets -from datetime import datetime, timezone from typing import List, Optional import aiosqlite @@ -18,61 +22,85 @@ MonitorCreate, MonitorResponse, MonitorUpdate, IncidentResponse, ) +from app.routes.deps import require_permission +from app.services import notifier +from app.services.scheduler import evaluate_monitors, parse_channel_ids router = APIRouter() +read_access = Depends(require_permission("monitors.read")) +write_access = Depends(require_permission("monitors.update")) + +MIN_INTERVAL_SECONDS = 10 +MAX_INTERVAL_SECONDS = 60 * 60 * 24 * 31 # a month + def _parse_channel_ids(raw: Optional[str]) -> List[int]: - if not raw: - return [] - try: - return json.loads(raw) - except (json.JSONDecodeError, TypeError): - return [] + return parse_channel_ids(raw) -def _monitor_from_row(row) -> dict: +def _monitor_from_row(row, include_ping_key: bool = False) -> dict: d = dict(row) d["notify_channel_ids"] = _parse_channel_ids(d.get("notify_channel_ids")) + if not include_ping_key: + d.pop("ping_key", None) return d -async def _create_incident(db: aiosqlite.Connection, monitor_id: int, title: str, - description: str, severity: str = "warning") -> int: - """Create an incident for a failing monitor (skip if an open one already exists).""" +def _validate_intervals(expected: Optional[int], grace: Optional[int]): + if expected is not None and not (MIN_INTERVAL_SECONDS <= expected <= MAX_INTERVAL_SECONDS): + raise HTTPException( + status_code=400, + detail=f"expected_interval_seconds must be between {MIN_INTERVAL_SECONDS} " + f"and {MAX_INTERVAL_SECONDS}", + ) + if grace is not None and not (0 <= grace <= MAX_INTERVAL_SECONDS): + raise HTTPException( + status_code=400, + detail=f"grace_period_seconds must be between 0 and {MAX_INTERVAL_SECONDS}", + ) + + +async def _verify_channels(db: aiosqlite.Connection, channel_ids: List[int]): + """Reject references to notification channels that do not exist.""" + if not channel_ids: + return + placeholders = ",".join("?" * len(channel_ids)) async with db.execute( - "SELECT id FROM incidents WHERE source_type='monitor' AND source_id=? AND status='open'", - (monitor_id,), + f"SELECT id FROM notification_channels WHERE id IN ({placeholders})", + tuple(channel_ids), ) as cur: - existing = await cur.fetchone() - if existing: - return existing[0] - cur = await db.execute( - """ - INSERT INTO incidents (title, source_type, source_id, status, severity, description) - VALUES (?, 'monitor', ?, 'open', ?, ?) - """, - (title, monitor_id, severity, description), - ) - return cur.lastrowid + found = {row[0] for row in await cur.fetchall()} + missing = sorted(set(channel_ids) - found) + if missing: + raise HTTPException( + status_code=400, + detail=f"Unknown notification channel id(s): {', '.join(str(m) for m in missing)}", + ) # ── CRUD ───────────────────────────────────────────────────────────────────── -@router.get("/", response_model=List[MonitorResponse]) +@router.get("/", response_model=List[MonitorResponse], dependencies=[read_access]) async def list_monitors(db: aiosqlite.Connection = Depends(get_db)): """List all heartbeat monitors with up-to-date status.""" - await _refresh_monitor_statuses(db) + await evaluate_monitors(db) async with db.execute("SELECT * FROM monitors ORDER BY name") as cur: rows = await cur.fetchall() return [_monitor_from_row(r) for r in rows] -@router.post("/", response_model=MonitorResponse, status_code=201) +@router.post("/", response_model=MonitorResponse, status_code=201, + dependencies=[Depends(require_permission("monitors.create"))]) async def create_monitor( data: MonitorCreate, db: aiosqlite.Connection = Depends(get_db) ): """Create a new heartbeat monitor.""" + if not data.name.strip(): + raise HTTPException(status_code=400, detail="Monitor name cannot be empty") + _validate_intervals(data.expected_interval_seconds, data.grace_period_seconds) + await _verify_channels(db, data.notify_channel_ids) + ping_key = secrets.token_urlsafe(24) channel_ids_json = json.dumps(data.notify_channel_ids) try: @@ -84,7 +112,7 @@ async def create_monitor( VALUES (?, ?, ?, ?, ?, ?) """, ( - data.name, data.description, + data.name.strip(), data.description, data.expected_interval_seconds, data.grace_period_seconds, ping_key, channel_ids_json, ), @@ -95,21 +123,23 @@ async def create_monitor( raise HTTPException(status_code=400, detail="Monitor name already exists") async with db.execute("SELECT * FROM monitors WHERE id = ?", (monitor_id,)) as cur: row = await cur.fetchone() - return _monitor_from_row(row) + # The creator needs the key once to wire up their cron job. + return _monitor_from_row(row, include_ping_key=True) -@router.get("/{monitor_id}", response_model=MonitorResponse) +@router.get("/{monitor_id}", response_model=MonitorResponse, dependencies=[read_access]) async def get_monitor(monitor_id: int, db: aiosqlite.Connection = Depends(get_db)): """Get a single monitor.""" - await _refresh_single_monitor_status(db, monitor_id) + async with db.execute("SELECT id FROM monitors WHERE id = ?", (monitor_id,)) as cur: + if not await cur.fetchone(): + raise HTTPException(status_code=404, detail="Monitor not found") + await evaluate_monitors(db) async with db.execute("SELECT * FROM monitors WHERE id = ?", (monitor_id,)) as cur: row = await cur.fetchone() - if not row: - raise HTTPException(status_code=404, detail="Monitor not found") return _monitor_from_row(row) -@router.put("/{monitor_id}", response_model=MonitorResponse) +@router.put("/{monitor_id}", response_model=MonitorResponse, dependencies=[write_access]) async def update_monitor( monitor_id: int, data: MonitorUpdate, db: aiosqlite.Connection = Depends(get_db) ): @@ -118,6 +148,10 @@ async def update_monitor( if not await cur.fetchone(): raise HTTPException(status_code=404, detail="Monitor not found") + _validate_intervals(data.expected_interval_seconds, data.grace_period_seconds) + if data.notify_channel_ids is not None: + await _verify_channels(db, data.notify_channel_ids) + fields, params = [], [] if data.name is not None: fields.append("name = ?"); params.append(data.name) @@ -133,27 +167,36 @@ async def update_monitor( if fields: fields.append("updated_at = CURRENT_TIMESTAMP") params.append(monitor_id) - await db.execute( - f"UPDATE monitors SET {', '.join(fields)} WHERE id = ?", params - ) - await db.commit() + try: + await db.execute( + f"UPDATE monitors SET {', '.join(fields)} WHERE id = ?", params + ) + await db.commit() + except aiosqlite.IntegrityError: + raise HTTPException(status_code=400, detail="Monitor name already exists") async with db.execute("SELECT * FROM monitors WHERE id = ?", (monitor_id,)) as cur: row = await cur.fetchone() return _monitor_from_row(row) -@router.delete("/{monitor_id}", status_code=204) +@router.delete("/{monitor_id}", status_code=204, + dependencies=[Depends(require_permission("monitors.delete"))]) async def delete_monitor(monitor_id: int, db: aiosqlite.Connection = Depends(get_db)): """Delete a monitor and all its ping history.""" async with db.execute("SELECT id FROM monitors WHERE id = ?", (monitor_id,)) as cur: if not await cur.fetchone(): raise HTTPException(status_code=404, detail="Monitor not found") await db.execute("DELETE FROM monitors WHERE id = ?", (monitor_id,)) + # Incidents are not FK-linked to monitors (source_id is a loose reference), + # so clean them up explicitly instead of leaving dangling alerts behind. + await db.execute( + "DELETE FROM incidents WHERE source_type = 'monitor' AND source_id = ?", (monitor_id,) + ) await db.commit() -@router.post("/{monitor_id}/pause", response_model=MonitorResponse) +@router.post("/{monitor_id}/pause", response_model=MonitorResponse, dependencies=[write_access]) async def pause_monitor(monitor_id: int, db: aiosqlite.Connection = Depends(get_db)): """Pause a monitor (stops overdue detection and alerting).""" async with db.execute("SELECT id FROM monitors WHERE id = ?", (monitor_id,)) as cur: @@ -169,7 +212,7 @@ async def pause_monitor(monitor_id: int, db: aiosqlite.Connection = Depends(get_ return _monitor_from_row(row) -@router.post("/{monitor_id}/resume", response_model=MonitorResponse) +@router.post("/{monitor_id}/resume", response_model=MonitorResponse, dependencies=[write_access]) async def resume_monitor(monitor_id: int, db: aiosqlite.Connection = Depends(get_db)): """Resume a paused monitor.""" async with db.execute("SELECT * FROM monitors WHERE id = ?", (monitor_id,)) as cur: @@ -178,13 +221,15 @@ async def resume_monitor(monitor_id: int, db: aiosqlite.Connection = Depends(get raise HTTPException(status_code=404, detail="Monitor not found") if row["status"] != "paused": raise HTTPException(status_code=400, detail="Monitor is not paused") - # Restore to 'new' if never pinged, else recompute status on next list + # Restore to 'new' if never pinged, else 'ok'; the evaluator immediately + # re-flags it as failing if the last ping is already outside the deadline. new_status = "new" if row["last_ping_at"] is None else "ok" await db.execute( "UPDATE monitors SET status = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?", (new_status, monitor_id), ) await db.commit() + await evaluate_monitors(db) async with db.execute("SELECT * FROM monitors WHERE id = ?", (monitor_id,)) as cur: row = await cur.fetchone() return _monitor_from_row(row) @@ -195,47 +240,96 @@ async def resume_monitor(monitor_id: int, db: aiosqlite.Connection = Depends(get @router.post("/ping/{ping_key}") async def receive_ping(ping_key: str, request: Request, db: aiosqlite.Connection = Depends(get_db)): """ - Receive a heartbeat ping. Call this URL from your cron script to signal + Receive a heartbeat ping. Call this URL from your cron script to signal that it ran successfully. + + Deliberately unauthenticated: the 192-bit random ping key is the credential, + so a cron job needs nothing but the URL. """ async with db.execute( - "SELECT id, name FROM monitors WHERE ping_key = ?", (ping_key,) + "SELECT id, name, notify_channel_ids FROM monitors WHERE ping_key = ?", (ping_key,) ) as cur: row = await cur.fetchone() if not row: raise HTTPException(status_code=404, detail="Unknown ping key") monitor_id, monitor_name = row[0], row[1] + channel_ids = _parse_channel_ids(row[2]) source_ip = request.client.host if request.client else None + async with db.execute("SELECT status FROM monitors WHERE id = ?", (monitor_id,)) as cur: + previous_status = (await cur.fetchone())[0] + await db.execute( "INSERT INTO monitor_pings (monitor_id, source_ip) VALUES (?, ?)", (monitor_id, source_ip), ) + # A ping from a paused monitor should not silently un-pause it. + new_status = "paused" if previous_status == "paused" else "ok" await db.execute( """ UPDATE monitors - SET last_ping_at = CURRENT_TIMESTAMP, status = 'ok', updated_at = CURRENT_TIMESTAMP + SET last_ping_at = CURRENT_TIMESTAMP, status = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? """, - (monitor_id,), + (new_status, monitor_id), ) # Resolve any open incident for this monitor await db.execute( """ UPDATE incidents SET status = 'resolved', resolved_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP - WHERE source_type = 'monitor' AND source_id = ? AND status = 'open' + WHERE source_type = 'monitor' AND source_id = ? AND status IN ('open', 'acknowledged') """, (monitor_id,), ) await db.commit() - return {"message": f"Ping received for monitor '{monitor_name}'"} + + if previous_status == "failing" and channel_ids: + await notifier.dispatch( + db, channel_ids, + notifier.Alert( + title=f"Monitor '{monitor_name}' recovered", + body="A heartbeat was received; the monitor is reporting again.", + severity="info", + source="script-manager/monitors", + ), + ) + + return { + "message": f"Ping received for monitor '{monitor_name}'", + "monitor_id": monitor_id, + "status": new_status, + "recovered": previous_status == "failing", + } # ── Ping history ────────────────────────────────────────────────────────────── -@router.get("/{monitor_id}/pings") +@router.get("/{monitor_id}/ping-url", dependencies=[write_access]) +async def get_monitor_ping_url(monitor_id: int, db: aiosqlite.Connection = Depends(get_db)): + """ + Reveal a monitor's ping key. + + Kept on its own endpoint, and behind write access rather than read access, + so a read-only viewer cannot collect the keys that let anything forge + heartbeats for every monitor. + """ + async with db.execute( + "SELECT name, ping_key FROM monitors WHERE id = ?", (monitor_id,) + ) as cur: + row = await cur.fetchone() + if not row: + raise HTTPException(status_code=404, detail="Monitor not found") + return { + "monitor_id": monitor_id, + "name": row["name"], + "ping_key": row["ping_key"], + "ping_path": f"/api/monitors/ping/{row['ping_key']}", + } + + +@router.get("/{monitor_id}/pings", dependencies=[read_access]) async def get_monitor_pings( monitor_id: int, limit: int = Query(50, ge=1, le=500), @@ -246,7 +340,7 @@ async def get_monitor_pings( if not await cur.fetchone(): raise HTTPException(status_code=404, detail="Monitor not found") async with db.execute( - "SELECT * FROM monitor_pings WHERE monitor_id = ? ORDER BY pinged_at DESC LIMIT ?", + "SELECT * FROM monitor_pings WHERE monitor_id = ? ORDER BY pinged_at DESC, id DESC LIMIT ?", (monitor_id, limit), ) as cur: rows = await cur.fetchall() @@ -255,7 +349,8 @@ async def get_monitor_pings( # ── Incidents for a monitor ─────────────────────────────────────────────────── -@router.get("/{monitor_id}/incidents", response_model=List[IncidentResponse]) +@router.get("/{monitor_id}/incidents", response_model=List[IncidentResponse], + dependencies=[read_access]) async def get_monitor_incidents( monitor_id: int, db: aiosqlite.Connection = Depends(get_db) ): @@ -269,63 +364,3 @@ async def get_monitor_incidents( ) as cur: rows = await cur.fetchall() return [dict(r) for r in rows] - - -# ── Internal helpers ────────────────────────────────────────────────────────── - -async def _refresh_monitor_statuses(db: aiosqlite.Connection): - """Check all monitors and flip overdue ones to 'failing', creating incidents.""" - async with db.execute("SELECT * FROM monitors WHERE status != 'paused'") as cur: - monitors = await cur.fetchall() - for m in monitors: - await _refresh_single_monitor_status(db, m["id"], m) - await db.commit() - - -async def _refresh_single_monitor_status( - db: aiosqlite.Connection, monitor_id: int, row=None -): - if row is None: - async with db.execute( - "SELECT * FROM monitors WHERE id = ?", (monitor_id,) - ) as cur: - row = await cur.fetchone() - if row is None: - return - - last_ping = row["last_ping_at"] - if last_ping is None: - # Never pinged yet – preserve existing 'new' status without modification - return - - now = datetime.now(timezone.utc) - # Parse last_ping (SQLite stores as string without tz) - if isinstance(last_ping, str): - try: - last_ping_dt = datetime.fromisoformat(last_ping.replace("Z", "+00:00")) - except ValueError: - return - else: - last_ping_dt = last_ping - - if last_ping_dt.tzinfo is None: - last_ping_dt = last_ping_dt.replace(tzinfo=timezone.utc) - - deadline_seconds = row["expected_interval_seconds"] + row["grace_period_seconds"] - elapsed = (now - last_ping_dt).total_seconds() - - if elapsed > deadline_seconds and row["status"] not in ("failing", "paused"): - await db.execute( - """ - UPDATE monitors SET status = 'failing', updated_at = CURRENT_TIMESTAMP - WHERE id = ? - """, - (monitor_id,), - ) - await _create_incident( - db, - monitor_id, - f"Monitor '{row['name']}' is overdue", - f"No ping received for {int(elapsed)}s (deadline: {deadline_seconds}s)", - severity="critical", - ) diff --git a/backend/app/routes/notes.py b/backend/app/routes/notes.py index 5837584..c1f6620 100644 --- a/backend/app/routes/notes.py +++ b/backend/app/routes/notes.py @@ -1,17 +1,25 @@ """ Notes API endpoints """ +import html from fastapi import APIRouter, Depends, HTTPException from typing import List import aiosqlite from app.db.database import get_db from app.models.schemas import NoteCreate, NoteResponse +from app.services.fts import update_script_notes_fts from app.services.markdown import render_markdown, extract_markdown_preview +from app.routes.deps import require_permission router = APIRouter() -@router.get("/script/{script_id}", response_model=List[NoteResponse]) +read_access = Depends(require_permission("notes.read")) +create_access = Depends(require_permission("notes.create")) +update_access = Depends(require_permission("notes.update")) +delete_access = Depends(require_permission("notes.delete")) + +@router.get("/script/{script_id}", response_model=List[NoteResponse], dependencies=[read_access]) async def get_script_notes(script_id: int, db: aiosqlite.Connection = Depends(get_db)): """Get all notes for a script""" async with db.execute("SELECT id FROM scripts WHERE id = ?", (script_id,)) as cursor: @@ -25,7 +33,7 @@ async def get_script_notes(script_id: int, db: aiosqlite.Connection = Depends(ge rows = await cursor.fetchall() return [dict(row) for row in rows] -@router.post("/script/{script_id}", response_model=NoteResponse) +@router.post("/script/{script_id}", response_model=NoteResponse, status_code=201, dependencies=[create_access]) async def create_script_note( script_id: int, note: NoteCreate, @@ -52,7 +60,8 @@ async def create_script_note( ) await db.commit() - + await update_script_notes_fts(db, script_id) + async with db.execute( "SELECT * FROM script_notes WHERE id = ?", (note_id,) @@ -60,7 +69,7 @@ async def create_script_note( row = await cursor.fetchone() return dict(row) -@router.put("/{note_id}", response_model=NoteResponse) +@router.put("/{note_id}", response_model=NoteResponse, dependencies=[update_access]) async def update_note( note_id: int, note: NoteCreate, @@ -92,7 +101,8 @@ async def update_note( ) await db.commit() - + await update_script_notes_fts(db, script_id) + async with db.execute( "SELECT * FROM script_notes WHERE id = ?", (note_id,) @@ -100,7 +110,7 @@ async def update_note( row = await cursor.fetchone() return dict(row) -@router.delete("/{note_id}") +@router.delete("/{note_id}", dependencies=[delete_access]) async def delete_note(note_id: int, db: aiosqlite.Connection = Depends(get_db)): """Delete a note""" async with db.execute( @@ -113,6 +123,9 @@ async def delete_note(note_id: int, db: aiosqlite.Connection = Depends(get_db)): script_id = note_row[0] old_content = note_row[1] + from app.routes.attachments import purge_attachment_files + + await purge_attachment_files(db, note_ids=[note_id]) await db.execute("DELETE FROM script_notes WHERE id = ?", (note_id,)) # Log the change @@ -125,9 +138,10 @@ async def delete_note(note_id: int, db: aiosqlite.Connection = Depends(get_db)): ) await db.commit() + await update_script_notes_fts(db, script_id) return {"message": "Note deleted successfully"} -@router.get("/{note_id}/render") +@router.get("/{note_id}/render", dependencies=[read_access]) async def render_note(note_id: int, db: aiosqlite.Connection = Depends(get_db)): """Render a markdown note to HTML""" async with db.execute( @@ -141,9 +155,10 @@ async def render_note(note_id: int, db: aiosqlite.Connection = Depends(get_db)): content, is_markdown = note_row if not is_markdown: - # Return plain text wrapped in
 tag
+        # Escape before wrapping: the markdown path is sanitised by bleach, so
+        # leaving the plain-text path raw made it the easier XSS route of the two.
         return {
-            "html": f"
{content}
", + "html": f"
{html.escape(content)}
", "is_markdown": False, "preview": content[:200] } @@ -158,12 +173,12 @@ async def render_note(note_id: int, db: aiosqlite.Connection = Depends(get_db)): "preview": preview } -@router.post("/preview") +@router.post("/preview", dependencies=[read_access]) async def preview_markdown(note: NoteCreate): """Preview markdown rendering without saving""" if not note.is_markdown: return { - "html": f"
{note.content}
", + "html": f"
{html.escape(note.content)}
", "is_markdown": False, "preview": note.content[:200] } diff --git a/backend/app/routes/notifications.py b/backend/app/routes/notifications.py index 5b3d273..a9f3049 100644 --- a/backend/app/routes/notifications.py +++ b/backend/app/routes/notifications.py @@ -2,58 +2,85 @@ Notification Channels & Incidents API endpoints Notification channels support: slack, discord, email, webhook, pagerduty, sms. -Incidents can be listed, acknowledged, and resolved. +Secrets inside a channel's config are never returned by the API; clients see +"***" and may send it back unchanged to keep the stored value. + +Incidents can be listed, acknowledged, resolved and deleted. """ import json from datetime import datetime, timezone from typing import List, Optional import aiosqlite -from fastapi import APIRouter, Depends, HTTPException +from fastapi import APIRouter, Depends, HTTPException, Query from app.db.database import get_db from app.models.schemas import ( NotificationChannelCreate, NotificationChannelResponse, NotificationChannelUpdate, IncidentResponse, IncidentUpdate, ) +from app.routes.deps import actor_name, get_optional_user, require_permission +from app.services import notifier router = APIRouter() -VALID_CHANNEL_TYPES = {"slack", "discord", "email", "webhook", "pagerduty", "sms"} - -# Config keys whose values are redacted in API responses to prevent secret leakage -REDACTED_CONFIG_KEYS = {"webhook_url", "url", "auth_token", "account_sid", "routing_key", - "smtp_pass", "password", "token", "api_key", "secret"} - +VALID_CHANNEL_TYPES = notifier.VALID_CHANNEL_TYPES +VALID_INCIDENT_STATUSES = ("open", "acknowledged", "resolved") +VALID_SEVERITIES = ("info", "warning", "critical") -def _get_auth_dep(): - from app.routes.auth import get_current_user - return get_current_user +read_access = Depends(require_permission("notifications.read")) +write_access = Depends(require_permission("notifications.update")) +incident_read = Depends(require_permission("incidents.read")) +incident_write = Depends(require_permission("incidents.update")) -def _redact_config(config: dict) -> dict: - """Return a copy of config with secret values replaced by '***'.""" - return { - k: "***" if k.lower() in REDACTED_CONFIG_KEYS else v - for k, v in config.items() - } - - -def _channel_from_row(row) -> dict: +def _channel_from_row(row, redact: bool = True) -> dict: + """Convert a channel row to a dict, redacting secret config values by default.""" d = dict(row) - if isinstance(d.get("config"), str): + config = d.get("config") + if isinstance(config, str): try: - d["config"] = json.loads(d["config"]) + config = json.loads(config) except (json.JSONDecodeError, TypeError): - d["config"] = {} + config = {} + if not isinstance(config, dict): + config = {} + d["config"] = notifier.redact_config(config) if redact else config + d["enabled"] = bool(d.get("enabled")) return d +def _validate_type_and_config(channel_type: str, config: dict): + if channel_type not in VALID_CHANNEL_TYPES: + raise HTTPException( + status_code=400, + detail=f"Invalid channel type. Allowed: {', '.join(sorted(VALID_CHANNEL_TYPES))}", + ) + problems = notifier.validate_channel_config(channel_type, config) + if problems: + raise HTTPException(status_code=400, detail="; ".join(problems)) + + +async def _load_raw_config(db: aiosqlite.Connection, channel_id: int) -> dict: + async with db.execute( + "SELECT config FROM notification_channels WHERE id = ?", (channel_id,) + ) as cur: + row = await cur.fetchone() + if not row: + return {} + try: + value = json.loads(row[0]) + except (json.JSONDecodeError, TypeError): + return {} + return value if isinstance(value, dict) else {} + + # ── Notification Channels CRUD ──────────────────────────────────────────────── -@router.get("/channels/", response_model=List[NotificationChannelResponse]) +@router.get("/channels/", response_model=List[NotificationChannelResponse], + dependencies=[read_access]) async def list_channels(db: aiosqlite.Connection = Depends(get_db)): - """List all notification channels.""" + """List all notification channels (secrets redacted).""" async with db.execute( "SELECT * FROM notification_channels ORDER BY name" ) as cur: @@ -61,16 +88,92 @@ async def list_channels(db: aiosqlite.Connection = Depends(get_db)): return [_channel_from_row(r) for r in rows] -@router.post("/channels/", response_model=NotificationChannelResponse, status_code=201) +@router.get("/channels/types", dependencies=[read_access]) +async def list_channel_types(): + """ + Describe the supported channel types and the config keys each expects, + so the UI can render a real form instead of a raw JSON textarea. + """ + return { + "types": [ + { + "type": "slack", + "label": "Slack", + "fields": [ + {"key": "webhook_url", "label": "Incoming webhook URL", + "required": True, "secret": True, + "placeholder": "https://hooks.slack.com/services/..."}, + ], + }, + { + "type": "discord", + "label": "Discord", + "fields": [ + {"key": "webhook_url", "label": "Webhook URL", + "required": True, "secret": True, + "placeholder": "https://discord.com/api/webhooks/..."}, + ], + }, + { + "type": "webhook", + "label": "Generic webhook", + "fields": [ + {"key": "url", "label": "Endpoint URL", "required": True, "secret": True, + "placeholder": "https://example.com/hooks/alerts"}, + {"key": "method", "label": "HTTP method", "required": False, + "placeholder": "POST"}, + ], + }, + { + "type": "pagerduty", + "label": "PagerDuty", + "fields": [ + {"key": "routing_key", "label": "Events API routing key", + "required": True, "secret": True}, + ], + }, + { + "type": "email", + "label": "Email (SMTP)", + "fields": [ + {"key": "smtp_host", "label": "SMTP host", "required": True, + "placeholder": "smtp.example.com"}, + {"key": "smtp_port", "label": "SMTP port", "required": False, + "placeholder": "587"}, + {"key": "to", "label": "Recipient", "required": True, + "placeholder": "ops@example.com"}, + {"key": "from", "label": "Sender", "required": False, + "placeholder": "alerts@example.com"}, + {"key": "smtp_user", "label": "SMTP username", "required": False}, + {"key": "smtp_pass", "label": "SMTP password", + "required": False, "secret": True}, + ], + }, + { + "type": "sms", + "label": "SMS (Twilio)", + "fields": [ + {"key": "account_sid", "label": "Account SID", + "required": True, "secret": True}, + {"key": "auth_token", "label": "Auth token", + "required": True, "secret": True}, + {"key": "from", "label": "From number", "required": True, + "placeholder": "+15550000000"}, + {"key": "to", "label": "To number", "required": True, + "placeholder": "+15551234567"}, + ], + }, + ] + } + + +@router.post("/channels/", response_model=NotificationChannelResponse, status_code=201, + dependencies=[Depends(require_permission("notifications.create"))]) async def create_channel( data: NotificationChannelCreate, db: aiosqlite.Connection = Depends(get_db) ): """Create a new notification channel.""" - if data.type not in VALID_CHANNEL_TYPES: - raise HTTPException( - status_code=400, - detail=f"Invalid channel type. Allowed: {', '.join(sorted(VALID_CHANNEL_TYPES))}", - ) + _validate_type_and_config(data.type, data.config) config_json = json.dumps(data.config) try: cur = await db.execute( @@ -92,9 +195,10 @@ async def create_channel( return _channel_from_row(row) -@router.get("/channels/{channel_id}", response_model=NotificationChannelResponse) +@router.get("/channels/{channel_id}", response_model=NotificationChannelResponse, + dependencies=[read_access]) async def get_channel(channel_id: int, db: aiosqlite.Connection = Depends(get_db)): - """Get a single notification channel.""" + """Get a single notification channel (secrets redacted).""" async with db.execute( "SELECT * FROM notification_channels WHERE id = ?", (channel_id,) ) as cur: @@ -104,24 +208,28 @@ async def get_channel(channel_id: int, db: aiosqlite.Connection = Depends(get_db return _channel_from_row(row) -@router.put("/channels/{channel_id}", response_model=NotificationChannelResponse) +@router.put("/channels/{channel_id}", response_model=NotificationChannelResponse, + dependencies=[write_access]) async def update_channel( channel_id: int, data: NotificationChannelUpdate, db: aiosqlite.Connection = Depends(get_db), ): - """Update a notification channel.""" + """Update a notification channel. Secrets left as '***' keep their stored value.""" async with db.execute( - "SELECT id FROM notification_channels WHERE id = ?", (channel_id,) + "SELECT * FROM notification_channels WHERE id = ?", (channel_id,) ) as cur: - if not await cur.fetchone(): - raise HTTPException(status_code=404, detail="Channel not found") + existing = await cur.fetchone() + if not existing: + raise HTTPException(status_code=404, detail="Channel not found") - if data.type is not None and data.type not in VALID_CHANNEL_TYPES: - raise HTTPException( - status_code=400, - detail=f"Invalid channel type. Allowed: {', '.join(sorted(VALID_CHANNEL_TYPES))}", - ) + channel_type = data.type if data.type is not None else existing["type"] + stored_config = await _load_raw_config(db, channel_id) + new_config = ( + notifier.merge_config(stored_config, data.config) + if data.config is not None else stored_config + ) + _validate_type_and_config(channel_type, new_config) fields, params = [], [] if data.name is not None: @@ -129,17 +237,20 @@ async def update_channel( if data.type is not None: fields.append("type = ?"); params.append(data.type) if data.config is not None: - fields.append("config = ?"); params.append(json.dumps(data.config)) + fields.append("config = ?"); params.append(json.dumps(new_config)) if data.enabled is not None: fields.append("enabled = ?"); params.append(int(data.enabled)) if fields: fields.append("updated_at = CURRENT_TIMESTAMP") params.append(channel_id) - await db.execute( - f"UPDATE notification_channels SET {', '.join(fields)} WHERE id = ?", params - ) - await db.commit() + try: + await db.execute( + f"UPDATE notification_channels SET {', '.join(fields)} WHERE id = ?", params + ) + await db.commit() + except aiosqlite.IntegrityError: + raise HTTPException(status_code=400, detail="Channel name already exists") async with db.execute( "SELECT * FROM notification_channels WHERE id = ?", (channel_id,) @@ -148,7 +259,8 @@ async def update_channel( return _channel_from_row(row) -@router.delete("/channels/{channel_id}", status_code=204) +@router.delete("/channels/{channel_id}", status_code=204, + dependencies=[Depends(require_permission("notifications.delete"))]) async def delete_channel( channel_id: int, db: aiosqlite.Connection = Depends(get_db) ): @@ -164,16 +276,17 @@ async def delete_channel( await db.commit() -@router.post("/channels/{channel_id}/test") +@router.post("/channels/{channel_id}/test", dependencies=[write_access]) async def test_channel( channel_id: int, db: aiosqlite.Connection = Depends(get_db), - current_user: dict = Depends(_get_auth_dep()), + current_user: Optional[dict] = Depends(get_optional_user), ): """ - Send a test notification through the channel (admin only). - Returns a sanitised view of the channel — secret config values are redacted. - Actual delivery requires installing optional integrations. + Send a real test notification through the channel and report the outcome. + + Delivery failures come back as ``success: false`` with the provider's error + rather than as an HTTP error, so the UI can show exactly what went wrong. """ async with db.execute( "SELECT * FROM notification_channels WHERE id = ?", (channel_id,) @@ -181,34 +294,90 @@ async def test_channel( row = await cur.fetchone() if not row: raise HTTPException(status_code=404, detail="Channel not found") - ch = _channel_from_row(row) - safe_ch = {**ch, "config": _redact_config(ch.get("config", {}))} + + channel = _channel_from_row(row, redact=False) + who = actor_name(current_user) or "an operator" + result = await notifier.send_to_channel( + channel, + notifier.Alert( + title="Script Manager test notification", + body=f"This is a test message sent by {who}. " + f"If you can read it, the '{channel['name']}' channel is working.", + severity="info", + source="script-manager", + ), + ) + return { - "message": f"Test notification queued for channel '{ch['name']}' (type={ch['type']})", - "channel": safe_ch, + "success": result.success, + "message": result.detail, + "channel": { + "id": channel["id"], + "name": channel["name"], + "type": channel["type"], + "enabled": channel["enabled"], + "config": notifier.redact_config(channel["config"]), + }, } # ── Incidents ───────────────────────────────────────────────────────────────── -@router.get("/incidents/", response_model=List[IncidentResponse]) +@router.get("/incidents/", response_model=List[IncidentResponse], + dependencies=[incident_read]) async def list_incidents( status: Optional[str] = None, + source_type: Optional[str] = None, + limit: int = Query(200, ge=1, le=1000), db: aiosqlite.Connection = Depends(get_db), ): - """List incidents, optionally filtered by status (open/acknowledged/resolved).""" + """List incidents, optionally filtered by status and source type.""" + if status and status not in VALID_INCIDENT_STATUSES: + raise HTTPException( + status_code=400, + detail=f"Status must be one of: {', '.join(VALID_INCIDENT_STATUSES)}", + ) + + conditions, params = [], [] if status: - query = "SELECT * FROM incidents WHERE status = ? ORDER BY created_at DESC" - params = (status,) - else: - query = "SELECT * FROM incidents ORDER BY created_at DESC" - params = () - async with db.execute(query, params) as cur: + conditions.append("status = ?") + params.append(status) + if source_type: + conditions.append("source_type = ?") + params.append(source_type) + + where = f"WHERE {' AND '.join(conditions)}" if conditions else "" + params.append(limit) + async with db.execute( + f"SELECT * FROM incidents {where} ORDER BY created_at DESC, id DESC LIMIT ?", + tuple(params), + ) as cur: rows = await cur.fetchall() return [dict(r) for r in rows] -@router.get("/incidents/{incident_id}", response_model=IncidentResponse) +@router.get("/incidents/stats", dependencies=[incident_read]) +async def incident_stats(db: aiosqlite.Connection = Depends(get_db)): + """Counts by status and severity, for the dashboard.""" + async with db.execute( + "SELECT status, COUNT(*) FROM incidents GROUP BY status" + ) as cur: + by_status = {row[0]: row[1] for row in await cur.fetchall()} + async with db.execute( + "SELECT severity, COUNT(*) FROM incidents WHERE status != 'resolved' GROUP BY severity" + ) as cur: + unresolved_by_severity = {row[0]: row[1] for row in await cur.fetchall()} + return { + "by_status": by_status, + "unresolved_by_severity": unresolved_by_severity, + "open": by_status.get("open", 0), + "acknowledged": by_status.get("acknowledged", 0), + "resolved": by_status.get("resolved", 0), + } + + +@router.get("/incidents/{incident_id}", response_model=IncidentResponse, + dependencies=[incident_read]) async def get_incident(incident_id: int, db: aiosqlite.Connection = Depends(get_db)): """Get a single incident.""" async with db.execute( @@ -220,11 +389,13 @@ async def get_incident(incident_id: int, db: aiosqlite.Connection = Depends(get_ return dict(row) -@router.put("/incidents/{incident_id}", response_model=IncidentResponse) +@router.put("/incidents/{incident_id}", response_model=IncidentResponse, + dependencies=[incident_write]) async def update_incident( incident_id: int, data: IncidentUpdate, db: aiosqlite.Connection = Depends(get_db), + current_user: Optional[dict] = Depends(get_optional_user), ): """ Update an incident (acknowledge or resolve it). @@ -241,18 +412,27 @@ async def update_incident( now_iso = datetime.now(timezone.utc).isoformat() if data.status is not None: - if data.status not in ("open", "acknowledged", "resolved"): + if data.status not in VALID_INCIDENT_STATUSES: raise HTTPException( status_code=400, - detail="Status must be one of: open, acknowledged, resolved", + detail=f"Status must be one of: {', '.join(VALID_INCIDENT_STATUSES)}", ) fields.append("status = ?"); params.append(data.status) if data.status == "acknowledged": fields.append("acknowledged_at = ?"); params.append(now_iso) + # Record who acknowledged it unless the caller named someone else. + if data.acknowledged_by is None and current_user: + fields.append("acknowledged_by = ?") + params.append(current_user["username"]) elif data.status == "resolved": fields.append("resolved_at = ?"); params.append(now_iso) if data.severity is not None: + if data.severity not in VALID_SEVERITIES: + raise HTTPException( + status_code=400, + detail=f"Severity must be one of: {', '.join(VALID_SEVERITIES)}", + ) fields.append("severity = ?"); params.append(data.severity) if data.description is not None: fields.append("description = ?"); params.append(data.description) @@ -274,7 +454,7 @@ async def update_incident( return dict(row) -@router.delete("/incidents/{incident_id}", status_code=204) +@router.delete("/incidents/{incident_id}", status_code=204, dependencies=[incident_write]) async def delete_incident( incident_id: int, db: aiosqlite.Connection = Depends(get_db) ): diff --git a/backend/app/routes/saved_searches.py b/backend/app/routes/saved_searches.py index f5826e1..ce9f77d 100644 --- a/backend/app/routes/saved_searches.py +++ b/backend/app/routes/saved_searches.py @@ -8,10 +8,16 @@ from app.db.database import get_db from app.models.schemas import SavedSearchCreate, SavedSearchResponse +from app.routes.deps import require_permission router = APIRouter() -@router.get("/", response_model=List[SavedSearchResponse]) +read_access = Depends(require_permission("search.read")) +create_access = Depends(require_permission("search.create")) +update_access = Depends(require_permission("search.update")) +delete_access = Depends(require_permission("search.delete")) + +@router.get("/", response_model=List[SavedSearchResponse], dependencies=[read_access]) async def list_saved_searches(db: aiosqlite.Connection = Depends(get_db)): """List all saved searches""" async with db.execute( @@ -26,7 +32,7 @@ async def list_saved_searches(db: aiosqlite.Connection = Depends(get_db)): results.append(item) return results -@router.post("/", response_model=SavedSearchResponse) +@router.post("/", response_model=SavedSearchResponse, status_code=201, dependencies=[create_access]) async def create_saved_search( search: SavedSearchCreate, db: aiosqlite.Connection = Depends(get_db) @@ -56,7 +62,7 @@ async def create_saved_search( except aiosqlite.IntegrityError: raise HTTPException(status_code=400, detail="Saved search with this name already exists") -@router.get("/{search_id}", response_model=SavedSearchResponse) +@router.get("/{search_id}", response_model=SavedSearchResponse, dependencies=[read_access]) async def get_saved_search(search_id: int, db: aiosqlite.Connection = Depends(get_db)): """Get a specific saved search""" async with db.execute( @@ -70,7 +76,7 @@ async def get_saved_search(search_id: int, db: aiosqlite.Connection = Depends(ge result['query_params'] = json.loads(result['query_params']) return result -@router.put("/{search_id}", response_model=SavedSearchResponse) +@router.put("/{search_id}", response_model=SavedSearchResponse, dependencies=[update_access]) async def update_saved_search( search_id: int, search: SavedSearchCreate, @@ -106,7 +112,7 @@ async def update_saved_search( result['query_params'] = json.loads(result['query_params']) return result -@router.delete("/{search_id}") +@router.delete("/{search_id}", dependencies=[delete_access]) async def delete_saved_search(search_id: int, db: aiosqlite.Connection = Depends(get_db)): """Delete a saved search""" async with db.execute( diff --git a/backend/app/routes/schedules.py b/backend/app/routes/schedules.py index d038f47..38439c3 100644 --- a/backend/app/routes/schedules.py +++ b/backend/app/routes/schedules.py @@ -2,8 +2,8 @@ Schedule Jobs API endpoints Provides CRUD for cron-scheduled tasks with: - - Timezone-aware cron expressions (stored; auto-execution requires an external - scheduler to call POST /{id}/trigger — no internal scheduler loop is included) + - Timezone-aware cron expressions, validated on write and executed by the + in-process scheduler (app.services.scheduler) - Overlap prevention (locking) - Auto-retry on failure - Full execution log capture (stdout/stderr) @@ -11,35 +11,31 @@ - Zombie/anomaly duration detection """ import json -import subprocess -import asyncio from datetime import datetime, timezone from typing import List, Optional import aiosqlite -from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks, Query +from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query from app.db.database import get_db, DB_PATH from app.models.schemas import ( ScheduleJobCreate, ScheduleJobResponse, ScheduleJobUpdate, JobExecutionResponse, ) +from app.routes.deps import require_permission +from app.services.cron import CronError, describe, validate_cron, validate_timezone +from app.services import scheduler router = APIRouter() -# Import auth dependency lazily to avoid circular imports -def _get_auth_dep(): - from app.routes.auth import get_current_user - return get_current_user +read_access = Depends(require_permission("schedules.read")) +write_access = Depends(require_permission("schedules.update")) +run_access = Depends(require_permission("schedules.run")) +delete_access = Depends(require_permission("schedules.delete")) def _parse_channel_ids(raw: Optional[str]) -> List[int]: - if not raw: - return [] - try: - return json.loads(raw) - except (json.JSONDecodeError, TypeError): - return [] + return scheduler.parse_channel_ids(raw) def _job_from_row(row) -> dict: @@ -48,9 +44,22 @@ def _job_from_row(row) -> dict: return d +def _validate_schedule(cron_expression: str, tz_name: str) -> str: + """Validate a cron expression + timezone pair, returning the timezone name.""" + try: + validate_cron(cron_expression) + return validate_timezone(tz_name) + except CronError as exc: + raise HTTPException(status_code=400, detail=str(exc)) + + +async def _next_run_for(cron_expression: str, tz_name: str) -> Optional[str]: + return await scheduler.compute_next_run(cron_expression, tz_name) + + # ── CRUD ───────────────────────────────────────────────────────────────────── -@router.get("/", response_model=List[ScheduleJobResponse]) +@router.get("/", response_model=List[ScheduleJobResponse], dependencies=[read_access]) async def list_jobs(db: aiosqlite.Connection = Depends(get_db)): """List all scheduled jobs.""" async with db.execute("SELECT * FROM schedule_jobs ORDER BY name") as cur: @@ -58,13 +67,22 @@ async def list_jobs(db: aiosqlite.Connection = Depends(get_db)): return [_job_from_row(r) for r in rows] -@router.post("/", response_model=ScheduleJobResponse, status_code=201) +@router.post("/", response_model=ScheduleJobResponse, status_code=201, + dependencies=[Depends(require_permission("schedules.create"))]) async def create_job(data: ScheduleJobCreate, db: aiosqlite.Connection = Depends(get_db)): """Create a new scheduled job.""" if not data.script_id and not data.command: raise HTTPException( status_code=400, detail="Either script_id or command must be provided" ) + if data.script_id is not None: + # Checked up front so a bad script id does not surface as the + # IntegrityError handler's "Job name already exists". + async with db.execute("SELECT id FROM scripts WHERE id = ?", (data.script_id,)) as cur: + if not await cur.fetchone(): + raise HTTPException(status_code=404, detail="Script not found") + tz_name = _validate_schedule(data.cron_expression, data.timezone) + next_run = await _next_run_for(data.cron_expression, tz_name) channel_ids_json = json.dumps(data.notify_channel_ids) try: cur = await db.execute( @@ -72,15 +90,15 @@ async def create_job(data: ScheduleJobCreate, db: aiosqlite.Connection = Depends INSERT INTO schedule_jobs (name, description, script_id, command, cron_expression, timezone, enabled, max_retries, retry_delay_seconds, prevent_overlap, - timeout_seconds, notify_channel_ids) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + timeout_seconds, notify_channel_ids, next_run_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, ( data.name, data.description, data.script_id, data.command, - data.cron_expression, data.timezone, int(data.enabled), + data.cron_expression, tz_name, int(data.enabled), data.max_retries, data.retry_delay_seconds, int(data.prevent_overlap), data.timeout_seconds, - channel_ids_json, + channel_ids_json, next_run if data.enabled else None, ), ) job_id = cur.lastrowid @@ -93,7 +111,7 @@ async def create_job(data: ScheduleJobCreate, db: aiosqlite.Connection = Depends return _job_from_row(row) -@router.get("/{job_id}", response_model=ScheduleJobResponse) +@router.get("/{job_id}", response_model=ScheduleJobResponse, dependencies=[read_access]) async def get_job(job_id: int, db: aiosqlite.Connection = Depends(get_db)): """Get a single scheduled job.""" async with db.execute("SELECT * FROM schedule_jobs WHERE id = ?", (job_id,)) as cur: @@ -103,14 +121,26 @@ async def get_job(job_id: int, db: aiosqlite.Connection = Depends(get_db)): return _job_from_row(row) -@router.put("/{job_id}", response_model=ScheduleJobResponse) +@router.put("/{job_id}", response_model=ScheduleJobResponse, dependencies=[write_access]) async def update_job( job_id: int, data: ScheduleJobUpdate, db: aiosqlite.Connection = Depends(get_db) ): """Update a scheduled job's configuration.""" - async with db.execute("SELECT id FROM schedule_jobs WHERE id = ?", (job_id,)) as cur: - if not await cur.fetchone(): - raise HTTPException(status_code=404, detail="Job not found") + async with db.execute("SELECT * FROM schedule_jobs WHERE id = ?", (job_id,)) as cur: + existing = await cur.fetchone() + if not existing: + raise HTTPException(status_code=404, detail="Job not found") + + # The schedule must stay valid as a pair, so validate the resulting values + # rather than only the fields that were supplied. + cron_expression = data.cron_expression if data.cron_expression is not None else existing["cron_expression"] + tz_name = data.timezone if data.timezone is not None else existing["timezone"] + tz_name = _validate_schedule(cron_expression, tz_name) + + if data.script_id is not None: + async with db.execute("SELECT id FROM scripts WHERE id = ?", (data.script_id,)) as cur: + if not await cur.fetchone(): + raise HTTPException(status_code=404, detail="Script not found") fields, params = [], [] if data.name is not None: @@ -124,7 +154,7 @@ async def update_job( if data.cron_expression is not None: fields.append("cron_expression = ?"); params.append(data.cron_expression) if data.timezone is not None: - fields.append("timezone = ?"); params.append(data.timezone) + fields.append("timezone = ?"); params.append(tz_name) if data.enabled is not None: fields.append("enabled = ?"); params.append(int(data.enabled)) if data.max_retries is not None: @@ -138,53 +168,104 @@ async def update_job( if data.notify_channel_ids is not None: fields.append("notify_channel_ids = ?"); params.append(json.dumps(data.notify_channel_ids)) - if fields: - fields.append("updated_at = CURRENT_TIMESTAMP") - params.append(job_id) + enabled = data.enabled if data.enabled is not None else bool(existing["enabled"]) + fields.append("next_run_at = ?") + params.append(await _next_run_for(cron_expression, tz_name) if enabled else None) + + fields.append("updated_at = CURRENT_TIMESTAMP") + params.append(job_id) + try: await db.execute( f"UPDATE schedule_jobs SET {', '.join(fields)} WHERE id = ?", params ) await db.commit() + except aiosqlite.IntegrityError: + raise HTTPException(status_code=400, detail="Job name already exists") async with db.execute("SELECT * FROM schedule_jobs WHERE id = ?", (job_id,)) as cur: row = await cur.fetchone() return _job_from_row(row) -@router.delete("/{job_id}", status_code=204) +@router.delete("/{job_id}", status_code=204, dependencies=[delete_access]) async def delete_job(job_id: int, db: aiosqlite.Connection = Depends(get_db)): """Delete a scheduled job and all its execution history.""" async with db.execute("SELECT id FROM schedule_jobs WHERE id = ?", (job_id,)) as cur: if not await cur.fetchone(): raise HTTPException(status_code=404, detail="Job not found") await db.execute("DELETE FROM schedule_jobs WHERE id = ?", (job_id,)) + await db.execute( + "DELETE FROM incidents WHERE source_type = 'schedule' AND source_id = ?", (job_id,) + ) await db.commit() +# ── Schedule preview ────────────────────────────────────────────────────────── + +@router.get("/preview/cron", dependencies=[read_access]) +async def preview_cron( + expression: str = Query(..., description="5-field cron expression"), + timezone_name: str = Query("UTC", alias="timezone"), + count: int = Query(5, ge=1, le=20), +): + """ + Validate a cron expression and return the next few run times. + + Lets the UI show a user what their schedule actually means before saving. + """ + try: + validate_cron(expression) + tz_name = validate_timezone(timezone_name) + except CronError as exc: + raise HTTPException(status_code=400, detail=str(exc)) + + runs = [] + cursor_time = datetime.now(timezone.utc) + for _ in range(count): + from app.services.cron import next_run_utc + nxt = next_run_utc(expression, tz_name, cursor_time) + if nxt is None: + break + runs.append(nxt.isoformat()) + cursor_time = nxt + + return { + "expression": expression, + "timezone": tz_name, + "description": describe(expression), + "next_runs": runs, + } + + # ── Enable / Disable ────────────────────────────────────────────────────────── -@router.post("/{job_id}/enable") +@router.post("/{job_id}/enable", dependencies=[write_access]) async def enable_job(job_id: int, db: aiosqlite.Connection = Depends(get_db)): """Enable a paused/disabled job.""" - async with db.execute("SELECT id FROM schedule_jobs WHERE id = ?", (job_id,)) as cur: - if not await cur.fetchone(): - raise HTTPException(status_code=404, detail="Job not found") + async with db.execute("SELECT * FROM schedule_jobs WHERE id = ?", (job_id,)) as cur: + row = await cur.fetchone() + if not row: + raise HTTPException(status_code=404, detail="Job not found") + + next_run = await _next_run_for(row["cron_expression"], row["timezone"]) await db.execute( - "UPDATE schedule_jobs SET enabled = 1, updated_at = CURRENT_TIMESTAMP WHERE id = ?", - (job_id,), + "UPDATE schedule_jobs SET enabled = 1, next_run_at = ?, updated_at = CURRENT_TIMESTAMP " + "WHERE id = ?", + (next_run, job_id), ) await db.commit() - return {"message": "Job enabled"} + return {"message": "Job enabled", "next_run_at": next_run} -@router.post("/{job_id}/disable") +@router.post("/{job_id}/disable", dependencies=[write_access]) async def disable_job(job_id: int, db: aiosqlite.Connection = Depends(get_db)): """Disable (pause) a job without deleting it.""" async with db.execute("SELECT id FROM schedule_jobs WHERE id = ?", (job_id,)) as cur: if not await cur.fetchone(): raise HTTPException(status_code=404, detail="Job not found") await db.execute( - "UPDATE schedule_jobs SET enabled = 0, updated_at = CURRENT_TIMESTAMP WHERE id = ?", + "UPDATE schedule_jobs SET enabled = 0, next_run_at = NULL, " + "updated_at = CURRENT_TIMESTAMP WHERE id = ?", (job_id,), ) await db.commit() @@ -193,17 +274,16 @@ async def disable_job(job_id: int, db: aiosqlite.Connection = Depends(get_db)): # ── Manual trigger ──────────────────────────────────────────────────────────── -@router.post("/{job_id}/trigger") +@router.post("/{job_id}/trigger", dependencies=[run_access]) async def trigger_job( job_id: int, background_tasks: BackgroundTasks, db: aiosqlite.Connection = Depends(get_db), - current_user: dict = Depends(_get_auth_dep()), ): """ Manually trigger a job immediately (outside its cron schedule). - Requires authentication. The execution runs in the background; - the endpoint returns the new execution ID. + The execution runs in the background; the endpoint returns the new + execution ID so the caller can poll for logs. """ async with db.execute("SELECT * FROM schedule_jobs WHERE id = ?", (job_id,)) as cur: row = await cur.fetchone() @@ -240,32 +320,45 @@ async def trigger_job( detail="Job is already running. Overlap prevention is enabled.", ) - # Create execution record - cur2 = await db.execute( - """ - INSERT INTO job_executions (job_id, started_at, status, triggered_by) - VALUES (?, CURRENT_TIMESTAMP, 'running', 'manual') - """, - (job_id,), - ) - execution_id = cur2.lastrowid - await db.commit() + try: + cur2 = await db.execute( + """ + INSERT INTO job_executions + (job_id, started_at, status, triggered_by, overlap_key) + VALUES (?, ?, 'running', 'manual', ?) + """, + ( + job_id, datetime.now(timezone.utc).isoformat(), + job_id if job["prevent_overlap"] else None, + ), + ) + execution_id = cur2.lastrowid + await db.commit() + except aiosqlite.IntegrityError: + # The partial unique index on running executions rejected this insert, + # which means another trigger won the race a moment ago. + raise HTTPException( + status_code=409, + detail="Job is already running. Overlap prevention is enabled.", + ) background_tasks.add_task( - _run_job_execution, + scheduler.run_job_execution, execution_id=execution_id, job_id=job_id, command=command, timeout=job.get("timeout_seconds"), max_retries=job.get("max_retries", 0), retry_delay=job.get("retry_delay_seconds", 60), + db_path=DB_PATH, ) return {"message": "Job triggered", "execution_id": execution_id} # ── Executions ──────────────────────────────────────────────────────────────── -@router.get("/{job_id}/executions", response_model=List[JobExecutionResponse]) +@router.get("/{job_id}/executions", response_model=List[JobExecutionResponse], + dependencies=[read_access]) async def list_executions( job_id: int, limit: int = Query(50, ge=1, le=500), @@ -279,7 +372,7 @@ async def list_executions( """ SELECT * FROM job_executions WHERE job_id = ? - ORDER BY started_at DESC + ORDER BY started_at DESC, id DESC LIMIT ? """, (job_id, limit), @@ -288,7 +381,8 @@ async def list_executions( return [dict(r) for r in rows] -@router.get("/{job_id}/executions/{execution_id}", response_model=JobExecutionResponse) +@router.get("/{job_id}/executions/{execution_id}", response_model=JobExecutionResponse, + dependencies=[read_access]) async def get_execution( job_id: int, execution_id: int, @@ -305,7 +399,7 @@ async def get_execution( return dict(row) -@router.get("/{job_id}/metrics") +@router.get("/{job_id}/metrics", dependencies=[read_access]) async def get_job_metrics( job_id: int, days: int = Query(30, ge=1, le=365), @@ -322,7 +416,7 @@ async def get_job_metrics( DATE(started_at) AS run_date, COUNT(*) AS total_runs, SUM(CASE WHEN status = 'success' THEN 1 ELSE 0 END) AS successful, - SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END) AS failed, + SUM(CASE WHEN status IN ('failed', 'timeout') THEN 1 ELSE 0 END) AS failed, AVG(duration_seconds) AS avg_duration, MAX(duration_seconds) AS max_duration, MIN(duration_seconds) AS min_duration @@ -337,194 +431,28 @@ async def get_job_metrics( ) as cur: rows = await cur.fetchall() + async with db.execute( + """ + SELECT + COUNT(*) AS total_runs, + SUM(CASE WHEN status = 'success' THEN 1 ELSE 0 END) AS successful, + AVG(duration_seconds) AS avg_duration + FROM job_executions + WHERE job_id = ? AND status != 'running' + AND started_at >= DATE('now', ? || ' days') + """, + (job_id, f"-{days}"), + ) as cur: + summary_row = await cur.fetchone() + + summary = dict(summary_row) if summary_row else {} + total = summary.get("total_runs") or 0 + successful = summary.get("successful") or 0 + summary["success_rate"] = round((successful / total) * 100, 1) if total else None + return { "job_id": job_id, "days": days, + "summary": summary, "data": [dict(r) for r in rows], } - - -# ── Background execution helper ─────────────────────────────────────────────── - -async def _run_job_execution( - execution_id: int, - job_id: int, - command: str, - timeout: Optional[int], - max_retries: int, - retry_delay: int, -): - """Run the command in a subprocess, capture output, record result.""" - attempt = 0 - while True: - started_at = datetime.now(timezone.utc) - stdout_data, stderr_data = "", "" - exit_code = None - status = "failed" - - try: - if not command: - raise ValueError("No command to execute") - - proc = await asyncio.create_subprocess_shell( - command, - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE, - ) - try: - raw_out, raw_err = await asyncio.wait_for( - proc.communicate(), timeout=float(timeout) if timeout else None - ) - except asyncio.TimeoutError: - proc.kill() - raw_out, raw_err = await proc.communicate() - stdout_data = raw_out.decode("utf-8", errors="replace") - stderr_data = raw_err.decode("utf-8", errors="replace") + "\nTIMEOUT: process killed after timeout\n" - exit_code = -1 - status = "timeout" - else: - stdout_data = raw_out.decode("utf-8", errors="replace") - stderr_data = raw_err.decode("utf-8", errors="replace") - exit_code = proc.returncode - status = "success" if exit_code == 0 else "failed" - except Exception as exc: - stderr_data += f"\nExecution error: {exc}" - exit_code = -1 - status = "failed" - - ended_at = datetime.now(timezone.utc) - duration = (ended_at - started_at).total_seconds() - - async with aiosqlite.connect(DB_PATH) as db: - db.row_factory = aiosqlite.Row - current_exec_id = execution_id # safe default; overridden for retries below - if attempt == 0: - # Update the pre-created execution record - await db.execute( - """ - UPDATE job_executions - SET ended_at = ?, status = ?, exit_code = ?, - stdout = ?, stderr = ?, duration_seconds = ?, - retry_attempt = ? - WHERE id = ? - """, - ( - ended_at.isoformat(), status, exit_code, - stdout_data, stderr_data, duration, - attempt, execution_id, - ), - ) - else: - # Insert a new record for the retry attempt - retry_cur = await db.execute( - """ - INSERT INTO job_executions - (job_id, started_at, ended_at, status, exit_code, - stdout, stderr, duration_seconds, retry_attempt, triggered_by) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'retry') - """, - ( - job_id, started_at.isoformat(), ended_at.isoformat(), - status, exit_code, stdout_data, stderr_data, duration, attempt, - ), - ) - current_exec_id = retry_cur.lastrowid - - # Update job's last_run_at and last_status - await db.execute( - """ - UPDATE schedule_jobs - SET last_run_at = CURRENT_TIMESTAMP, last_status = ?, - updated_at = CURRENT_TIMESTAMP - WHERE id = ? - """, - (status, job_id), - ) - await db.commit() - - # Zombie / anomaly detection — only on first attempt to avoid stale execution_id issues - if attempt == 0 and status not in ("running", "timeout") and duration is not None: - await _check_zombie_duration(job_id, duration, current_exec_id) - - if status == "success" or attempt >= max_retries: - break - - attempt += 1 - await asyncio.sleep(retry_delay) - - -# ── Zombie / duration anomaly detection ────────────────────────────────────── - -async def _check_zombie_duration(job_id: int, current_duration: float, execution_id: int): - """ - Compare current execution duration against historical average. - If the job ran >3× longer than avg or finished in <20% of avg (with ≥5 prior runs), - create a 'zombie' incident. - """ - MIN_SAMPLES = 5 - OVER_MULTIPLIER = 3.0 # flag if current > avg * 3 - UNDER_FRACTION = 0.20 # flag if current < avg * 0.20 - - async with aiosqlite.connect(DB_PATH) as db: - db.row_factory = aiosqlite.Row - async with db.execute( - """ - SELECT AVG(duration_seconds) AS avg_dur, COUNT(*) AS cnt, - name - FROM job_executions - JOIN schedule_jobs ON schedule_jobs.id = job_executions.job_id - WHERE job_executions.job_id = ? - AND job_executions.status IN ('success', 'failed') - AND job_executions.id != ? - AND job_executions.duration_seconds IS NOT NULL - """, - (job_id, execution_id), - ) as cur: - row = await cur.fetchone() - - if row is None or row["cnt"] < MIN_SAMPLES or row["avg_dur"] is None: - return # not enough history - - avg_dur = row["avg_dur"] - job_name = row["name"] - - if avg_dur <= 0: - return - - anomaly = None - ratio = current_duration / avg_dur - if ratio > OVER_MULTIPLIER: - anomaly = ( - f"Zombie process detected: job '{job_name}' ran for " - f"{current_duration:.1f}s (avg: {avg_dur:.1f}s, " - f"{ratio:.1f}× over average)" - ) - elif ratio < UNDER_FRACTION: - anomaly = ( - f"Suspiciously short run: job '{job_name}' finished in " - f"{current_duration:.1f}s (avg: {avg_dur:.1f}s, " - f"only {ratio*100:.0f}% of average)" - ) - - if anomaly: - # Deduplicate: only create if no open anomaly incident exists for this job - async with db.execute( - """ - SELECT id FROM incidents - WHERE source_type = 'schedule' AND source_id = ? - AND status = 'open' - AND title LIKE 'Abnormal duration for job%' - """, - (job_id,), - ) as cur: - existing = await cur.fetchone() - if not existing: - await db.execute( - """ - INSERT INTO incidents - (title, source_type, source_id, status, severity, description) - VALUES (?, 'schedule', ?, 'open', 'warning', ?) - """, - (f"Abnormal duration for job '{job_name}'", job_id, anomaly), - ) - await db.commit() diff --git a/backend/app/routes/scripts.py b/backend/app/routes/scripts.py index 367da0e..5ff6b4c 100644 --- a/backend/app/routes/scripts.py +++ b/backend/app/routes/scripts.py @@ -1,22 +1,45 @@ """ Scripts API endpoints """ -from fastapi import APIRouter, Depends, HTTPException, Query -from typing import List, Optional -from datetime import datetime +import asyncio import os +from datetime import datetime, timezone +from typing import List, Optional + import aiosqlite +from fastapi import APIRouter, Body, Depends, HTTPException, Query from app.db.database import get_db +from app.db.sql import TAGS_SUBQUERY, split_tags from app.models.schemas import ( ScriptResponse, StatusUpdate, PaginatedResponse, - BulkTagRequest, BulkStatusRequest + BulkTagRequest, BulkStatusRequest, ExportRequest ) +from app.routes.deps import actor_name, get_optional_user, require_permission router = APIRouter() -@router.get("/", response_model=PaginatedResponse) +def _like_pattern(term: str) -> str: + """ + Build a LIKE pattern that treats the user's text literally. + + Without escaping, a search for "%" matches every row and "_" matches any + character, so the filter silently did something other than what was typed. + Callers must pair this with ESCAPE '\\'. + """ + escaped = term.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") + return f"%{escaped}%" + + +# Script bodies can be large; cap what a single request returns. +MAX_CONTENT_BYTES = int(os.getenv("MAX_SCRIPT_CONTENT_BYTES", str(2 * 1024 * 1024))) + +read_access = Depends(require_permission("scripts.read")) +update_access = Depends(require_permission("scripts.update")) + + +@router.get("/", response_model=PaginatedResponse, dependencies=[read_access]) async def list_scripts( page: int = Query(1, ge=1), page_size: int = Query(50, ge=1, le=100), @@ -45,8 +68,8 @@ async def list_scripts( params.append(status) if search: - conditions.append("(s.name LIKE ? OR s.path LIKE ?)") - search_pattern = f"%{search}%" + conditions.append("(s.name LIKE ? ESCAPE '\\' OR s.path LIKE ? ESCAPE '\\')") + search_pattern = _like_pattern(search) params.extend([search_pattern, search_pattern]) allowed_sort_columns = { @@ -69,7 +92,7 @@ async def list_scripts( # Get total count count_query = f""" - SELECT COUNT(DISTINCT s.id) + SELECT COUNT(*) FROM scripts s LEFT JOIN script_status st ON s.id = st.script_id WHERE {where_clause} @@ -80,15 +103,12 @@ async def list_scripts( # Get paginated results offset = (page - 1) * page_size query = f""" - SELECT DISTINCT s.id, s.name, s.path, s.extension, s.language, + SELECT s.id, s.name, s.path, s.extension, s.language, s.size, s.mtime, st.status, - GROUP_CONCAT(DISTINCT t.name) as tags + {TAGS_SUBQUERY} AS tags FROM scripts s LEFT JOIN script_status st ON s.id = st.script_id - LEFT JOIN script_tags sct ON s.id = sct.script_id - LEFT JOIN tags t ON sct.tag_id = t.id WHERE {where_clause} - GROUP BY s.id ORDER BY {sort_column} {sort_direction}, s.id ASC LIMIT ? OFFSET ? """ @@ -99,7 +119,9 @@ async def list_scripts( items = [] for row in rows: item = dict(row) - item['tags'] = item['tags'].split(',') if item.get('tags') else [] + # GROUP_CONCAT's separator is a unit separator rather than a + # comma so a tag name containing a comma is not split in two. + item['tags'] = split_tags(item.get('tags')) items.append(item) total_pages = (total + page_size - 1) // page_size @@ -112,7 +134,7 @@ async def list_scripts( 'total_pages': total_pages } -@router.get("/{script_id}", response_model=ScriptResponse) +@router.get("/{script_id}", response_model=ScriptResponse, dependencies=[read_access]) async def get_script(script_id: int, db: aiosqlite.Connection = Depends(get_db)): """Get detailed script information""" async with db.execute("SELECT * FROM scripts WHERE id = ?", (script_id,)) as cursor: @@ -168,11 +190,12 @@ async def get_script(script_id: int, db: aiosqlite.Connection = Depends(get_db)) return script -@router.put("/{script_id}/status") +@router.put("/{script_id}/status", dependencies=[update_access]) async def update_script_status( script_id: int, status_update: StatusUpdate, - db: aiosqlite.Connection = Depends(get_db) + db: aiosqlite.Connection = Depends(get_db), + current_user: Optional[dict] = Depends(get_optional_user), ): """Update script status and classification""" # Check if script exists @@ -246,59 +269,66 @@ async def update_script_status( ) ) - # Log status changes + # Log status changes. The change_log.actor column existed but was never + # populated, so the audit trail could not say who made a change. + actor = actor_name(current_user) if status_update.status is not None: await db.execute( """ - INSERT INTO change_log (script_id, change_type, old_value, new_value) - VALUES (?, 'status_changed', ?, ?) + INSERT INTO change_log (script_id, change_type, old_value, new_value, actor) + VALUES (?, 'status_changed', ?, ?, ?) """, - (script_id, old_values.get('status', 'none'), status_update.status) + (script_id, old_values.get('status', 'none'), status_update.status, actor) ) if status_update.classification is not None: await db.execute( """ - INSERT INTO change_log (script_id, change_type, old_value, new_value) - VALUES (?, 'classification_changed', ?, ?) + INSERT INTO change_log (script_id, change_type, old_value, new_value, actor) + VALUES (?, 'classification_changed', ?, ?, ?) """, - (script_id, old_values.get('classification', 'none'), status_update.classification) + (script_id, old_values.get('classification', 'none'), status_update.classification, actor) ) if status_update.owner is not None: await db.execute( """ - INSERT INTO change_log (script_id, change_type, old_value, new_value) - VALUES (?, 'owner_changed', ?, ?) + INSERT INTO change_log (script_id, change_type, old_value, new_value, actor) + VALUES (?, 'owner_changed', ?, ?, ?) """, - (script_id, old_values.get('owner', 'none'), status_update.owner) + (script_id, old_values.get('owner', 'none'), status_update.owner, actor) ) if status_update.environment is not None: await db.execute( """ - INSERT INTO change_log (script_id, change_type, old_value, new_value) - VALUES (?, 'environment_changed', ?, ?) + INSERT INTO change_log (script_id, change_type, old_value, new_value, actor) + VALUES (?, 'environment_changed', ?, ?, ?) """, - (script_id, old_values.get('environment', 'none'), status_update.environment) + (script_id, old_values.get('environment', 'none'), status_update.environment, actor) ) await db.commit() return {"message": "Status updated successfully"} -@router.post("/{script_id}/tags/{tag_id}") +@router.post("/{script_id}/tags/{tag_id}", dependencies=[update_access]) async def add_tag_to_script( script_id: int, tag_id: int, db: aiosqlite.Connection = Depends(get_db) ): """Add a tag to a script""" + async with db.execute("SELECT id FROM scripts WHERE id = ?", (script_id,)) as cursor: + if not await cursor.fetchone(): + raise HTTPException(status_code=404, detail="Script not found") + + async with db.execute("SELECT name FROM tags WHERE id = ?", (tag_id,)) as cursor: + tag_row = await cursor.fetchone() + if not tag_row: + raise HTTPException(status_code=404, detail="Tag not found") + tag_name = tag_row[0] + try: - # Get tag name - async with db.execute("SELECT name FROM tags WHERE id = ?", (tag_id,)) as cursor: - tag_row = await cursor.fetchone() - tag_name = tag_row[0] if tag_row else str(tag_id) - await db.execute( "INSERT INTO script_tags (script_id, tag_id) VALUES (?, ?)", (script_id, tag_id) @@ -318,36 +348,39 @@ async def add_tag_to_script( except aiosqlite.IntegrityError: raise HTTPException(status_code=400, detail="Tag already added to script") -@router.delete("/{script_id}/tags/{tag_id}") +@router.delete("/{script_id}/tags/{tag_id}", dependencies=[update_access]) async def remove_tag_from_script( script_id: int, tag_id: int, - db: aiosqlite.Connection = Depends(get_db) + db: aiosqlite.Connection = Depends(get_db), + current_user: Optional[dict] = Depends(get_optional_user), ): """Remove a tag from a script""" - # Get tag name async with db.execute("SELECT name FROM tags WHERE id = ?", (tag_id,)) as cursor: tag_row = await cursor.fetchone() tag_name = tag_row[0] if tag_row else str(tag_id) - - await db.execute( + + cursor = await db.execute( "DELETE FROM script_tags WHERE script_id = ? AND tag_id = ?", (script_id, tag_id) ) - - # Log the change + # Reporting success for a link that never existed hid mistakes and wrote a + # misleading audit entry. + if not cursor.rowcount: + raise HTTPException(status_code=404, detail="That tag is not applied to this script") + await db.execute( """ - INSERT INTO change_log (script_id, change_type, old_value) - VALUES (?, 'tag_removed', ?) + INSERT INTO change_log (script_id, change_type, old_value, actor) + VALUES (?, 'tag_removed', ?, ?) """, - (script_id, tag_name) + (script_id, tag_name, actor_name(current_user)) ) - + await db.commit() return {"message": "Tag removed successfully"} -@router.get("/duplicates/list") +@router.get("/duplicates/list", dependencies=[read_access]) async def list_duplicates(db: aiosqlite.Connection = Depends(get_db)): """Find and list duplicate scripts""" query = """ @@ -372,7 +405,7 @@ async def list_duplicates(db: aiosqlite.Connection = Depends(get_db)): }) return duplicates -@router.get("/{script_id}/history") +@router.get("/{script_id}/history", dependencies=[read_access]) async def get_script_history(script_id: int, db: aiosqlite.Connection = Depends(get_db)): """Get change history for a script""" # Check if script exists @@ -401,7 +434,7 @@ async def get_script_history(script_id: int, db: aiosqlite.Connection = Depends( }) return history -@router.get("/{script_id}/content") +@router.get("/{script_id}/content", dependencies=[read_access]) async def get_script_content(script_id: int, db: aiosqlite.Connection = Depends(get_db)): """Get the actual file content of a script""" # Get script path and validate it exists in database @@ -437,22 +470,53 @@ async def get_script_content(script_id: int, db: aiosqlite.Connection = Depends( if common_path != root_path_abs: raise HTTPException(status_code=403, detail="Access denied: file is outside registered folder root") - # Read file content + # Read the file in a worker thread and cap how much is returned: an + # unbounded synchronous read of a large file froze the whole API. + def _read() -> tuple: + size = os.path.getsize(file_path_abs) + with open(file_path_abs, 'r', encoding='utf-8', errors='ignore') as fh: + body = fh.read(MAX_CONTENT_BYTES + 1) + if len(body) > MAX_CONTENT_BYTES: + return body[:MAX_CONTENT_BYTES], True, size + return body, False, size + try: - with open(file_path_abs, 'r', encoding='utf-8', errors='ignore') as f: - content = f.read() - return {"content": content, "path": file_path} + content, truncated, size = await asyncio.to_thread(_read) except FileNotFoundError: raise HTTPException(status_code=404, detail="File not found on disk") - except Exception as e: - raise HTTPException(status_code=500, detail=f"Error reading file: {str(e)}") + except OSError as e: + raise HTTPException(status_code=500, detail=f"Error reading file: {e}") + + return { + "content": content, + "path": file_path, + "size": size, + "truncated": truncated, + } -@router.post("/bulk/tags") +@router.post("/bulk/tags", dependencies=[update_access]) async def bulk_add_tags( request: BulkTagRequest, db: aiosqlite.Connection = Depends(get_db) ): """Add tags to multiple scripts""" + if not request.script_ids or not request.tag_ids: + raise HTTPException(status_code=400, detail="Provide at least one script and one tag") + + # Verify the tags up front so an unknown id is a clear 404 rather than a + # foreign-key error counted as "skipped". + placeholders = ','.join('?' * len(request.tag_ids)) + async with db.execute( + f"SELECT id, name FROM tags WHERE id IN ({placeholders})", tuple(request.tag_ids) + ) as cursor: + tag_names = {row[0]: row[1] for row in await cursor.fetchall()} + unknown = sorted(set(request.tag_ids) - set(tag_names)) + if unknown: + raise HTTPException( + status_code=404, + detail=f"Unknown tag id(s): {', '.join(str(t) for t in unknown)}", + ) + added_count = 0 skipped_count = 0 @@ -465,11 +529,8 @@ async def bulk_add_tags( for tag_id in request.tag_ids: try: - # Get tag name for logging - async with db.execute("SELECT name FROM tags WHERE id = ?", (tag_id,)) as cursor: - tag_row = await cursor.fetchone() - tag_name = tag_row[0] if tag_row else str(tag_id) - + tag_name = tag_names[tag_id] + await db.execute( "INSERT INTO script_tags (script_id, tag_id) VALUES (?, ?)", (script_id, tag_id) @@ -496,7 +557,7 @@ async def bulk_add_tags( "skipped": skipped_count } -@router.post("/bulk/status") +@router.post("/bulk/status", dependencies=[update_access]) async def bulk_update_status( request: BulkStatusRequest, db: aiosqlite.Connection = Depends(get_db) @@ -574,12 +635,19 @@ async def bulk_update_status( "updated": updated_count } -@router.post("/export") +@router.post("/export", dependencies=[read_access]) async def export_scripts( - script_ids: List[int] = None, + request: ExportRequest = Body(default_factory=ExportRequest), db: aiosqlite.Connection = Depends(get_db) ): - """Export script metadata as JSON""" + """ + Export script metadata as JSON. + + `script_ids` is read from the request body. Declared as a bare + `List[int] = None` parameter it was interpreted as a query parameter and + every export silently returned the entire database. + """ + script_ids = request.script_ids # Build query based on whether specific script IDs are provided if script_ids: placeholders = ','.join('?' * len(script_ids)) @@ -645,12 +713,12 @@ async def export_scripts( exported_scripts.append(script) return { - "export_date": datetime.now().isoformat(), + "export_date": datetime.now(timezone.utc).isoformat(), "script_count": len(exported_scripts), "scripts": exported_scripts } -@router.post("/import") +@router.post("/import", dependencies=[update_access]) async def import_scripts( data: dict, conflict_resolution: str = Query("skip", pattern="^(skip|overwrite|merge)$"), @@ -663,7 +731,6 @@ async def import_scripts( - overwrite: Overwrite existing script metadata - merge: Merge tags and notes """ - imported_count = 0 skipped_count = 0 updated_count = 0 @@ -690,6 +757,7 @@ async def import_scripts( # Handle tags if script_data.get('tags'): + existing_tag_ids = set() if conflict_resolution == "merge": # Get existing tags async with db.execute( @@ -730,11 +798,20 @@ async def import_scripts( # Handle status if script_data.get('status') and conflict_resolution in ["overwrite", "merge"]: status_data = script_data['status'] + # An upsert rather than INSERT OR REPLACE: the latter deletes + # the existing row first, silently wiping deprecated_date and + # migration_note (and any column the import omits). await db.execute( """ - INSERT OR REPLACE INTO script_status + INSERT INTO script_status (script_id, status, classification, owner, environment, updated_at) VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP) + ON CONFLICT(script_id) DO UPDATE SET + status = COALESCE(excluded.status, script_status.status), + classification = COALESCE(excluded.classification, script_status.classification), + owner = COALESCE(excluded.owner, script_status.owner), + environment = COALESCE(excluded.environment, script_status.environment), + updated_at = CURRENT_TIMESTAMP """, ( existing_id, @@ -750,6 +827,14 @@ async def import_scripts( for note_data in script_data['notes']: content = note_data.get('content') if content: + # Re-importing the same file used to append a fresh + # copy of every note each time. + async with db.execute( + "SELECT 1 FROM script_notes WHERE script_id = ? AND content = ?", + (existing_id, content), + ) as cursor: + if await cursor.fetchone(): + continue is_markdown = 1 if note_data.get('is_markdown') else 0 await db.execute( "INSERT INTO script_notes (script_id, content, is_markdown) VALUES (?, ?, ?)", @@ -764,13 +849,15 @@ async def import_scripts( await db.commit() return { + # Scripts are only ever matched to rows that already exist (a metadata + # import cannot conjure a file on disk), so there is no "imported" + # count to report - it was always zero. "message": "Import completed", - "imported": imported_count, "updated": updated_count, "skipped": skipped_count } -@router.get("/{script_id}/fields") +@router.get("/{script_id}/fields", dependencies=[read_access]) async def get_script_custom_fields(script_id: int, db: aiosqlite.Connection = Depends(get_db)): """Get all custom fields for a script""" async with db.execute("SELECT id FROM scripts WHERE id = ?", (script_id,)) as cursor: @@ -784,7 +871,7 @@ async def get_script_custom_fields(script_id: int, db: aiosqlite.Connection = De rows = await cursor.fetchall() return {row[0]: row[1] for row in rows} -@router.put("/{script_id}/fields/{key}") +@router.put("/{script_id}/fields/{key}", dependencies=[update_access]) async def set_script_custom_field( script_id: int, key: str, @@ -820,7 +907,7 @@ async def set_script_custom_field( await db.commit() return {"message": "Custom field updated successfully"} -@router.delete("/{script_id}/fields/{key}") +@router.delete("/{script_id}/fields/{key}", dependencies=[update_access]) async def delete_script_custom_field( script_id: int, key: str, diff --git a/backend/app/routes/search.py b/backend/app/routes/search.py index 4d37c02..ceb502c 100644 --- a/backend/app/routes/search.py +++ b/backend/app/routes/search.py @@ -5,11 +5,15 @@ import aiosqlite from app.db.database import get_db +from app.db.sql import TAGS_SUBQUERY, split_tags from app.models.schemas import SearchRequest, PaginatedResponse +from app.routes.deps import require_permission router = APIRouter() -@router.post("/", response_model=PaginatedResponse) +read_access = Depends(require_permission("search.read")) + +@router.post("/", response_model=PaginatedResponse, dependencies=[read_access]) async def search_scripts( search: SearchRequest, db: aiosqlite.Connection = Depends(get_db) @@ -20,8 +24,11 @@ async def search_scripts( # Query filter if search.query: - conditions.append("(s.name LIKE ? OR s.path LIKE ?)") - search_pattern = f"%{search.query}%" + # ESCAPE keeps %/_ in the user's text literal instead of turning the + # query into a wildcard that matches everything. + conditions.append("(s.name LIKE ? ESCAPE '\\' OR s.path LIKE ? ESCAPE '\\')") + escaped = search.query.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") + search_pattern = f"%{escaped}%" params.extend([search_pattern, search_pattern]) # Language filter @@ -116,9 +123,9 @@ async def search_scripts( # Get paginated results offset = (search.page - 1) * search.page_size query = f""" - SELECT DISTINCT s.id, s.name, s.path, s.extension, s.language, + SELECT DISTINCT s.id, s.name, s.path, s.extension, s.language, s.size, s.mtime, st.status, - GROUP_CONCAT(DISTINCT t.name) as tags + {TAGS_SUBQUERY} AS tags FROM scripts s LEFT JOIN script_status st ON s.id = st.script_id LEFT JOIN script_tags sct ON s.id = sct.script_id @@ -135,7 +142,7 @@ async def search_scripts( items = [] for row in rows: item = dict(row) - item['tags'] = item['tags'].split(',') if item.get('tags') else [] + item['tags'] = split_tags(item.get('tags')) items.append(item) total_pages = (total + search.page_size - 1) // search.page_size @@ -148,7 +155,7 @@ async def search_scripts( 'total_pages': total_pages } -@router.get("/stats") +@router.get("/stats", dependencies=[read_access]) async def get_stats(db: aiosqlite.Connection = Depends(get_db)): """Get statistics about scripts""" stats = {} diff --git a/backend/app/routes/setup.py b/backend/app/routes/setup.py index c8a8584..9a8e7c8 100644 --- a/backend/app/routes/setup.py +++ b/backend/app/routes/setup.py @@ -3,6 +3,7 @@ """ import json import os +import secrets import tempfile from fastapi import APIRouter, Depends, HTTPException @@ -206,9 +207,37 @@ async def get_setup_status(db: aiosqlite.Connection = Depends(get_db)): return {"setup_completed": False, "mode": None} +async def _create_admin(db: aiosqlite.Connection, username: str, email: str, + password: str, full_name: Optional[str] = None) -> Optional[int]: + """Create a superuser account with the admin role, or return None if taken.""" + from app.services.auth import get_password_hash + + async with db.execute( + "SELECT id FROM users WHERE username = ? OR email = ?", (username, email) + ) as cursor: + if await cursor.fetchone(): + return None + + cursor = await db.execute( + """INSERT INTO users + (username, email, full_name, hashed_password, is_active, is_superuser) + VALUES (?, ?, ?, ?, ?, ?)""", + (username, email, full_name, get_password_hash(password), True, True), + ) + user_id = cursor.lastrowid + async with db.execute("SELECT id FROM roles WHERE name = ?", ("admin",)) as cur: + role_row = await cur.fetchone() + if role_row: + await db.execute( + "INSERT OR IGNORE INTO user_roles (user_id, role_id) VALUES (?, ?)", + (user_id, role_row[0]), + ) + return user_id + + @router.post("/demo") async def start_demo_mode(db: aiosqlite.Connection = Depends(get_db)): - """Activate demo mode and seed sample data.""" + """Activate demo mode, seed sample data and create a demo administrator.""" # Reject if setup has already been completed async with db.execute( "SELECT value FROM app_settings WHERE key = 'setup_completed'" @@ -222,9 +251,24 @@ async def start_demo_mode(db: aiosqlite.Connection = Depends(get_db)): await _save_setting(db, "app_mode", "demo") await _seed_demo_data(db) + + # Demo mode previously finished with zero user accounts, so nobody could + # sign in to the API it had just enabled. Create a demo administrator and + # hand its one-time password back for the wizard to display. + demo_password = secrets.token_urlsafe(12) + created = await _create_admin( + db, "demo", "demo@example.com", demo_password, "Demo Administrator" + ) await _save_setting(db, "setup_completed", "true") await db.commit() - return {"message": "Demo mode activated", "mode": "demo"} + + return { + "message": "Demo mode activated", + "mode": "demo", + "credentials": ( + {"username": "demo", "password": demo_password} if created else None + ), + } @router.post("/complete") @@ -265,11 +309,24 @@ async def complete_setup( await _save_setting(db, "db_config", json.dumps(db_meta)) await _save_setting(db, "db_type", config.database.type) + demo_credentials = None if config.mode == "demo": await _seed_demo_data(db) + # Demo mode through this endpoint used to finish with no account at + # all, locking the installation it had just enabled. + if config.admin: + await _create_admin( + db, config.admin.username, config.admin.email, + config.admin.password, config.admin.full_name, + ) + else: + password = secrets.token_urlsafe(12) + if await _create_admin(db, "demo", "demo@example.com", password, + "Demo Administrator"): + demo_credentials = {"username": "demo", "password": password} else: # Create admin account for production / development - from app.services.auth import get_password_hash, validate_password_strength + from app.services.auth import validate_password_strength is_valid, error_msg = validate_password_strength(config.admin.password) if not is_valid: @@ -282,31 +339,13 @@ async def complete_setup( existing = await cursor.fetchone() if not existing: - hashed = get_password_hash(config.admin.password) - cur = await db.execute( - """INSERT INTO users - (username, email, full_name, hashed_password, is_active, is_superuser) - VALUES (?, ?, ?, ?, ?, ?)""", - ( - config.admin.username, - config.admin.email, - config.admin.full_name, - hashed, - True, - True, - ), + await _create_admin( + db, + config.admin.username, + config.admin.email, + config.admin.password, + config.admin.full_name, ) - user_id = cur.lastrowid - # Assign admin role - async with db.execute( - "SELECT id FROM roles WHERE name = ?", ("admin",) - ) as cur2: - role_row = await cur2.fetchone() - if role_row: - await db.execute( - "INSERT INTO user_roles (user_id, role_id) VALUES (?, ?)", - (user_id, role_row[0]), - ) await _save_setting(db, "setup_completed", "true") await db.commit() @@ -314,6 +353,7 @@ async def complete_setup( "message": "Setup completed successfully", "mode": config.mode, "db_type": config.database.type, + "credentials": demo_credentials, } diff --git a/backend/app/routes/similarity.py b/backend/app/routes/similarity.py index ffcb1de..0b96af9 100644 --- a/backend/app/routes/similarity.py +++ b/backend/app/routes/similarity.py @@ -7,11 +7,14 @@ from app.db.database import get_db from app.services.similarity import find_similar_scripts, find_all_similar_groups, get_similarity_matrix +from app.routes.deps import require_permission router = APIRouter() +read_access = Depends(require_permission("scripts.read")) -@router.get("/{script_id}") + +@router.get("/{script_id}", dependencies=[read_access]) async def get_similar_scripts( script_id: int, threshold: float = Query(0.7, ge=0.0, le=1.0, description="Similarity threshold (0.0 to 1.0)"), @@ -42,7 +45,7 @@ async def get_similar_scripts( raise HTTPException(status_code=500, detail=f"Similarity detection failed: {str(e)}") -@router.get("/groups/all") +@router.get("/groups/all", dependencies=[read_access]) async def get_similarity_groups( threshold: float = Query(0.8, ge=0.0, le=1.0, description="Similarity threshold"), min_group_size: int = Query(2, ge=2, le=10, description="Minimum scripts per group"), @@ -58,18 +61,23 @@ async def get_similarity_groups( - **min_group_size**: Minimum number of scripts required to form a group """ try: - groups = await find_all_similar_groups(db, threshold, min_group_size) + result = await find_all_similar_groups(db, threshold, min_group_size) return { 'threshold': threshold, 'min_group_size': min_group_size, - 'group_count': len(groups), - 'groups': groups + 'group_count': len(result['groups']), + 'groups': result['groups'], + # Surfaced so the UI can say the sweep was capped rather than + # implying it covered the whole collection. + 'scripts_compared': result['scripts_compared'], + 'truncated': result['truncated'], + 'max_scripts': result['max_scripts'], } except Exception as e: raise HTTPException(status_code=500, detail=f"Group detection failed: {str(e)}") -@router.post("/matrix") +@router.post("/matrix", dependencies=[read_access]) async def similarity_matrix( script_ids: List[int], db: aiosqlite.Connection = Depends(get_db) @@ -91,11 +99,15 @@ async def similarity_matrix( try: matrix = await get_similarity_matrix(db, script_ids) return matrix + except ValueError as exc: + # Unknown or duplicated script ids are the caller's mistake, not a + # server fault; they used to surface as a 500. + raise HTTPException(status_code=400, detail=str(exc)) except Exception as e: raise HTTPException(status_code=500, detail=f"Matrix generation failed: {str(e)}") -@router.get("/compare/{script_id1}/{script_id2}") +@router.get("/compare/{script_id1}/{script_id2}", dependencies=[read_access]) async def compare_two_scripts( script_id1: int, script_id2: int, diff --git a/backend/app/routes/tags.py b/backend/app/routes/tags.py index 51e2741..061dca7 100644 --- a/backend/app/routes/tags.py +++ b/backend/app/routes/tags.py @@ -7,17 +7,22 @@ from app.db.database import get_db from app.models.schemas import TagCreate, TagResponse +from app.routes.deps import require_permission router = APIRouter() -@router.get("/", response_model=List[TagResponse]) +read_access = Depends(require_permission("tags.read")) +create_access = Depends(require_permission("tags.create")) +delete_access = Depends(require_permission("tags.delete")) + +@router.get("/", response_model=List[TagResponse], dependencies=[read_access]) async def list_tags(db: aiosqlite.Connection = Depends(get_db)): """List all tags""" async with db.execute("SELECT * FROM tags ORDER BY name") as cursor: rows = await cursor.fetchall() return [dict(row) for row in rows] -@router.post("/", response_model=TagResponse) +@router.post("/", response_model=TagResponse, status_code=201, dependencies=[create_access]) async def create_tag(tag: TagCreate, db: aiosqlite.Connection = Depends(get_db)): """Create a new tag""" try: @@ -36,7 +41,7 @@ async def create_tag(tag: TagCreate, db: aiosqlite.Connection = Depends(get_db)) except aiosqlite.IntegrityError: raise HTTPException(status_code=400, detail="Tag with this name already exists") -@router.get("/{tag_id}", response_model=TagResponse) +@router.get("/{tag_id}", response_model=TagResponse, dependencies=[read_access]) async def get_tag(tag_id: int, db: aiosqlite.Connection = Depends(get_db)): """Get a specific tag""" async with db.execute("SELECT * FROM tags WHERE id = ?", (tag_id,)) as cursor: @@ -45,7 +50,7 @@ async def get_tag(tag_id: int, db: aiosqlite.Connection = Depends(get_db)): raise HTTPException(status_code=404, detail="Tag not found") return dict(row) -@router.delete("/{tag_id}") +@router.delete("/{tag_id}", dependencies=[delete_access]) async def delete_tag(tag_id: int, db: aiosqlite.Connection = Depends(get_db)): """Delete a tag""" async with db.execute("SELECT * FROM tags WHERE id = ?", (tag_id,)) as cursor: @@ -56,7 +61,7 @@ async def delete_tag(tag_id: int, db: aiosqlite.Connection = Depends(get_db)): await db.commit() return {"message": "Tag deleted successfully"} -@router.get("/{tag_id}/scripts") +@router.get("/{tag_id}/scripts", dependencies=[read_access]) async def get_tag_scripts( tag_id: int, db: aiosqlite.Connection = Depends(get_db) diff --git a/backend/app/routes/watch.py b/backend/app/routes/watch.py index 81ffba2..682b8cd 100644 --- a/backend/app/routes/watch.py +++ b/backend/app/routes/watch.py @@ -7,11 +7,15 @@ from app.db.database import get_db, DB_PATH from app.services.watch import get_watch_manager +from app.routes.deps import require_permission router = APIRouter() +read_access = Depends(require_permission("roots.read")) +manage_access = Depends(require_permission("roots.update")) -@router.post("/start/{root_id}") + +@router.post("/start/{root_id}", dependencies=[manage_access]) async def start_watch_mode( root_id: int, db: aiosqlite.Connection = Depends(get_db) @@ -62,7 +66,7 @@ async def start_watch_mode( } -@router.post("/stop/{root_id}") +@router.post("/stop/{root_id}", dependencies=[manage_access]) async def stop_watch_mode( root_id: int, db: aiosqlite.Connection = Depends(get_db) @@ -93,7 +97,7 @@ async def stop_watch_mode( } -@router.get("/status") +@router.get("/status", dependencies=[read_access]) async def watch_status(db: aiosqlite.Connection = Depends(get_db)): """Get watch mode status for all folder roots""" watch_manager = get_watch_manager(DB_PATH) @@ -142,7 +146,7 @@ async def watch_status(db: aiosqlite.Connection = Depends(get_db)): } -@router.post("/start-all") +@router.post("/start-all", dependencies=[manage_access]) async def start_all_watch_mode(db: aiosqlite.Connection = Depends(get_db)): """Start watch mode for all folder roots that have it enabled""" watch_manager = get_watch_manager(DB_PATH) @@ -176,7 +180,7 @@ async def start_all_watch_mode(db: aiosqlite.Connection = Depends(get_db)): } -@router.post("/stop-all") +@router.post("/stop-all", dependencies=[manage_access]) async def stop_all_watch_mode(): """Stop watch mode for all folder roots""" watch_manager = get_watch_manager(DB_PATH) diff --git a/backend/app/services/auth.py b/backend/app/services/auth.py index ed42de5..cba85f3 100644 --- a/backend/app/services/auth.py +++ b/backend/app/services/auth.py @@ -2,16 +2,84 @@ Authentication service Handles JWT tokens, password hashing, and user authentication """ +import logging +import os +import secrets from datetime import datetime, timedelta, timezone +from pathlib import Path from typing import Optional + from passlib.context import CryptContext from jose import JWTError, jwt -import os -# JWT settings -SECRET_KEY = os.getenv("SECRET_KEY", "your-secret-key-change-this-in-production") +logger = logging.getLogger(__name__) + ALGORITHM = "HS256" -ACCESS_TOKEN_EXPIRE_MINUTES = 60 * 24 # 24 hours +ACCESS_TOKEN_EXPIRE_MINUTES = int(os.getenv("ACCESS_TOKEN_EXPIRE_MINUTES", str(60 * 24))) + +# Placeholder that shipped in older .env.example files. Treating it as "unset" +# stops a well-known signing key from silently protecting a real deployment. +_INSECURE_PLACEHOLDERS = { + "", + "your-secret-key-change-this-in-production", + "change-me", + "changeme", + "secret", +} + + +def _resolve_secret_key() -> str: + """ + Resolve the JWT signing key. + + Order of preference: + 1. SECRET_KEY from the environment (the only option for multi-process + deployments, since every worker must sign with the same key). + 2. A random key persisted next to the database, generated on first run. + + A generated key keeps single-node installs secure by default while still + surviving restarts, which an in-memory key would not. + """ + env_key = (os.getenv("SECRET_KEY") or "").strip() + if env_key and env_key.lower() not in _INSECURE_PLACEHOLDERS: + return env_key + + if env_key: + logger.warning( + "SECRET_KEY is set to a well-known placeholder value and is being ignored. " + "Set a strong SECRET_KEY in the environment for production deployments." + ) + + key_path = Path(os.getenv("SECRET_KEY_FILE", "./data/.secret_key")) + try: + key_path.parent.mkdir(parents=True, exist_ok=True) + if key_path.exists(): + stored = key_path.read_text(encoding="utf-8").strip() + if stored: + return stored + generated = secrets.token_urlsafe(64) + key_path.write_text(generated, encoding="utf-8") + try: + key_path.chmod(0o600) + except OSError: + pass + logger.warning( + "SECRET_KEY was not provided; generated one at %s. " + "Set SECRET_KEY explicitly if you run more than one backend process.", + key_path, + ) + return generated + except OSError as exc: + # Read-only filesystem: fall back to a process-local key. Tokens will + # not survive a restart, which is safer than a predictable key. + logger.error( + "Could not persist a generated SECRET_KEY (%s); using an ephemeral key. " + "Tokens will be invalidated on restart.", exc + ) + return secrets.token_urlsafe(64) + + +SECRET_KEY = _resolve_secret_key() # Password hashing pwd_context = CryptContext(schemes=["argon2"], deprecated="auto") @@ -115,40 +183,73 @@ def check_permissions(user_permissions: list, required_permission: str) -> bool: return False -# Default permissions structure +# Default permissions structure. +# Permission names are "."; check_permissions() also honours +# ".*" wildcards and the "superuser" catch-all. DEFAULT_PERMISSIONS = { "admin": [ "superuser" ], "editor": [ - "scripts.read", - "scripts.create", - "scripts.update", - "scripts.delete", - "notes.read", - "notes.create", - "notes.update", - "notes.delete", - "tags.read", - "tags.create", - "tags.update", - "tags.delete", - "folders.read", - "folders.update", - "attachments.read", - "attachments.upload", - "attachments.delete" + "scripts.read", "scripts.create", "scripts.update", "scripts.delete", + "notes.read", "notes.create", "notes.update", "notes.delete", + "tags.read", "tags.create", "tags.update", "tags.delete", + "folders.read", "folders.update", + "attachments.read", "attachments.upload", "attachments.delete", + "roots.read", "roots.create", "roots.update", "roots.delete", "roots.scan", + "search.read", "search.create", "search.update", "search.delete", + "monitors.read", "monitors.create", "monitors.update", "monitors.delete", + "schedules.read", "schedules.create", "schedules.update", + "schedules.delete", "schedules.run", + "notifications.read", "notifications.update", + "incidents.read", "incidents.update", ], "viewer": [ "scripts.read", "notes.read", "tags.read", "folders.read", - "attachments.read" + "attachments.read", + "roots.read", + "search.read", + "monitors.read", + "schedules.read", + "notifications.read", + "incidents.read", ] } +async def sync_role_permissions(db): + """ + Refresh the built-in roles' permission sets. + + New resources (monitors, schedules, notifications) added permissions after + the roles were first seeded; without this an existing install would leave + editors and viewers unable to reach them. + """ + import json + + for role_name, permissions in DEFAULT_PERMISSIONS.items(): + async with db.execute( + "SELECT id, permissions FROM roles WHERE name = ?", (role_name,) + ) as cursor: + row = await cursor.fetchone() + if not row: + continue + try: + current = set(json.loads(row[1])) + except (json.JSONDecodeError, TypeError): + current = set() + merged = sorted(current | set(permissions)) + if merged != sorted(current): + await db.execute( + "UPDATE roles SET permissions = ? WHERE id = ?", + (json.dumps(merged), row[0]), + ) + await db.commit() + + async def init_default_roles(db): """Initialize default roles if they don't exist""" import json @@ -173,15 +274,21 @@ async def init_default_roles(db): async def init_default_admin(db): - """Initialize default admin user if no users exist""" - # Check if any users exist + """ + Create a bootstrap admin account if the installation has no users at all. + + Only reachable for installations that completed setup before the wizard + existed. The password is randomly generated and logged once rather than + being a well-known default, so an unattended upgrade never leaves an + admin/admin account exposed on the network. + """ async with db.execute("SELECT COUNT(*) FROM users") as cursor: count = (await cursor.fetchone())[0] if count > 0: return - - # Create default admin user - hashed_password = get_password_hash("admin") + + generated_password = os.getenv("BOOTSTRAP_ADMIN_PASSWORD") or secrets.token_urlsafe(16) + hashed_password = get_password_hash(generated_password) cursor = await db.execute( """ INSERT INTO users (username, email, full_name, hashed_password, is_active, is_superuser) @@ -190,8 +297,7 @@ async def init_default_admin(db): ("admin", "admin@example.com", "Administrator", hashed_password, True, True) ) user_id = cursor.lastrowid - - # Assign admin role + async with db.execute("SELECT id FROM roles WHERE name = ?", ("admin",)) as cursor: role_row = await cursor.fetchone() if role_row: @@ -199,7 +305,12 @@ async def init_default_admin(db): "INSERT INTO user_roles (user_id, role_id) VALUES (?, ?)", (user_id, role_row[0]) ) - + await db.commit() - print("Created default admin user (username: admin, password: admin)") - print("⚠️ IMPORTANT: Change the default admin password immediately!") + logger.warning( + "No users existed; created a bootstrap admin account.\n" + " username: admin\n" + " password: %s\n" + "Sign in and change this password immediately.", + generated_password, + ) diff --git a/backend/app/services/cron.py b/backend/app/services/cron.py new file mode 100644 index 0000000..b7e9fde --- /dev/null +++ b/backend/app/services/cron.py @@ -0,0 +1,298 @@ +""" +Cron expression parsing, validation and next-run calculation. + +Supports the standard 5-field crontab syntax: + + minute hour day-of-month month day-of-week + 0-59 0-23 1-31 1-12 0-6 (0 = Sunday, 7 also accepted) + +Each field accepts ``*``, ``a``, ``a-b``, ``*/n``, ``a-b/n`` and comma +separated lists of those. Three-letter month (JAN..DEC) and weekday +(SUN..SAT) names are accepted. The convenience aliases ``@hourly``, +``@daily``/``@midnight``, ``@weekly``, ``@monthly`` and ``@yearly``/``@annually`` +are also supported. + +Implemented in-tree rather than pulling in a scheduling dependency so that +validation errors and next-run times stay consistent between the API layer +and the scheduler loop. +""" +from __future__ import annotations + +from datetime import datetime, timedelta, timezone +from typing import List, Optional, Set + +try: # Python 3.9+ + from zoneinfo import ZoneInfo, ZoneInfoNotFoundError +except ImportError: # pragma: no cover - Python < 3.9 is unsupported anyway + ZoneInfo = None # type: ignore + + class ZoneInfoNotFoundError(Exception): # type: ignore + pass + + +class CronError(ValueError): + """Raised when a cron expression cannot be parsed.""" + + +MONTH_NAMES = { + "jan": 1, "feb": 2, "mar": 3, "apr": 4, "may": 5, "jun": 6, + "jul": 7, "aug": 8, "sep": 9, "oct": 10, "nov": 11, "dec": 12, +} + +DAY_NAMES = { + "sun": 0, "mon": 1, "tue": 2, "wed": 3, "thu": 4, "fri": 5, "sat": 6, +} + +ALIASES = { + "@yearly": "0 0 1 1 *", + "@annually": "0 0 1 1 *", + "@monthly": "0 0 1 * *", + "@weekly": "0 0 * * 0", + "@daily": "0 0 * * *", + "@midnight": "0 0 * * *", + "@hourly": "0 * * * *", +} + +# (name, min, max, name_map) +FIELDS = ( + ("minute", 0, 59, None), + ("hour", 0, 23, None), + ("day of month", 1, 31, None), + ("month", 1, 12, MONTH_NAMES), + ("day of week", 0, 6, DAY_NAMES), +) + +# A cron expression can legitimately have no match for years (e.g. "0 0 30 2 *"), +# so the search is bounded rather than looping forever. +MAX_LOOKAHEAD_DAYS = 366 * 5 + + +def _parse_value(token: str, field_name: str, low: int, high: int, names) -> int: + token = token.strip().lower() + if names and token in names: + value = names[token] + else: + try: + value = int(token) + except ValueError: + raise CronError(f"Invalid {field_name} value: '{token}'") + # Cron allows 7 as Sunday in the day-of-week field. + if field_name == "day of week" and value == 7: + value = 0 + if value < low or value > high: + raise CronError( + f"{field_name.capitalize()} value {value} is out of range ({low}-{high})" + ) + return value + + +def _parse_field(expr: str, field_name: str, low: int, high: int, names) -> Set[int]: + values: Set[int] = set() + for part in expr.split(","): + part = part.strip() + if not part: + raise CronError(f"Empty {field_name} entry in '{expr}'") + + step = 1 + if "/" in part: + base, _, step_str = part.partition("/") + try: + step = int(step_str) + except ValueError: + raise CronError(f"Invalid step '{step_str}' in {field_name}") + if step < 1: + raise CronError(f"Step must be >= 1 in {field_name}") + part = base.strip() or "*" + + if part == "*": + start, end = low, high + elif "-" in part[1:]: # part[1:] so a leading '-' is still an error + start_str, _, end_str = part.partition("-") + start = _parse_value(start_str, field_name, low, high, names) + end = _parse_value(end_str, field_name, low, high, names) + if start > end: + raise CronError( + f"Invalid {field_name} range '{part}': start is after end" + ) + else: + start = _parse_value(part, field_name, low, high, names) + end = start if step == 1 else high + + values.update(range(start, end + 1, step)) + + if not values: + raise CronError(f"No values matched for {field_name} in '{expr}'") + return values + + +class CronSchedule: + """A parsed 5-field cron expression.""" + + __slots__ = ("expression", "minutes", "hours", "days", "months", "weekdays", + "_dom_restricted", "_dow_restricted") + + def __init__(self, expression: str): + raw = (expression or "").strip() + if not raw: + raise CronError("Cron expression is empty") + + normalized = ALIASES.get(raw.lower(), raw) + parts = normalized.split() + if len(parts) != 5: + raise CronError( + "Cron expression must have exactly 5 fields " + "(minute hour day-of-month month day-of-week), " + f"got {len(parts)}" + ) + + self.expression = raw + parsed = [ + _parse_field(parts[i], name, low, high, names) + for i, (name, low, high, names) in enumerate(FIELDS) + ] + self.minutes, self.hours, self.days, self.months, self.weekdays = parsed + + # Standard cron semantics: when both day-of-month and day-of-week are + # restricted the job runs when EITHER matches; otherwise both must match. + self._dom_restricted = parts[2].strip() != "*" + self._dow_restricted = parts[4].strip() != "*" + + def matches(self, moment: datetime) -> bool: + """True if `moment` (naive local-to-its-timezone) satisfies the schedule.""" + if moment.minute not in self.minutes: + return False + if moment.hour not in self.hours: + return False + if moment.month not in self.months: + return False + + # Python: Monday == 0; cron: Sunday == 0 + cron_weekday = (moment.weekday() + 1) % 7 + dom_match = moment.day in self.days + dow_match = cron_weekday in self.weekdays + + if self._dom_restricted and self._dow_restricted: + return dom_match or dow_match + return dom_match and dow_match + + def next_run(self, after: datetime) -> Optional[datetime]: + """ + Return the first matching minute strictly after `after`. + + `after` must be timezone-aware; the result carries the same tzinfo. + Returns None when nothing matches within the lookahead window. + """ + candidate = after.replace(second=0, microsecond=0) + timedelta(minutes=1) + limit = after + timedelta(days=MAX_LOOKAHEAD_DAYS) + + while candidate <= limit: + if candidate.month not in self.months: + # Jump to the first minute of the next month. + if candidate.month == 12: + candidate = candidate.replace( + year=candidate.year + 1, month=1, day=1, hour=0, minute=0 + ) + else: + candidate = candidate.replace( + month=candidate.month + 1, day=1, hour=0, minute=0 + ) + continue + + if not self._day_matches(candidate): + candidate = (candidate + timedelta(days=1)).replace(hour=0, minute=0) + continue + + if candidate.hour not in self.hours: + candidate += timedelta(hours=1) + candidate = candidate.replace(minute=0) + continue + + if candidate.minute not in self.minutes: + candidate += timedelta(minutes=1) + continue + + return candidate + + return None + + def _day_matches(self, moment: datetime) -> bool: + cron_weekday = (moment.weekday() + 1) % 7 + dom_match = moment.day in self.days + dow_match = cron_weekday in self.weekdays + if self._dom_restricted and self._dow_restricted: + return dom_match or dow_match + return dom_match and dow_match + + +def validate_cron(expression: str) -> CronSchedule: + """Parse an expression, raising CronError with a readable message.""" + return CronSchedule(expression) + + +def validate_timezone(name: Optional[str]) -> str: + """Return a valid IANA timezone name, raising CronError otherwise.""" + tz_name = (name or "UTC").strip() or "UTC" + if tz_name.upper() == "UTC": + return "UTC" + if ZoneInfo is None: # pragma: no cover + raise CronError("Timezone support is unavailable on this Python build") + try: + ZoneInfo(tz_name) + except (ZoneInfoNotFoundError, ValueError, KeyError): + raise CronError(f"Unknown timezone: '{tz_name}'") + return tz_name + + +def _tzinfo(tz_name: Optional[str]): + tz_name = (tz_name or "UTC").strip() or "UTC" + if tz_name.upper() == "UTC" or ZoneInfo is None: + return timezone.utc + try: + return ZoneInfo(tz_name) + except (ZoneInfoNotFoundError, ValueError, KeyError): + return timezone.utc + + +def next_run_utc( + expression: str, + tz_name: str = "UTC", + after: Optional[datetime] = None, +) -> Optional[datetime]: + """ + Compute the next run time for `expression` in `tz_name`, returned in UTC. + + Raises CronError if the expression is invalid. + """ + schedule = validate_cron(expression) + tz = _tzinfo(tz_name) + reference = after or datetime.now(timezone.utc) + if reference.tzinfo is None: + reference = reference.replace(tzinfo=timezone.utc) + local_next = schedule.next_run(reference.astimezone(tz)) + if local_next is None: + return None + return local_next.astimezone(timezone.utc) + + +def describe(expression: str) -> str: + """Human-readable summary used in API responses and the UI.""" + try: + schedule = validate_cron(expression) + except CronError as exc: + return str(exc) + + def summarize(values: Set[int], low: int, high: int, label: str) -> str: + if len(values) == high - low + 1: + return f"every {label}" + ordered = sorted(values) + if len(ordered) > 6: + return f"{len(ordered)} selected {label}s" + return f"{label} {', '.join(str(v) for v in ordered)}" + + return "; ".join([ + summarize(schedule.minutes, 0, 59, "minute"), + summarize(schedule.hours, 0, 23, "hour"), + summarize(schedule.days, 1, 31, "day"), + summarize(schedule.months, 1, 12, "month"), + summarize(schedule.weekdays, 0, 6, "weekday"), + ]) diff --git a/backend/app/services/fts.py b/backend/app/services/fts.py index d03b052..77ed295 100644 --- a/backend/app/services/fts.py +++ b/backend/app/services/fts.py @@ -1,9 +1,12 @@ """ Full-Text Search (FTS5) service """ -import aiosqlite from typing import Dict +import aiosqlite + +from app.db.sql import TAGS_SUBQUERY, split_tags + async def index_script_content(db: aiosqlite.Connection, script_id: int, name: str, path: str, content: str = "", notes: str = ""): """Index a script's content in the FTS table""" @@ -58,6 +61,33 @@ async def update_script_notes_fts(db: aiosqlite.Connection, script_id: int): await db.commit() +def build_match_expression(query: str, search_content: bool, search_notes: bool) -> str: + """ + Turn user input into a safe FTS5 MATCH expression. + + The query is quoted as a phrase so FTS5 operators the user typed (AND, OR, + NEAR, ``*``, ``:``, unbalanced quotes) are matched literally instead of + raising a syntax error or changing the meaning of the search. + + The column filter is what makes ``search_content`` and ``search_notes`` + actually do something: previously both flags were computed and then + ignored, so every search matched content and notes regardless. + """ + columns = ["name", "path"] + if search_content: + columns.append("content") + if search_notes: + columns.append("notes") + + escaped = (query or "").replace('"', '""').strip() + if not escaped: + raise ValueError("Search query cannot be empty") + + phrase = f'"{escaped}"' + # {col1 col2} : restricts the match to those columns. + return "{" + " ".join(columns) + "} : " + phrase + + async def search_fts( db: aiosqlite.Connection, query: str, @@ -67,74 +97,61 @@ async def search_fts( page_size: int = 50 ) -> Dict: """ - Perform full-text search across scripts - Returns paginated results with match ranks + Perform full-text search across scripts. + Returns paginated results with match ranks. """ - # Build search columns - search_cols = ["name", "path"] - if search_content: - search_cols.append("content") - if search_notes: - search_cols.append("notes") - - # Build FTS query - sanitize for FTS5 syntax - # Wrap query in quotes to treat as phrase and escape special chars - fts_query = query.replace('"', '""') # Escape double quotes - # Wrap in quotes for phrase matching, which prevents FTS5 syntax injection - fts_query = f'"{fts_query}"' - - # Count total results - count_query = f""" + fts_query = build_match_expression(query, search_content, search_notes) + + count_query = """ SELECT COUNT(DISTINCT fts.script_id) FROM scripts_fts fts - WHERE scripts_fts MATCH ? + JOIN scripts s ON fts.script_id = s.id + WHERE scripts_fts MATCH ? AND s.missing_flag = 0 """ async with db.execute(count_query, (fts_query,)) as cursor: total = (await cursor.fetchone())[0] - - # Get paginated results with ranking + offset = (page - 1) * page_size - search_query = """ - SELECT DISTINCT + search_query = f""" + SELECT fts.script_id, s.name, s.path, + s.extension, s.language, s.size, s.mtime, st.status, - GROUP_CONCAT(DISTINCT t.name) as tags, + {TAGS_SUBQUERY} AS tags, rank FROM scripts_fts fts JOIN scripts s ON fts.script_id = s.id LEFT JOIN script_status st ON s.id = st.script_id - LEFT JOIN script_tags sct ON s.id = sct.script_id - LEFT JOIN tags t ON sct.tag_id = t.id - WHERE scripts_fts MATCH ? + WHERE scripts_fts MATCH ? AND s.missing_flag = 0 GROUP BY fts.script_id ORDER BY rank LIMIT ? OFFSET ? """ - + async with db.execute(search_query, (fts_query, page_size, offset)) as cursor: rows = await cursor.fetchall() items = [] for row in rows: - item = { + items.append({ 'id': row[0], 'name': row[1], 'path': row[2], - 'language': row[3], - 'size': row[4], - 'mtime': row[5], - 'status': row[6], - 'tags': row[7].split(',') if row[7] else [], - 'rank': row[8] - } - items.append(item) - + 'extension': row[3], + 'language': row[4], + 'size': row[5], + 'mtime': row[6], + 'status': row[7], + 'tags': split_tags(row[8]), + 'rank': row[9], + }) + total_pages = (total + page_size - 1) // page_size - + return { 'items': items, 'total': total, @@ -144,6 +161,11 @@ async def search_fts( } +async def remove_from_index(db: aiosqlite.Connection, script_id: int): + """Drop a script from the FTS index (used when it is deleted or goes missing).""" + await db.execute("DELETE FROM scripts_fts WHERE script_id = ?", (script_id,)) + + async def rebuild_fts_index(db: aiosqlite.Connection, root_id: int = None): """Rebuild the FTS index for all scripts or a specific root""" # Clear FTS table diff --git a/backend/app/services/notifier.py b/backend/app/services/notifier.py new file mode 100644 index 0000000..3160666 --- /dev/null +++ b/backend/app/services/notifier.py @@ -0,0 +1,402 @@ +""" +Notification delivery service. + +Turns a configured notification channel into an actual outbound message. +Every sender is best-effort: a failure is reported back to the caller as a +``DeliveryResult`` rather than raised, so one broken channel never stops the +others (or the scheduler / monitor loop that triggered the alert). + +Supported channel types and their ``config`` keys: + + slack webhook_url + discord webhook_url + webhook url, method (default POST), headers (dict) + pagerduty routing_key, (optional) severity + email smtp_host, smtp_port, to, from, (optional) smtp_user, + smtp_pass, use_tls (default true) + sms provider ("twilio"), account_sid, auth_token, from, to +""" +from __future__ import annotations + +import asyncio +import json +import logging +import smtplib +import ssl +from dataclasses import dataclass, field +from email.message import EmailMessage +from typing import Any, Dict, Iterable, List, Optional + +import httpx + +logger = logging.getLogger(__name__) + +# Outbound requests must never stall a scheduler tick or an API request. +HTTP_TIMEOUT_SECONDS = 10.0 +SMTP_TIMEOUT_SECONDS = 15.0 + +VALID_CHANNEL_TYPES = {"slack", "discord", "email", "webhook", "pagerduty", "sms"} + +# Config keys whose values are secrets. They are never returned by the API and +# are preserved (rather than overwritten) when a client submits the placeholder. +SECRET_CONFIG_KEYS = { + "webhook_url", "url", "auth_token", "account_sid", "routing_key", + "smtp_pass", "smtp_password", "password", "token", "api_key", "secret", +} + +REDACTED = "***" + +SEVERITY_COLORS = { + "critical": "#dc2626", + "warning": "#f59e0b", + "info": "#3b82f6", +} + + +@dataclass +class DeliveryResult: + """Outcome of a single delivery attempt.""" + channel_id: Optional[int] + channel_name: str + channel_type: str + success: bool + detail: str + + def as_dict(self) -> Dict[str, Any]: + return { + "channel_id": self.channel_id, + "channel_name": self.channel_name, + "channel_type": self.channel_type, + "success": self.success, + "detail": self.detail, + } + + +@dataclass +class Alert: + """A message to deliver through one or more channels.""" + title: str + body: str = "" + severity: str = "warning" + source: str = "script-manager" + links: Dict[str, str] = field(default_factory=dict) + + def as_text(self) -> str: + parts = [self.title] + if self.body: + parts.append(self.body) + for label, url in self.links.items(): + parts.append(f"{label}: {url}") + return "\n".join(parts) + + +def redact_config(config: Dict[str, Any]) -> Dict[str, Any]: + """Return a copy of `config` with secret values replaced by '***'.""" + if not isinstance(config, dict): + return {} + return { + key: REDACTED if key.lower() in SECRET_CONFIG_KEYS and value not in (None, "") else value + for key, value in config.items() + } + + +def merge_config(existing: Dict[str, Any], incoming: Dict[str, Any]) -> Dict[str, Any]: + """ + Merge a client-submitted config over the stored one. + + Because reads are redacted, a client that edits a channel will send back + ``"***"`` for any secret it did not change. Treat that as "keep the stored + value" so editing a channel name cannot silently wipe its webhook URL. + """ + existing = existing if isinstance(existing, dict) else {} + incoming = incoming if isinstance(incoming, dict) else {} + merged = dict(incoming) + for key, value in incoming.items(): + if value == REDACTED and key in existing: + merged[key] = existing[key] + return merged + + +def validate_channel_config(channel_type: str, config: Dict[str, Any]) -> List[str]: + """Return a list of human-readable problems with the config (empty if valid).""" + config = config if isinstance(config, dict) else {} + problems: List[str] = [] + + def require(*keys: str): + for key in keys: + if not str(config.get(key) or "").strip(): + problems.append(f"'{key}' is required for {channel_type} channels") + + if channel_type in ("slack", "discord"): + require("webhook_url") + elif channel_type == "webhook": + require("url") + method = str(config.get("method") or "POST").upper() + if method not in ("POST", "PUT", "PATCH"): + problems.append("'method' must be POST, PUT or PATCH") + headers = config.get("headers") + if headers is not None and not isinstance(headers, dict): + problems.append("'headers' must be an object") + elif channel_type == "pagerduty": + require("routing_key") + elif channel_type == "email": + require("smtp_host", "to") + port = config.get("smtp_port", 587) + try: + port = int(port) + if not 1 <= port <= 65535: + raise ValueError + except (TypeError, ValueError): + problems.append("'smtp_port' must be a port number between 1 and 65535") + elif channel_type == "sms": + provider = str(config.get("provider") or "twilio").lower() + if provider != "twilio": + problems.append("only the 'twilio' SMS provider is supported") + require("account_sid", "auth_token", "to") + if not str(config.get("from") or config.get("from_number") or "").strip(): + problems.append("'from' is required for sms channels") + + return problems + + +# ── Individual senders ──────────────────────────────────────────────────────── + +async def _post_json(url: str, payload: Any, headers: Optional[Dict[str, str]] = None, + method: str = "POST") -> httpx.Response: + async with httpx.AsyncClient(timeout=HTTP_TIMEOUT_SECONDS) as client: + response = await client.request(method, url, json=payload, headers=headers or {}) + if response.status_code >= 400: + raise RuntimeError( + f"HTTP {response.status_code}: {response.text[:200] or 'no response body'}" + ) + return response + + +async def _send_slack(config: Dict[str, Any], alert: Alert): + payload = { + "text": alert.title, + "attachments": [{ + "color": SEVERITY_COLORS.get(alert.severity, "#6b7280"), + "text": alert.body or alert.title, + "footer": alert.source, + "fields": [ + {"title": label, "value": url, "short": False} + for label, url in alert.links.items() + ], + }], + } + await _post_json(config["webhook_url"], payload) + return "Delivered to Slack" + + +async def _send_discord(config: Dict[str, Any], alert: Alert): + colour = int(SEVERITY_COLORS.get(alert.severity, "#6b7280").lstrip("#"), 16) + payload = { + "content": alert.title, + "embeds": [{ + "title": alert.title, + "description": alert.body or None, + "color": colour, + "footer": {"text": alert.source}, + }], + } + await _post_json(config["webhook_url"], payload) + return "Delivered to Discord" + + +async def _send_webhook(config: Dict[str, Any], alert: Alert): + payload = { + "title": alert.title, + "body": alert.body, + "severity": alert.severity, + "source": alert.source, + "links": alert.links, + } + headers = config.get("headers") if isinstance(config.get("headers"), dict) else {} + method = str(config.get("method") or "POST").upper() + await _post_json(config["url"], payload, headers=headers, method=method) + return f"Delivered via {method} webhook" + + +async def _send_pagerduty(config: Dict[str, Any], alert: Alert): + severity = str(config.get("severity") or alert.severity).lower() + if severity not in ("critical", "error", "warning", "info"): + severity = "warning" + payload = { + "routing_key": config["routing_key"], + "event_action": "trigger", + "payload": { + "summary": alert.title[:1024], + "source": alert.source, + "severity": severity, + "custom_details": {"body": alert.body, **alert.links}, + }, + } + await _post_json("https://events.pagerduty.com/v2/enqueue", payload) + return "Event queued with PagerDuty" + + +def _send_email_blocking(config: Dict[str, Any], alert: Alert) -> str: + message = EmailMessage() + message["Subject"] = f"[{alert.severity.upper()}] {alert.title}" + message["From"] = config.get("from") or config.get("smtp_user") or "script-manager@localhost" + recipients = config["to"] + if isinstance(recipients, (list, tuple)): + recipients = ", ".join(recipients) + message["To"] = recipients + message.set_content(alert.as_text()) + + host = config["smtp_host"] + port = int(config.get("smtp_port") or 587) + use_tls = config.get("use_tls", True) + + if port == 465: + server = smtplib.SMTP_SSL(host, port, timeout=SMTP_TIMEOUT_SECONDS, + context=ssl.create_default_context()) + else: + server = smtplib.SMTP(host, port, timeout=SMTP_TIMEOUT_SECONDS) + try: + if port != 465 and use_tls: + server.starttls(context=ssl.create_default_context()) + user = config.get("smtp_user") + password = config.get("smtp_pass") or config.get("smtp_password") + if user and password: + server.login(user, password) + server.send_message(message) + finally: + try: + server.quit() + except Exception: # noqa: BLE001 - closing errors must not mask the send result + pass + return f"Email sent to {recipients}" + + +async def _send_email(config: Dict[str, Any], alert: Alert): + # smtplib is blocking; keep it off the event loop. + return await asyncio.to_thread(_send_email_blocking, config, alert) + + +async def _send_sms(config: Dict[str, Any], alert: Alert): + account_sid = config["account_sid"] + auth_token = config["auth_token"] + from_number = config.get("from") or config.get("from_number") + to_number = config["to"] + body = alert.as_text()[:1500] + + url = f"https://api.twilio.com/2010-04-01/Accounts/{account_sid}/Messages.json" + async with httpx.AsyncClient(timeout=HTTP_TIMEOUT_SECONDS) as client: + response = await client.post( + url, + data={"From": from_number, "To": to_number, "Body": body}, + auth=(account_sid, auth_token), + ) + if response.status_code >= 400: + raise RuntimeError(f"HTTP {response.status_code}: {response.text[:200]}") + return f"SMS sent to {to_number}" + + +SENDERS = { + "slack": _send_slack, + "discord": _send_discord, + "webhook": _send_webhook, + "pagerduty": _send_pagerduty, + "email": _send_email, + "sms": _send_sms, +} + + +# ── Public API ──────────────────────────────────────────────────────────────── + +async def send_to_channel(channel: Dict[str, Any], alert: Alert) -> DeliveryResult: + """Deliver `alert` through a single channel row (dict with name/type/config).""" + channel_id = channel.get("id") + name = channel.get("name") or f"channel-{channel_id}" + channel_type = str(channel.get("type") or "").lower() + config = channel.get("config") or {} + if isinstance(config, str): + try: + config = json.loads(config) + except (json.JSONDecodeError, TypeError): + config = {} + + def result(success: bool, detail: str) -> DeliveryResult: + return DeliveryResult(channel_id, name, channel_type, success, detail) + + if not channel.get("enabled", True): + return result(False, "Channel is disabled") + + sender = SENDERS.get(channel_type) + if sender is None: + return result(False, f"Unsupported channel type '{channel_type}'") + + problems = validate_channel_config(channel_type, config) + if problems: + return result(False, "; ".join(problems)) + + try: + detail = await sender(config, alert) + return result(True, detail or "Delivered") + except httpx.TimeoutException: + return result(False, f"Timed out after {HTTP_TIMEOUT_SECONDS:.0f}s") + except httpx.HTTPError as exc: + return result(False, f"Network error: {exc}") + except KeyError as exc: + return result(False, f"Missing config key: {exc}") + except Exception as exc: # noqa: BLE001 - report, never propagate + logger.warning("Notification delivery failed for channel %s: %s", name, exc) + return result(False, str(exc)) + + +async def load_channels(db, channel_ids: Optional[Iterable[int]] = None) -> List[Dict[str, Any]]: + """Load enabled notification channels, optionally restricted to `channel_ids`.""" + ids = [int(cid) for cid in (channel_ids or [])] + if channel_ids is not None and not ids: + return [] + + if ids: + placeholders = ",".join("?" * len(ids)) + query = f"SELECT * FROM notification_channels WHERE enabled = 1 AND id IN ({placeholders})" + params: tuple = tuple(ids) + else: + query = "SELECT * FROM notification_channels WHERE enabled = 1" + params = () + + async with db.execute(query, params) as cursor: + rows = await cursor.fetchall() + + channels = [] + for row in rows: + channel = dict(row) + if isinstance(channel.get("config"), str): + try: + channel["config"] = json.loads(channel["config"]) + except (json.JSONDecodeError, TypeError): + channel["config"] = {} + channels.append(channel) + return channels + + +async def dispatch(db, channel_ids: Optional[Iterable[int]], alert: Alert) -> List[DeliveryResult]: + """ + Deliver `alert` to the given channels concurrently. + + Passing None for `channel_ids` broadcasts to every enabled channel. + Never raises: failures come back inside the returned results. + """ + channels = await load_channels(db, channel_ids) + if not channels: + return [] + results = await asyncio.gather( + *(send_to_channel(channel, alert) for channel in channels), + return_exceptions=True, + ) + delivered: List[DeliveryResult] = [] + for channel, outcome in zip(channels, results): + if isinstance(outcome, BaseException): + delivered.append(DeliveryResult( + channel.get("id"), channel.get("name", "?"), + str(channel.get("type", "?")), False, str(outcome), + )) + else: + delivered.append(outcome) + return delivered diff --git a/backend/app/services/scanner.py b/backend/app/services/scanner.py index d8324ee..555fbfb 100644 --- a/backend/app/services/scanner.py +++ b/backend/app/services/scanner.py @@ -1,12 +1,16 @@ """ Script scanning and indexing service """ -import os +import asyncio import hashlib -from pathlib import Path -from datetime import datetime -from typing import List, Dict, Optional, Tuple import fnmatch +import logging +import os +from datetime import datetime, timezone +from pathlib import Path +from typing import Dict, List, Optional, Set, Tuple + +logger = logging.getLogger(__name__) # Language detection based on extensions EXTENSION_LANGUAGE_MAP = { @@ -77,86 +81,129 @@ def match_patterns(path: str, patterns: Optional[str]) -> bool: return True return False -async def scan_directory( +def _scan_directory_sync( root_path: str, recursive: bool = True, include_patterns: Optional[str] = None, exclude_patterns: Optional[str] = None, follow_symlinks: bool = False, - max_file_size: int = 10485760 -) -> List[Dict]: - """ - Scan directory for script files - Returns list of file metadata dictionaries - """ - scripts = [] + max_file_size: int = 10485760, +) -> Tuple[List[Dict], List[str]]: + """Blocking directory walk. Returns (script metadata, folder paths).""" + scripts: List[Dict] = [] + folders: List[str] = [] root_path_obj = Path(root_path) - + if not root_path_obj.exists(): raise ValueError(f"Path does not exist: {root_path}") - + if not root_path_obj.is_dir(): raise ValueError(f"Path is not a directory: {root_path}") - + + # Following symlinks can otherwise walk a cycle forever; track real paths. + visited: Set[str] = set() + def scan_path(path: Path): try: - for item in path.iterdir(): + real = os.path.realpath(path) + if real in visited: + return + visited.add(real) + + for item in sorted(path.iterdir(), key=lambda p: p.name): # Skip if exclude pattern matches if exclude_patterns and match_patterns(str(item), exclude_patterns): continue - + # Handle symlinks if item.is_symlink() and not follow_symlinks: continue - + # Recursively scan directories if item.is_dir(): + folders.append(str(item.absolute())) if recursive: scan_path(item) continue - + # Process files if item.is_file(): - # Check if it's a script file if not is_script_file(str(item)): continue - - # Check include patterns + if include_patterns and not match_patterns(str(item), include_patterns): continue - - # Check file size + try: - file_size = item.stat().st_size - if file_size > max_file_size: - continue - except Exception: + stat = item.stat() + except OSError: + continue + + # Hashing and line counting read the whole file, so the + # size limit has to be checked before either runs. + if stat.st_size > max_file_size: continue - - # Get file metadata + try: - stat = item.stat() scripts.append({ 'path': str(item.absolute()), 'name': item.name, 'extension': item.suffix.lower(), 'language': detect_language(str(item)), 'size': stat.st_size, - 'mtime': datetime.fromtimestamp(stat.st_mtime), + 'mtime': datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc), 'hash': get_file_hash(str(item)), - 'line_count': get_line_count(str(item)) + 'line_count': get_line_count(str(item)), }) - except Exception as e: - print(f"Error processing file {item}: {e}") + except OSError as exc: + logger.warning("Error processing file %s: %s", item, exc) continue except PermissionError: - print(f"Permission denied: {path}") - except Exception as e: - print(f"Error scanning {path}: {e}") - + logger.warning("Permission denied while scanning %s", path) + except OSError as exc: + logger.warning("Error scanning %s: %s", path, exc) + scan_path(root_path_obj) + return scripts, folders + + +async def scan_directory( + root_path: str, + recursive: bool = True, + include_patterns: Optional[str] = None, + exclude_patterns: Optional[str] = None, + follow_symlinks: bool = False, + max_file_size: int = 10485760, +) -> List[Dict]: + """ + Scan a directory for script files. + + The walk is blocking (stat, hashing, line counts), so it runs in a worker + thread; running it inline would freeze the whole API for the duration of + a large scan. + """ + scripts, _folders = await asyncio.to_thread( + _scan_directory_sync, root_path, recursive, include_patterns, + exclude_patterns, follow_symlinks, max_file_size, + ) return scripts + +async def scan_directory_detailed( + root_path: str, + recursive: bool = True, + include_patterns: Optional[str] = None, + exclude_patterns: Optional[str] = None, + follow_symlinks: bool = False, + max_file_size: int = 10485760, +) -> Tuple[List[Dict], List[str]]: + """Like scan_directory but also returns the folders encountered.""" + return await asyncio.to_thread( + _scan_directory_sync, root_path, recursive, include_patterns, + exclude_patterns, follow_symlinks, max_file_size, + ) + + async def get_duplicate_scripts(db) -> List[Dict]: """Find scripts with duplicate content hashes""" query = """ diff --git a/backend/app/services/scheduler.py b/backend/app/services/scheduler.py new file mode 100644 index 0000000..4b23e2b --- /dev/null +++ b/backend/app/services/scheduler.py @@ -0,0 +1,722 @@ +""" +Background scheduler and monitor evaluator. + +Two responsibilities, both previously missing: + + * **Job scheduler** - jobs stored a ``cron_expression`` but nothing ever ran + them, so "scheduled" jobs only executed when a human clicked Run. This loop + computes ``next_run_at`` and fires jobs when they come due. + * **Monitor evaluator** - heartbeat monitors only re-evaluated their status + while somebody had the Monitors page open, so an overdue cron job produced + no incident and no alert unless a browser happened to be watching. This + loop evaluates them on a timer. + +Both loops are resilient: an exception in one tick is logged and the loop +continues. Set ``ENABLE_SCHEDULER=false`` to disable them (the test suite and +read-only replicas do). +""" +from __future__ import annotations + +import asyncio +import json +import logging +import os +import signal +from datetime import datetime, timedelta, timezone +from typing import List, Optional + +import aiosqlite + +from app.db import database as db_module +from app.services import notifier +from app.services.cron import CronError, next_run_utc + +logger = logging.getLogger(__name__) + +# asyncio keeps only a weak reference to a task, so a job left solely in a local +# variable can be garbage-collected mid-run, stranding its execution row at +# 'running' and blocking the job until the next startup reap. +_running_tasks: set = set() + +TICK_SECONDS = int(os.getenv("SCHEDULER_TICK_SECONDS", "30")) +# A job whose next_run_at slipped further into the past than this is rescheduled +# rather than fired, so a backend that was offline for a week does not stampede. +MAX_CATCHUP_SECONDS = int(os.getenv("SCHEDULER_MAX_CATCHUP_SECONDS", "3600")) +# Captured stdout/stderr is stored in SQLite; cap it so one chatty job cannot +# bloat the database. +MAX_LOG_CHARS = int(os.getenv("JOB_LOG_MAX_CHARS", "200000")) +# How long to wait for a killed job's pipes to close before giving up on them. +KILL_DRAIN_SECONDS = 10.0 + + +def scheduler_enabled() -> bool: + return os.getenv("ENABLE_SCHEDULER", "true").strip().lower() not in ("0", "false", "no", "off") + + +def _kill_process_group(proc) -> None: + """Terminate a job's whole process group, falling back to the process.""" + try: + os.killpg(os.getpgid(proc.pid), signal.SIGKILL) + except (ProcessLookupError, PermissionError, OSError): + try: + proc.kill() + except ProcessLookupError: + pass + + +def _truncate(text: str) -> str: + if text and len(text) > MAX_LOG_CHARS: + return text[:MAX_LOG_CHARS] + f"\n... [truncated, {len(text) - MAX_LOG_CHARS} more characters]" + return text + + +def parse_channel_ids(raw) -> List[int]: + """Parse the JSON-encoded notify_channel_ids column into a list of ints.""" + if not raw: + return [] + if isinstance(raw, list): + return [int(v) for v in raw] + try: + value = json.loads(raw) + except (json.JSONDecodeError, TypeError): + return [] + if not isinstance(value, list): + return [] + out = [] + for item in value: + try: + out.append(int(item)) + except (TypeError, ValueError): + continue + return out + + +def parse_sqlite_timestamp(value) -> Optional[datetime]: + """ + Parse a SQLite timestamp into an aware UTC datetime. + + SQLite's CURRENT_TIMESTAMP writes naive UTC strings ("YYYY-MM-DD HH:MM:SS"), + while code paths that store ``datetime.isoformat()`` write offset-aware + ones. Both must compare correctly against ``datetime.now(timezone.utc)``. + """ + if value is None: + return None + if isinstance(value, datetime): + parsed = value + else: + text = str(value).strip() + if not text: + return None + try: + parsed = datetime.fromisoformat(text.replace("Z", "+00:00")) + except ValueError: + return None + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=timezone.utc) + return parsed.astimezone(timezone.utc) + + +async def compute_next_run(cron_expression: str, tz_name: str = "UTC", + after: Optional[datetime] = None) -> Optional[str]: + """Return the next run time as an ISO-8601 UTC string, or None.""" + try: + moment = next_run_utc(cron_expression, tz_name, after) + except CronError: + return None + return moment.isoformat() if moment else None + + +# ── Job execution ───────────────────────────────────────────────────────────── + +async def run_job_execution( + execution_id: int, + job_id: int, + command: str, + timeout: Optional[int], + max_retries: int, + retry_delay: int, + db_path: Optional[str] = None, +): + """ + Run a job's command in a subprocess, capture output, record the result and + alert the job's notification channels when it ultimately fails. + + Retries create additional execution rows so the history shows every attempt. + """ + db_path = db_path or db_module.DB_PATH + attempt = 0 + final_status = "failed" + final_stderr = "" + + while True: + started_at = datetime.now(timezone.utc) + stdout_data, stderr_data = "", "" + exit_code: Optional[int] = None + status = "failed" + + try: + if not command: + raise ValueError("No command to execute") + + # start_new_session puts the shell and everything it spawns in one + # process group, so a timeout can kill the children too. Killing + # only the shell left the real workload running and the subsequent + # drain of its still-open pipes blocked forever. + proc = await asyncio.create_subprocess_shell( + command, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + start_new_session=True, + ) + try: + raw_out, raw_err = await asyncio.wait_for( + proc.communicate(), timeout=float(timeout) if timeout else None + ) + except asyncio.TimeoutError: + _kill_process_group(proc) + try: + raw_out, raw_err = await asyncio.wait_for( + proc.communicate(), timeout=KILL_DRAIN_SECONDS + ) + except asyncio.TimeoutError: + raw_out, raw_err = b"", b"" + stdout_data = raw_out.decode("utf-8", errors="replace") + stderr_data = ( + raw_err.decode("utf-8", errors="replace") + + f"\nTIMEOUT: process group killed after {timeout}s\n" + ) + exit_code = -1 + status = "timeout" + else: + stdout_data = raw_out.decode("utf-8", errors="replace") + stderr_data = raw_err.decode("utf-8", errors="replace") + exit_code = proc.returncode + status = "success" if exit_code == 0 else "failed" + except Exception as exc: # noqa: BLE001 - the failure belongs in the log row + stderr_data += f"\nExecution error: {exc}" + exit_code = -1 + status = "failed" + + ended_at = datetime.now(timezone.utc) + duration = (ended_at - started_at).total_seconds() + stdout_data = _truncate(stdout_data) + stderr_data = _truncate(stderr_data) + final_status, final_stderr = status, stderr_data + + async with aiosqlite.connect(db_path) as db: + db.row_factory = aiosqlite.Row + await db_module.apply_connection_pragmas(db) + current_exec_id = execution_id + if attempt == 0: + await db.execute( + """ + UPDATE job_executions + SET ended_at = ?, status = ?, exit_code = ?, + stdout = ?, stderr = ?, duration_seconds = ?, + retry_attempt = ? + WHERE id = ? + """, + ( + ended_at.isoformat(), status, exit_code, + stdout_data, stderr_data, duration, attempt, execution_id, + ), + ) + else: + retry_cur = await db.execute( + """ + INSERT INTO job_executions + (job_id, started_at, ended_at, status, exit_code, + stdout, stderr, duration_seconds, retry_attempt, triggered_by) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'retry') + """, + ( + job_id, started_at.isoformat(), ended_at.isoformat(), + status, exit_code, stdout_data, stderr_data, duration, attempt, + ), + ) + current_exec_id = retry_cur.lastrowid + + await db.execute( + """ + UPDATE schedule_jobs + SET last_run_at = ?, last_status = ?, updated_at = CURRENT_TIMESTAMP + WHERE id = ? + """, + (ended_at.isoformat(), status, job_id), + ) + await db.commit() + + if attempt == 0 and status not in ("running", "timeout") and duration is not None: + await check_zombie_duration(job_id, duration, current_exec_id, db_path) + + if status == "success" or attempt >= max_retries: + break + + attempt += 1 + await asyncio.sleep(retry_delay) + + if final_status != "success": + await _alert_job_failure(job_id, final_status, final_stderr, db_path) + else: + # A recovered job should not leave a stale open incident behind. + async with aiosqlite.connect(db_path) as db: + db.row_factory = aiosqlite.Row + await db_module.apply_connection_pragmas(db) + await resolve_job_incidents(db, job_id) + await db.commit() + + +async def _alert_job_failure(job_id: int, status: str, stderr: str, db_path: str): + """Open an incident for a failed job and notify its channels.""" + try: + async with aiosqlite.connect(db_path) as db: + db.row_factory = aiosqlite.Row + await db_module.apply_connection_pragmas(db) + async with db.execute( + "SELECT name, notify_channel_ids FROM schedule_jobs WHERE id = ?", (job_id,) + ) as cursor: + row = await cursor.fetchone() + if not row: + return + job_name = row["name"] + channel_ids = parse_channel_ids(row["notify_channel_ids"]) + + title = f"Scheduled job '{job_name}' {status}" + description = (stderr or "").strip()[:2000] or f"Job finished with status '{status}'." + + async with db.execute( + """ + SELECT id FROM incidents + WHERE source_type = 'schedule' AND source_id = ? AND status = 'open' + AND title = ? + """, + (job_id, title), + ) as cursor: + existing = await cursor.fetchone() + + if not existing: + await db.execute( + """ + INSERT INTO incidents (title, source_type, source_id, status, severity, description) + VALUES (?, 'schedule', ?, 'open', 'critical', ?) + """, + (title, job_id, description), + ) + await db.commit() + + if channel_ids: + await notifier.dispatch( + db, + channel_ids, + notifier.Alert( + title=title, + body=description, + severity="critical", + source="script-manager/schedules", + ), + ) + except Exception as exc: # noqa: BLE001 - alerting must never break execution + logger.warning("Could not raise failure alert for job %s: %s", job_id, exc) + + +async def check_zombie_duration(job_id: int, current_duration: float, + execution_id: int, db_path: Optional[str] = None): + """ + Compare a run's duration against its historical average and open a + 'zombie' incident on a large deviation in either direction. + """ + MIN_SAMPLES = 5 + OVER_MULTIPLIER = 3.0 + UNDER_FRACTION = 0.20 + + db_path = db_path or db_module.DB_PATH + async with aiosqlite.connect(db_path) as db: + db.row_factory = aiosqlite.Row + await db_module.apply_connection_pragmas(db) + async with db.execute( + """ + SELECT AVG(je.duration_seconds) AS avg_dur, COUNT(*) AS cnt + FROM job_executions je + WHERE je.job_id = ? + AND je.status IN ('success', 'failed') + AND je.id != ? + AND je.duration_seconds IS NOT NULL + """, + (job_id, execution_id), + ) as cursor: + row = await cursor.fetchone() + + if row is None or row["cnt"] < MIN_SAMPLES or row["avg_dur"] is None: + return + + avg_dur = row["avg_dur"] + if avg_dur <= 0: + return + + async with db.execute( + "SELECT name, notify_channel_ids FROM schedule_jobs WHERE id = ?", (job_id,) + ) as cursor: + job_row = await cursor.fetchone() + if not job_row: + return + job_name = job_row["name"] + + ratio = current_duration / avg_dur + if ratio > OVER_MULTIPLIER: + anomaly = ( + f"Zombie process detected: job '{job_name}' ran for " + f"{current_duration:.1f}s (avg: {avg_dur:.1f}s, {ratio:.1f}x over average)" + ) + elif ratio < UNDER_FRACTION: + anomaly = ( + f"Suspiciously short run: job '{job_name}' finished in " + f"{current_duration:.1f}s (avg: {avg_dur:.1f}s, only {ratio * 100:.0f}% of average)" + ) + else: + return + + async with db.execute( + """ + SELECT id FROM incidents + WHERE source_type = 'schedule' AND source_id = ? AND status = 'open' + AND title LIKE 'Abnormal duration for job%' + """, + (job_id,), + ) as cursor: + existing = await cursor.fetchone() + if existing: + return + + await db.execute( + """ + INSERT INTO incidents (title, source_type, source_id, status, severity, description) + VALUES (?, 'schedule', ?, 'open', 'warning', ?) + """, + (f"Abnormal duration for job '{job_name}'", job_id, anomaly), + ) + await db.commit() + + channel_ids = parse_channel_ids(job_row["notify_channel_ids"]) + if channel_ids: + await notifier.dispatch( + db, channel_ids, + notifier.Alert( + title=f"Abnormal duration for job '{job_name}'", + body=anomaly, + severity="warning", + source="script-manager/schedules", + ), + ) + + +async def resolve_job_incidents(db, job_id: int): + """Close open incidents for a job after a successful run.""" + await db.execute( + """ + UPDATE incidents + SET status = 'resolved', resolved_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP + WHERE source_type = 'schedule' AND source_id = ? AND status IN ('open', 'acknowledged') + """, + (job_id,), + ) + + +# ── Monitor evaluation ──────────────────────────────────────────────────────── + +async def evaluate_monitors(db) -> List[int]: + """ + Flip overdue monitors to 'failing', open an incident and notify. + + Database work is committed before any notification is sent: an outbound + webhook can take up to ten seconds, and holding SQLite's single write lock + for that long makes every other writer fail with "database is locked". + + Returns the ids of monitors that newly transitioned to failing. + """ + now = datetime.now(timezone.utc) + transitioned: List[int] = [] + pending_alerts: List[tuple] = [] + + async with db.execute("SELECT * FROM monitors WHERE status != 'paused'") as cursor: + monitors = await cursor.fetchall() + + for monitor in monitors: + if monitor["status"] == "failing": + continue + + # A monitor that never received its first ping is exactly the case a + # fail-safe monitor exists to catch (the cron job never ran at all), so + # the deadline is measured from creation when there is no ping yet. + reference = parse_sqlite_timestamp( + monitor["last_ping_at"] or monitor["created_at"] + ) + if reference is None: + continue + never_pinged = monitor["last_ping_at"] is None + + deadline = monitor["expected_interval_seconds"] + monitor["grace_period_seconds"] + elapsed = (now - reference).total_seconds() + if elapsed <= deadline: + continue + + await db.execute( + "UPDATE monitors SET status = 'failing', updated_at = CURRENT_TIMESTAMP WHERE id = ?", + (monitor["id"],), + ) + transitioned.append(monitor["id"]) + + title = f"Monitor '{monitor['name']}' is overdue" + description = ( + f"No ping has ever been received; {int(elapsed)}s since the monitor " + f"was created (deadline: {deadline}s)" + if never_pinged else + f"No ping received for {int(elapsed)}s (deadline: {deadline}s)" + ) + + async with db.execute( + "SELECT id FROM incidents WHERE source_type='monitor' AND source_id=? AND status='open'", + (monitor["id"],), + ) as cursor: + existing = await cursor.fetchone() + if not existing: + await db.execute( + """ + INSERT INTO incidents (title, source_type, source_id, status, severity, description) + VALUES (?, 'monitor', ?, 'open', 'critical', ?) + """, + (title, monitor["id"], description), + ) + + channel_ids = parse_channel_ids(monitor["notify_channel_ids"]) + if channel_ids: + pending_alerts.append((channel_ids, title, description)) + + if transitioned: + await db.commit() + + for channel_ids, title, description in pending_alerts: + await notifier.dispatch( + db, channel_ids, + notifier.Alert( + title=title, body=description, + severity="critical", source="script-manager/monitors", + ), + ) + + return transitioned + + +# ── Loops ───────────────────────────────────────────────────────────────────── + +async def _due_jobs(db, now: datetime): + """Yield jobs that are enabled and due to run.""" + async with db.execute( + "SELECT * FROM schedule_jobs WHERE enabled = 1" + ) as cursor: + return await cursor.fetchall() + + +async def tick_scheduler(db_path: Optional[str] = None) -> int: + """ + Run one scheduler pass. Returns the number of jobs started. + + Exposed separately from the loop so it can be tested and triggered on demand. + """ + db_path = db_path or db_module.DB_PATH + now = datetime.now(timezone.utc) + started = 0 + + async with aiosqlite.connect(db_path) as db: + db.row_factory = aiosqlite.Row + await db_module.apply_connection_pragmas(db) + + for job in await _due_jobs(db, now): + job_id = job["id"] + next_run = parse_sqlite_timestamp(job["next_run_at"]) + + if next_run is None: + # Newly enabled job, or one created before scheduling existed. + computed = await compute_next_run(job["cron_expression"], job["timezone"], now) + await db.execute( + "UPDATE schedule_jobs SET next_run_at = ? WHERE id = ?", (computed, job_id) + ) + continue + + if next_run > now: + continue + + # Always advance the schedule first so a failure cannot cause a hot loop. + computed = await compute_next_run(job["cron_expression"], job["timezone"], now) + await db.execute( + "UPDATE schedule_jobs SET next_run_at = ? WHERE id = ?", (computed, job_id) + ) + await db.commit() + + if (now - next_run).total_seconds() > MAX_CATCHUP_SECONDS: + logger.info( + "Skipping job '%s': scheduled run at %s is older than the catch-up window", + job["name"], next_run.isoformat(), + ) + continue + + command = job["command"] or "" + if not command and job["script_id"]: + async with db.execute( + "SELECT path FROM scripts WHERE id = ?", (job["script_id"],) + ) as cursor: + script_row = await cursor.fetchone() + if script_row: + command = script_row[0] + if not command: + logger.warning("Job '%s' has no command and no resolvable script", job["name"]) + continue + + if job["prevent_overlap"]: + async with db.execute( + "SELECT id FROM job_executions WHERE job_id = ? AND status = 'running'", + (job_id,), + ) as cursor: + if await cursor.fetchone(): + logger.info( + "Skipping job '%s': a previous run is still in progress", job["name"] + ) + continue + + try: + cursor = await db.execute( + """ + INSERT INTO job_executions + (job_id, started_at, status, triggered_by, overlap_key) + VALUES (?, ?, 'running', 'scheduler', ?) + """, + # overlap_key is the job id only when the job opted into + # overlap prevention; NULL rows do not collide in SQLite. + (job_id, now.isoformat(), job_id if job["prevent_overlap"] else None), + ) + execution_id = cursor.lastrowid + await db.commit() + except aiosqlite.IntegrityError: + # A manual trigger started this job between our overlap check + # and this insert; the partial unique index caught it. + logger.info("Skipping job '%s': another run started concurrently", job["name"]) + continue + + task = asyncio.create_task(run_job_execution( + execution_id=execution_id, + job_id=job_id, + command=command, + timeout=job["timeout_seconds"], + max_retries=job["max_retries"], + retry_delay=job["retry_delay_seconds"], + db_path=db_path, + )) + _running_tasks.add(task) + task.add_done_callback(_running_tasks.discard) + started += 1 + + await db.commit() + + return started + + +async def tick_monitors(db_path: Optional[str] = None) -> int: + """Run one monitor evaluation pass. Returns the number newly marked failing.""" + db_path = db_path or db_module.DB_PATH + async with aiosqlite.connect(db_path) as db: + db.row_factory = aiosqlite.Row + await db_module.apply_connection_pragmas(db) + return len(await evaluate_monitors(db)) + + +async def reap_stale_executions(db_path: Optional[str] = None) -> int: + """ + Mark executions that were left 'running' by a backend restart as failed. + + Without this, `prevent_overlap` would refuse to ever run the job again. + """ + db_path = db_path or db_module.DB_PATH + cutoff = datetime.now(timezone.utc) - timedelta(hours=24) + async with aiosqlite.connect(db_path) as db: + db.row_factory = aiosqlite.Row + await db_module.apply_connection_pragmas(db) + async with db.execute( + "SELECT id, started_at FROM job_executions WHERE status = 'running'" + ) as cursor: + rows = await cursor.fetchall() + + stale = [] + for row in rows: + started = parse_sqlite_timestamp(row["started_at"]) + if started is None or started < cutoff: + stale.append(row["id"]) + + for execution_id in stale: + await db.execute( + """ + UPDATE job_executions + SET status = 'failed', ended_at = CURRENT_TIMESTAMP, + stderr = COALESCE(stderr, '') || + '\nMarked failed: the backend restarted while this run was in progress.' + WHERE id = ? + """, + (execution_id,), + ) + if stale: + await db.commit() + return len(stale) + + +async def scheduler_loop(stop_event: asyncio.Event, db_path: Optional[str] = None): + """Main background loop: fires due jobs and evaluates monitors every tick.""" + logger.info("Scheduler started (tick=%ss)", TICK_SECONDS) + try: + await reap_stale_executions(db_path) + except Exception as exc: # noqa: BLE001 + logger.warning("Could not reap stale executions: %s", exc) + + while not stop_event.is_set(): + try: + started = await tick_scheduler(db_path) + if started: + logger.info("Scheduler started %d job(s)", started) + except Exception as exc: # noqa: BLE001 - one bad tick must not kill the loop + logger.exception("Scheduler tick failed: %s", exc) + + try: + failing = await tick_monitors(db_path) + if failing: + logger.warning("%d monitor(s) transitioned to failing", failing) + except Exception as exc: # noqa: BLE001 + logger.exception("Monitor evaluation tick failed: %s", exc) + + try: + await asyncio.wait_for(stop_event.wait(), timeout=TICK_SECONDS) + except asyncio.TimeoutError: + continue + + logger.info("Scheduler stopped") + + +class SchedulerHandle: + """Owns the background task so the app lifespan can start and stop it cleanly.""" + + def __init__(self): + self._stop = asyncio.Event() + self._task: Optional[asyncio.Task] = None + + def start(self, db_path: Optional[str] = None): + if self._task is not None: + return + self._stop = asyncio.Event() + self._task = asyncio.create_task(scheduler_loop(self._stop, db_path)) + + async def stop(self): + if self._task is None: + return + self._stop.set() + try: + await asyncio.wait_for(self._task, timeout=10) + except (asyncio.TimeoutError, asyncio.CancelledError): + self._task.cancel() + finally: + self._task = None diff --git a/backend/app/services/similarity.py b/backend/app/services/similarity.py index 39f9285..ecd2e70 100644 --- a/backend/app/services/similarity.py +++ b/backend/app/services/similarity.py @@ -2,17 +2,38 @@ Similarity Detection service Uses difflib for fuzzy matching to find similar scripts """ +import asyncio import difflib -from typing import List, Dict +import logging +import os +from typing import Dict, List, Optional, Set + import aiosqlite +logger = logging.getLogger(__name__) + +# Files larger than this are skipped: SequenceMatcher is quadratic in the +# input length, so comparing megabyte files stalls the worker thread. +MAX_COMPARE_BYTES = 1024 * 1024 + +# Upper bound on how many scripts a single repository-wide sweep will compare. +# The comparison is inherently O(n^2), so an unbounded sweep over a large +# collection never returns. +MAX_GROUP_SCAN_SCRIPTS = 400 + def calculate_similarity(text1: str, text2: str) -> float: """ - Calculate similarity ratio between two texts - Returns a value between 0 and 1 (1 = identical) + Calculate similarity ratio between two texts. + Returns a value between 0 and 1 (1 = identical). + + ``autojunk`` must stay off: difflib's heuristic treats any character + appearing in more than 1% of a sequence longer than 200 items as junk, + which for source code means spaces, newlines and common letters. With it + enabled, files over roughly 2 KB score near zero no matter how alike they + are, so every similarity result was meaningless for real scripts. """ - matcher = difflib.SequenceMatcher(None, text1, text2) + matcher = difflib.SequenceMatcher(None, text1, text2, autojunk=False) return matcher.ratio() @@ -25,17 +46,58 @@ def normalize_content(content: str) -> str: """ lines = content.split('\n') normalized_lines = [] - + for line in lines: line = line.strip() # Skip empty lines and common comment patterns if not line or line.startswith('#') or line.startswith('//') or line.startswith('/*'): continue normalized_lines.append(line.lower()) - + return '\n'.join(normalized_lines) +def _read_normalized(path: str) -> Optional[str]: + """Read and normalize a file, or None when it is unreadable or too large.""" + try: + if os.path.getsize(path) > MAX_COMPARE_BYTES: + return None + with open(path, 'r', encoding='utf-8', errors='ignore') as fh: + return normalize_content(fh.read()) + except OSError: + return None + + +async def _load_normalized(paths: Dict[int, str]) -> Dict[int, str]: + """ + Read and normalize several files off the event loop. + + Reading is blocking and the callers compare every pair, so loading each + file once here replaces the previous behaviour of re-reading a file from + disk for every comparison it took part in. + """ + def _load_all() -> Dict[int, str]: + loaded = {} + for script_id, path in paths.items(): + content = _read_normalized(path) + if content is not None: + loaded[script_id] = content + return loaded + + return await asyncio.to_thread(_load_all) + + +async def _score_pairs(pairs, contents) -> List[float]: + """Score a list of (id1, id2) pairs off the event loop.""" + def _run() -> List[float]: + return [ + calculate_similarity(contents.get(a, ""), contents.get(b, "")) + for a, b in pairs + ] + + return await asyncio.to_thread(_run) + + async def find_similar_scripts( db: aiosqlite.Connection, script_id: int, @@ -44,17 +106,16 @@ async def find_similar_scripts( ) -> List[Dict]: """ Find scripts similar to the given script - + Args: db: Database connection script_id: ID of the script to compare against threshold: Similarity threshold (0.0 to 1.0) limit: Maximum number of similar scripts to return - + Returns: List of similar scripts with similarity scores """ - # Get the target script's content async with db.execute( "SELECT path, name, language, size, hash FROM scripts WHERE id = ?", (script_id,) @@ -62,28 +123,16 @@ async def find_similar_scripts( target = await cursor.fetchone() if not target: return [] - - target_path, target_name, target_language, target_size, target_hash = target - - # Read target file content - try: - # Check file size first (limit to 1MB for similarity comparison) - import os - if os.path.getsize(target_path) > 1024 * 1024: - return [] - - with open(target_path, 'r', encoding='utf-8', errors='ignore') as f: - target_content = f.read() - except Exception: + target_path, _target_name, target_language, target_size, target_hash = target + + normalized_target = await asyncio.to_thread(_read_normalized, target_path) + if normalized_target is None: return [] - - # Normalize target content - normalized_target = normalize_content(target_content) - + # Find candidate scripts (same language, similar size) size_min = int(target_size * 0.5) if target_size else 0 size_max = int(target_size * 2) if target_size else 999999999 - + query = """ SELECT id, path, name, size, hash FROM scripts @@ -91,54 +140,44 @@ async def find_similar_scripts( AND missing_flag = 0 AND language = ? AND size BETWEEN ? AND ? - ORDER BY ABS(size - ?) + ORDER BY ABS(size - ?) LIMIT 50 """ - + async with db.execute( query, (script_id, target_language, size_min, size_max, target_size) ) as cursor: candidates = await cursor.fetchall() - + + # Skip exact duplicates (same hash) - those belong in the duplicates view. + candidates = [ + c for c in candidates + if not (c[4] and target_hash and c[4] == target_hash) + ] + if not candidates: + return [] + + contents = await _load_normalized({c[0]: c[1] for c in candidates}) + pairs = [(script_id, c[0]) for c in candidates if c[0] in contents] + contents[script_id] = normalized_target + scores = await _score_pairs(pairs, contents) + similar_scripts = [] - - for candidate in candidates: - candidate_id, candidate_path, candidate_name, candidate_size, candidate_hash = candidate - - # Skip exact duplicates (same hash) - if candidate_hash and target_hash and candidate_hash == target_hash: + by_id = {c[0]: c for c in candidates} + for (_target, candidate_id), similarity in zip(pairs, scores): + if similarity < threshold: continue - - # Read candidate content - try: - # Check file size first (limit to 1MB) - import os - if os.path.getsize(candidate_path) > 1024 * 1024: - continue - - with open(candidate_path, 'r', encoding='utf-8', errors='ignore') as f: - candidate_content = f.read() - except Exception: - continue - - # Normalize candidate content - normalized_candidate = normalize_content(candidate_content) - - # Calculate similarity - similarity = calculate_similarity(normalized_target, normalized_candidate) - - if similarity >= threshold: - similar_scripts.append({ - 'id': candidate_id, - 'path': candidate_path, - 'name': candidate_name, - 'size': candidate_size, - 'similarity_score': round(similarity, 4), - 'similarity_percent': round(similarity * 100, 2) - }) - - # Sort by similarity (highest first) and limit results + candidate = by_id[candidate_id] + similar_scripts.append({ + 'id': candidate[0], + 'path': candidate[1], + 'name': candidate[2], + 'size': candidate[3], + 'similarity_score': round(similarity, 4), + 'similarity_percent': round(similarity * 100, 2) + }) + similar_scripts.sort(key=lambda x: x['similarity_score'], reverse=True) return similar_scripts[:limit] @@ -146,20 +185,19 @@ async def find_similar_scripts( async def find_all_similar_groups( db: aiosqlite.Connection, threshold: float = 0.8, - min_group_size: int = 2 -) -> List[Dict]: + min_group_size: int = 2, + max_scripts: int = MAX_GROUP_SCAN_SCRIPTS, +) -> Dict: """ - Find all groups of similar scripts - - Args: - db: Database connection - threshold: Similarity threshold (0.0 to 1.0) - min_group_size: Minimum number of scripts in a group - - Returns: - List of similarity groups + Find groups of similar scripts across the collection. + + Each language is handled separately, every file is read once, and the + number of scripts considered is capped: the comparison is quadratic, so an + unbounded sweep over a large collection would never finish. + + Returns a dict with the groups plus how much of the collection was covered, + so the caller can tell the user when results were truncated. """ - # Get all scripts grouped by language async with db.execute( """ SELECT language, COUNT(*) as count @@ -167,77 +205,90 @@ async def find_all_similar_groups( WHERE missing_flag = 0 AND language IS NOT NULL GROUP BY language HAVING count >= ? + ORDER BY count DESC """, (min_group_size,) ) as cursor: languages = await cursor.fetchall() - - all_groups = [] - processed_scripts = set() - + + all_groups: List[Dict] = [] + considered = 0 + truncated = False + for language_row in languages: + if considered >= max_scripts: + truncated = True + break + language = language_row[0] - - # Get all scripts for this language + remaining = max_scripts - considered + async with db.execute( """ - SELECT id, path, name, size, hash + SELECT id, path, name, size FROM scripts WHERE language = ? AND missing_flag = 0 ORDER BY size + LIMIT ? """, - (language,) + (language, remaining) ) as cursor: scripts = await cursor.fetchall() - - # Compare scripts within same language - for i, script1 in enumerate(scripts): - script1_id = script1[0] - - if script1_id in processed_scripts: + + if (language_row[1] or 0) > len(scripts): + truncated = True + if len(scripts) < min_group_size: + continue + + considered += len(scripts) + details = {s[0]: {'id': s[0], 'name': s[2], 'path': s[1], 'size': s[3]} for s in scripts} + contents = await _load_normalized({s[0]: s[1] for s in scripts}) + + ids = [s[0] for s in scripts if s[0] in contents] + # Upper triangle only: similarity is symmetric, so comparing both + # directions doubled the work for no extra information. + pairs = [(ids[i], ids[j]) for i in range(len(ids)) for j in range(i + 1, len(ids))] + scores = await _score_pairs(pairs, contents) + + # Union-find over the pairs above the threshold groups transitively + # similar scripts together. + parent = {sid: sid for sid in ids} + + def find(x): + while parent[x] != x: + parent[x] = parent[parent[x]] + x = parent[x] + return x + + def union(a, b): + ra, rb = find(a), find(b) + if ra != rb: + parent[rb] = ra + + for (a, b), score in zip(pairs, scores): + if score >= threshold: + union(a, b) + + clusters: Dict[int, Set[int]] = {} + for sid in ids: + clusters.setdefault(find(sid), set()).add(sid) + + for members in clusters.values(): + if len(members) < min_group_size: continue - - similar_group = [script1_id] - - # Find similar scripts - similar = await find_similar_scripts(db, script1_id, threshold, limit=20) - - for sim in similar: - sim_id = sim['id'] - if sim_id not in processed_scripts: - similar_group.append(sim_id) - processed_scripts.add(sim_id) - - if len(similar_group) >= min_group_size: - # Get details for all scripts in group - placeholders = ','.join('?' * len(similar_group)) - async with db.execute( - f""" - SELECT id, name, path, size - FROM scripts - WHERE id IN ({placeholders}) - """, - similar_group - ) as cursor: - group_scripts = await cursor.fetchall() - - all_groups.append({ - 'language': language, - 'script_count': len(similar_group), - 'scripts': [ - { - 'id': s[0], - 'name': s[1], - 'path': s[2], - 'size': s[3] - } - for s in group_scripts - ] - }) - - processed_scripts.add(script1_id) - - return all_groups + all_groups.append({ + 'language': language, + 'script_count': len(members), + 'scripts': [details[m] for m in sorted(members)], + }) + + all_groups.sort(key=lambda g: g['script_count'], reverse=True) + return { + 'groups': all_groups, + 'scripts_compared': considered, + 'truncated': truncated, + 'max_scripts': max_scripts, + } async def get_similarity_matrix( @@ -246,51 +297,66 @@ async def get_similarity_matrix( ) -> Dict: """ Generate a similarity matrix for a set of scripts - + Args: db: Database connection script_ids: List of script IDs to compare - + Returns: Similarity matrix and script details + + Raises: + ValueError: when fewer than two scripts are given or an id is unknown. """ - if len(script_ids) < 2: - return {'error': 'Need at least 2 scripts for comparison'} - - # Get script details - placeholders = ','.join('?' * len(script_ids)) + # De-duplicate while preserving the caller's order. + ordered_ids: List[int] = [] + for sid in script_ids: + if sid not in ordered_ids: + ordered_ids.append(sid) + + if len(ordered_ids) < 2: + raise ValueError("Need at least 2 distinct scripts for comparison") + + placeholders = ','.join('?' * len(ordered_ids)) async with db.execute( f"SELECT id, name, path FROM scripts WHERE id IN ({placeholders})", - script_ids + ordered_ids ) as cursor: scripts = await cursor.fetchall() - + script_dict = {s[0]: {'id': s[0], 'name': s[1], 'path': s[2]} for s in scripts} - - # Read all file contents - contents = {} - for script_id, script_info in script_dict.items(): - try: - with open(script_info['path'], 'r', encoding='utf-8', errors='ignore') as f: - content = f.read() - contents[script_id] = normalize_content(content) - except Exception: - contents[script_id] = "" - - # Calculate similarity matrix + + # Previously a missing id produced a KeyError and a 500; name it instead. + missing = [sid for sid in ordered_ids if sid not in script_dict] + if missing: + raise ValueError( + f"Unknown script id(s): {', '.join(str(m) for m in missing)}" + ) + + contents = await _load_normalized({sid: script_dict[sid]['path'] for sid in ordered_ids}) + + pairs = [ + (ordered_ids[i], ordered_ids[j]) + for i in range(len(ordered_ids)) + for j in range(i + 1, len(ordered_ids)) + ] + scores = await _score_pairs(pairs, contents) + lookup = {pair: round(score, 4) for pair, score in zip(pairs, scores)} + matrix = [] - for id1 in script_ids: + for id1 in ordered_ids: row = [] - for id2 in script_ids: + for id2 in ordered_ids: if id1 == id2: - score = 1.0 + row.append(1.0) else: - score = calculate_similarity(contents[id1], contents[id2]) - row.append(round(score, 4)) + key = (id1, id2) if (id1, id2) in lookup else (id2, id1) + row.append(lookup.get(key, 0.0)) matrix.append(row) - + return { - 'script_ids': script_ids, - 'scripts': [script_dict[sid] for sid in script_ids], - 'similarity_matrix': matrix + 'script_ids': ordered_ids, + 'scripts': [script_dict[sid] for sid in ordered_ids], + 'similarity_matrix': matrix, + 'unreadable_script_ids': [sid for sid in ordered_ids if sid not in contents], } diff --git a/backend/app/services/watch.py b/backend/app/services/watch.py index 26b27e4..00adbcf 100644 --- a/backend/app/services/watch.py +++ b/backend/app/services/watch.py @@ -1,238 +1,326 @@ """ -Watch Mode service for automatic filesystem monitoring +Watch Mode service for automatic filesystem monitoring. + +A watchdog observer reports filesystem events from its own thread. Those +events are handed to a single long-lived worker thread per folder root, which +owns one SQLite connection and drains a queue. + +The previous design started a brand new thread and a brand new event loop and +connection for every individual event, so saving a directory of files could +spawn hundreds of concurrent writers against one SQLite file, each without a +busy timeout. It also ignored the folder root's include and exclude patterns, +so watch mode indexed files a scan would have skipped. """ import asyncio -import os +import logging +import queue +import sqlite3 +import threading +from datetime import datetime, timezone from pathlib import Path -from watchdog.observers import Observer +from typing import Dict, Optional, Tuple + from watchdog.events import FileSystemEventHandler -from typing import Dict -import aiosqlite -from datetime import datetime +from watchdog.observers import Observer + +from app.services.scanner import ( + detect_language, get_file_hash, get_line_count, is_script_file, match_patterns, +) + +logger = logging.getLogger(__name__) + +# Sentinel that tells a worker to finish. +_STOP = object() -from app.services.scanner import is_script_file, get_file_hash, get_line_count, detect_language +# Bound the backlog so a runaway process writing thousands of files cannot grow +# the queue without limit; excess events are dropped and logged, and the next +# manual scan reconciles whatever was missed. +MAX_QUEUE_SIZE = 10_000 class ScriptFileHandler(FileSystemEventHandler): - """Handler for script file changes""" - - def __init__(self, root_id: int, root_path: str, db_path: str, recursive: bool, - include_patterns: str, exclude_patterns: str, max_file_size: int): + """Translates watchdog events into work items for the root's worker.""" + + def __init__(self, root_id: int, root_path: str, work_queue: "queue.Queue", + include_patterns: Optional[str], exclude_patterns: Optional[str]): self.root_id = root_id self.root_path = root_path - self.db_path = db_path - self.recursive = recursive + self.queue = work_queue self.include_patterns = include_patterns self.exclude_patterns = exclude_patterns - self.max_file_size = max_file_size - self.pending_changes = [] - + + # ── Filtering ──────────────────────────────────────────────────────────── + + def _tracked(self, path: str) -> bool: + """Apply the same filters the scanner uses, so both agree on what is indexed.""" + if not is_script_file(path): + return False + if self.exclude_patterns and match_patterns(path, self.exclude_patterns): + return False + if self.include_patterns and not match_patterns(path, self.include_patterns): + return False + return True + + def _submit(self, action: str, path: str): + try: + self.queue.put_nowait((action, path)) + except queue.Full: + logger.warning( + "Watch queue for root %s is full; dropping %s event for %s", + self.root_id, action, path, + ) + + # ── Events ─────────────────────────────────────────────────────────────── + def on_created(self, event): - """Handle file creation""" if event.is_directory: return - - file_path = event.src_path - if is_script_file(file_path): - print(f"Watch: File created: {file_path}") - self._schedule_file_update(file_path, 'created') - + if self._tracked(event.src_path): + self._submit("upsert", event.src_path) + def on_modified(self, event): - """Handle file modification""" if event.is_directory: return - - file_path = event.src_path - if is_script_file(file_path): - print(f"Watch: File modified: {file_path}") - self._schedule_file_update(file_path, 'modified') - + if self._tracked(event.src_path): + self._submit("upsert", event.src_path) + def on_deleted(self, event): - """Handle file deletion""" if event.is_directory: return - - file_path = event.src_path - if is_script_file(file_path): - print(f"Watch: File deleted: {file_path}") - self._schedule_file_deletion(file_path) - + if self._tracked(event.src_path): + self._submit("missing", event.src_path) + def on_moved(self, event): - """Handle file move/rename""" if event.is_directory: return - - old_path = event.src_path - new_path = event.dest_path - - if is_script_file(old_path) or is_script_file(new_path): - print(f"Watch: File moved: {old_path} -> {new_path}") - # Treat as delete + create - if is_script_file(old_path): - self._schedule_file_deletion(old_path) - if is_script_file(new_path): - self._schedule_file_update(new_path, 'created') - - def _schedule_file_update(self, file_path: str, change_type: str): - """Schedule a file to be updated in the database""" - # Run database operation in a separate thread since watchdog runs in its own thread - import threading - thread = threading.Thread(target=self._sync_update_file, args=(file_path, change_type)) - thread.daemon = True - thread.start() - - def _schedule_file_deletion(self, file_path: str): - """Schedule a file to be marked as missing""" - import threading - thread = threading.Thread(target=self._sync_mark_file_missing, args=(file_path,)) - thread.daemon = True - thread.start() - - def _sync_update_file(self, file_path: str, change_type: str): - """Synchronous wrapper for file update""" - import asyncio - asyncio.run(self._update_file(file_path, change_type)) - - def _sync_mark_file_missing(self, file_path: str): - """Synchronous wrapper for marking file missing""" - import asyncio - asyncio.run(self._mark_file_missing(file_path)) - - async def _update_file(self, file_path: str, change_type: str): - """Update file in database""" - try: - # Get file metadata - path_obj = Path(file_path) - stat = path_obj.stat() - - # Check file size - if stat.st_size > self.max_file_size: - return - - metadata = { - 'path': str(path_obj.absolute()), - 'name': path_obj.name, - 'extension': path_obj.suffix.lower(), - 'language': detect_language(file_path), - 'size': stat.st_size, - 'mtime': datetime.fromtimestamp(stat.st_mtime), - 'hash': get_file_hash(file_path), - 'line_count': get_line_count(file_path) - } - - # Update database - async with aiosqlite.connect(self.db_path) as db: - # Check if script exists - async with db.execute( - "SELECT id FROM scripts WHERE path = ?", - (metadata['path'],) - ) as cursor: - existing = await cursor.fetchone() - - if existing: - # Update existing script - await db.execute( - """ - UPDATE scripts - SET name = ?, extension = ?, language = ?, size = ?, - mtime = ?, hash = ?, line_count = ?, missing_flag = 0, - updated_at = CURRENT_TIMESTAMP - WHERE path = ? - """, - ( - metadata['name'], metadata['extension'], metadata['language'], - metadata['size'], metadata['mtime'], metadata['hash'], - metadata['line_count'], metadata['path'] - ) - ) - print(f"Watch: Updated script in DB: {file_path}") - else: - # Insert new script - await db.execute( - """ - INSERT INTO scripts (root_id, path, name, extension, language, - size, mtime, hash, line_count) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) - """, - ( - self.root_id, metadata['path'], metadata['name'], - metadata['extension'], metadata['language'], metadata['size'], - metadata['mtime'], metadata['hash'], metadata['line_count'] - ) - ) - print(f"Watch: Inserted new script in DB: {file_path}") - - await db.commit() - - except Exception as e: - print(f"Watch: Error updating file {file_path}: {e}") - - async def _mark_file_missing(self, file_path: str): - """Mark file as missing in database""" + if self._tracked(event.src_path): + self._submit("missing", event.src_path) + if self._tracked(event.dest_path): + self._submit("upsert", event.dest_path) + + +class _RootWorker(threading.Thread): + """One worker thread per watched root, owning a single SQLite connection.""" + + def __init__(self, root_id: int, db_path: str, work_queue: "queue.Queue", + max_file_size: int): + super().__init__(daemon=True, name=f"watch-root-{root_id}") + self.root_id = root_id + self.db_path = db_path + self.queue = work_queue + self.max_file_size = max_file_size + + def run(self): + conn = sqlite3.connect(self.db_path, timeout=30) try: - async with aiosqlite.connect(self.db_path) as db: - await db.execute( - "UPDATE scripts SET missing_flag = 1 WHERE path = ?", - (file_path,) - ) - await db.commit() - print(f"Watch: Marked file as missing: {file_path}") - except Exception as e: - print(f"Watch: Error marking file missing {file_path}: {e}") + conn.execute("PRAGMA foreign_keys = ON") + conn.execute("PRAGMA busy_timeout = 30000") + while True: + item = self.queue.get() + if item is _STOP: + return + action, path = item + try: + if action == "upsert": + self._upsert(conn, path) + elif action == "missing": + self._mark_missing(conn, path) + except sqlite3.Error as exc: + logger.warning("Watch: database error handling %s for %s: %s", + action, path, exc) + except OSError as exc: + logger.warning("Watch: filesystem error handling %s for %s: %s", + action, path, exc) + finally: + self.queue.task_done() + finally: + conn.close() + + # ── Database work ──────────────────────────────────────────────────────── + + def _upsert(self, conn: sqlite3.Connection, file_path: str): + path_obj = Path(file_path) + if not path_obj.is_file(): + return + stat = path_obj.stat() + if stat.st_size > self.max_file_size: + return + + absolute = str(path_obj.absolute()) + # Stored in UTC to match every other timestamp in the database; a naive + # local value made date filters wrong on non-UTC servers. + mtime = datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc) + + row = conn.execute( + "SELECT id FROM scripts WHERE path = ?", (absolute,) + ).fetchone() + + values = ( + path_obj.name, path_obj.suffix.lower(), detect_language(file_path), + stat.st_size, mtime, get_file_hash(file_path), get_line_count(file_path), + ) + + if row: + conn.execute( + """ + UPDATE scripts + SET name = ?, extension = ?, language = ?, size = ?, + mtime = ?, hash = ?, line_count = ?, missing_flag = 0, + updated_at = CURRENT_TIMESTAMP + WHERE path = ? + """, + values + (absolute,), + ) + script_id = row[0] + else: + cursor = conn.execute( + """ + INSERT INTO scripts (root_id, path, name, extension, language, + size, mtime, hash, line_count) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + (self.root_id, absolute) + values, + ) + script_id = cursor.lastrowid + + self._reindex(conn, script_id, absolute, path_obj.name) + conn.commit() + logger.debug("Watch: indexed %s", absolute) + + def _reindex(self, conn: sqlite3.Connection, script_id: int, path: str, name: str): + """ + Keep the full-text index in step with the file. + + Without this, watch mode silently left FTS results pointing at stale + content until somebody triggered a manual rebuild. + """ + indexing = conn.execute( + "SELECT enable_content_indexing FROM folder_roots WHERE id = ?", + (self.root_id,), + ).fetchone() + content = "" + if indexing and indexing[0]: + try: + with open(path, "r", encoding="utf-8", errors="ignore") as fh: + content = fh.read(100_000) + except OSError: + content = "" + + notes_row = conn.execute( + "SELECT GROUP_CONCAT(content, ' ') FROM script_notes WHERE script_id = ?", + (script_id,), + ).fetchone() + notes = notes_row[0] if notes_row and notes_row[0] else "" + + conn.execute("DELETE FROM scripts_fts WHERE script_id = ?", (script_id,)) + conn.execute( + "INSERT INTO scripts_fts (script_id, name, path, content, notes) " + "VALUES (?, ?, ?, ?, ?)", + (script_id, name, path, content, notes), + ) + + def _mark_missing(self, conn: sqlite3.Connection, file_path: str): + absolute = str(Path(file_path).absolute()) + conn.execute( + "UPDATE scripts SET missing_flag = 1, updated_at = CURRENT_TIMESTAMP " + "WHERE path = ?", + (absolute,), + ) + conn.execute( + "DELETE FROM scripts_fts WHERE script_id IN " + "(SELECT id FROM scripts WHERE path = ?)", + (absolute,), + ) + conn.commit() + logger.debug("Watch: marked missing %s", absolute) class WatchManager: """Manages filesystem watchers for folder roots""" - + def __init__(self, db_path: str): self.db_path = db_path - self.observers: Dict[int, Observer] = {} - + self.observers: Dict[int, Tuple[Observer, _RootWorker, "queue.Queue"]] = {} + async def start_watching(self, root_id: int, root_path: str, recursive: bool, - include_patterns: str, exclude_patterns: str, max_file_size: int): + include_patterns: Optional[str], + exclude_patterns: Optional[str], + max_file_size: int): """Start watching a folder root""" - # Stop existing watcher if any await self.stop_watching(root_id) - - # Create handler + + work_queue: "queue.Queue" = queue.Queue(maxsize=MAX_QUEUE_SIZE) + worker = _RootWorker(root_id, self.db_path, work_queue, max_file_size) + worker.start() + handler = ScriptFileHandler( - root_id, root_path, self.db_path, recursive, - include_patterns, exclude_patterns, max_file_size + root_id, root_path, work_queue, include_patterns, exclude_patterns ) - - # Create observer observer = Observer() - observer.schedule(handler, root_path, recursive=recursive) - observer.start() - - self.observers[root_id] = observer - print(f"Started watching folder root {root_id}: {root_path}") - + observer.schedule(handler, root_path, recursive=bool(recursive)) + # Observer.start() spins up a thread and can touch the filesystem. + await asyncio.to_thread(observer.start) + + self.observers[root_id] = (observer, worker, work_queue) + logger.info("Started watching folder root %s: %s", root_id, root_path) + async def stop_watching(self, root_id: int): """Stop watching a folder root""" - if root_id in self.observers: - observer = self.observers[root_id] + entry = self.observers.pop(root_id, None) + if not entry: + return + observer, worker, work_queue = entry + + def _shutdown(): observer.stop() observer.join(timeout=2) - del self.observers[root_id] - print(f"Stopped watching folder root {root_id}") - + try: + # Non-blocking: a full queue whose worker has already died would + # otherwise block here forever, and this runs on a thread the + # event loop is waiting on. + work_queue.put_nowait(_STOP) + except queue.Full: + logger.warning( + "Watch queue for root %s is full; draining it to stop the worker", root_id + ) + while True: + try: + work_queue.get_nowait() + work_queue.task_done() + except queue.Empty: + break + try: + work_queue.put_nowait(_STOP) + except queue.Full: + pass + worker.join(timeout=5) + + # Joining threads is blocking; keep it off the event loop so stopping a + # watcher cannot freeze every other request. + await asyncio.to_thread(_shutdown) + logger.info("Stopped watching folder root %s", root_id) + async def stop_all(self): """Stop all watchers""" for root_id in list(self.observers.keys()): await self.stop_watching(root_id) - + def is_watching(self, root_id: int) -> bool: """Check if a folder root is being watched""" return root_id in self.observers - + def get_watching_roots(self) -> list: """Get list of root IDs currently being watched""" return list(self.observers.keys()) + def pending_events(self, root_id: int) -> int: + """How many filesystem events are still queued for a root.""" + entry = self.observers.get(root_id) + return entry[2].qsize() if entry else 0 + # Global watch manager instance -watch_manager = None +watch_manager: Optional[WatchManager] = None def get_watch_manager(db_path: str) -> WatchManager: @@ -240,4 +328,8 @@ def get_watch_manager(db_path: str) -> WatchManager: global watch_manager if watch_manager is None: watch_manager = WatchManager(db_path) + else: + # The test suite and the CLI point DB_PATH at different databases; keep + # the singleton aimed at whichever one the caller is using. + watch_manager.db_path = db_path return watch_manager diff --git a/backend/main.py b/backend/main.py index 2f301a6..96ccd69 100644 --- a/backend/main.py +++ b/backend/main.py @@ -4,47 +4,67 @@ """ import os from contextlib import asynccontextmanager + from fastapi import FastAPI, Request from fastapi.middleware.cors import CORSMiddleware +from fastapi.middleware.gzip import GZipMiddleware from starlette.middleware.base import BaseHTTPMiddleware + +from app.db import database as db_module from app.db.database import init_db -from app.routes import folder_roots, scripts, tags, notes, search, folders, saved_searches, fts, watch, similarity, attachments, auth, setup, monitors, schedules, notifications +from app.routes import ( + attachments, auth, folder_roots, folders, fts, monitors, notes, + notifications, saved_searches, schedules, scripts, search, setup, + similarity, tags, watch, +) +from app.services.scheduler import SchedulerHandle, scheduler_enabled from app.utils.logging_config import setup_logging, get_logger # Setup logging setup_logging() logger = get_logger(__name__) +scheduler_handle = SchedulerHandle() + class SecurityHeadersMiddleware(BaseHTTPMiddleware): """Add security headers to all responses""" + async def dispatch(self, request: Request, call_next): response = await call_next(request) - # Security headers response.headers["X-Content-Type-Options"] = "nosniff" response.headers["X-Frame-Options"] = "DENY" response.headers["X-XSS-Protection"] = "1; mode=block" - response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains" response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin" + response.headers["Permissions-Policy"] = "geolocation=(), microphone=(), camera=()" + # HSTS is only meaningful over TLS, and sending it over plain HTTP can + # lock users out of a local http:// deployment for a year. + if request.url.scheme == "https": + response.headers["Strict-Transport-Security"] = ( + "max-age=31536000; includeSubDomains" + ) return response @asynccontextmanager async def lifespan(app: FastAPI): """Application lifespan events""" - # Startup logger.info("Starting Script Manager API...") await init_db() - # Initialize auth system - from app.db.database import DB_PATH - import aiosqlite - from app.services.auth import init_default_roles, init_default_admin + from app.services.auth import init_default_admin, init_default_roles, sync_role_permissions - async with aiosqlite.connect(DB_PATH) as db: + async with db_module.connection() as db: await init_default_roles(db) + await sync_role_permissions(db) - # Only create the fallback admin/admin account for existing installations + # Repair databases written by releases that ran without foreign keys + # enabled, which left children behind after a parent was deleted. + removed = await db_module.cleanup_orphans(db) + if removed: + logger.warning("Removed orphaned rows left by earlier deletes: %s", removed) + + # Only create the fallback admin account for existing installations # that already completed setup before the wizard was introduced. # Fresh installs must go through the wizard to create their admin account. async with db.execute( @@ -54,31 +74,51 @@ async def lifespan(app: FastAPI): if row and row[0] == "true": await init_default_admin(db) + if scheduler_enabled(): + scheduler_handle.start(db_module.DB_PATH) + else: + logger.info("Background scheduler disabled (ENABLE_SCHEDULER=false)") + logger.info("Script Manager API started successfully") yield - # Shutdown logger.info("Shutting down Script Manager API...") + await scheduler_handle.stop() + + # Release filesystem watchers so the process can exit cleanly. + try: + from app.services.watch import get_watch_manager + + await get_watch_manager(db_module.DB_PATH).stop_all() + except Exception as exc: # noqa: BLE001 - shutdown must not raise + logger.warning("Could not stop watch manager cleanly: %s", exc) # Get allowed origins from environment variable -ALLOWED_ORIGINS = os.getenv( - "ALLOWED_ORIGINS", - "http://localhost:3000,http://localhost:5173" -).split(",") +ALLOWED_ORIGINS = [ + origin.strip() + for origin in os.getenv( + "ALLOWED_ORIGINS", + "http://localhost:3000,http://localhost:5173", + ).split(",") + if origin.strip() +] app = FastAPI( title="Script Manager API", description="API for managing script file collections", - version="1.0.0", - lifespan=lifespan + version="1.1.0", + lifespan=lifespan, ) -# CORS middleware for frontend +# CORS middleware for frontend. +# Credentials cannot be combined with a wildcard origin, and doing so would let +# any site read authenticated responses, so "*" downgrades to credential-less. +allow_credentials = "*" not in ALLOWED_ORIGINS app.add_middleware( CORSMiddleware, allow_origins=ALLOWED_ORIGINS, - allow_credentials=True, + allow_credentials=allow_credentials, allow_methods=["*"], allow_headers=["*"], ) @@ -86,6 +126,9 @@ async def lifespan(app: FastAPI): # Security headers middleware app.add_middleware(SecurityHeadersMiddleware) +# Script content and long result pages compress very well. +app.add_middleware(GZipMiddleware, minimum_size=1024) + # Include routers app.include_router(auth.router, prefix="/api/auth", tags=["Authentication"]) app.include_router(setup.router, prefix="/api/setup", tags=["Setup Wizard"]) @@ -104,21 +147,40 @@ async def lifespan(app: FastAPI): app.include_router(schedules.router, prefix="/api/schedules", tags=["Schedules"]) app.include_router(notifications.router, prefix="/api/notifications", tags=["Notifications"]) + @app.get("/") async def root(): """Root endpoint""" return { "message": "Script Manager API", - "version": "1.0.0", - "docs": "/docs" + "version": app.version, + "docs": "/docs", } + @app.get("/health") async def health_check(): - """Health check endpoint""" - return {"status": "healthy"} + """Health check endpoint used by Docker and load balancers.""" + healthy = True + detail = "ok" + try: + async with db_module.connection() as db: + await db.execute("SELECT 1") + except Exception as exc: # noqa: BLE001 - the point is to report, not raise + healthy = False + detail = f"database unavailable: {exc}" + + return { + "status": "healthy" if healthy else "degraded", + "database": detail, + "scheduler": "running" if scheduler_enabled() else "disabled", + "version": app.version, + } + if __name__ == "__main__": import uvicorn + port = int(os.getenv("API_PORT", "8000")) - uvicorn.run("main:app", host="0.0.0.0", port=port, reload=True) + reload = os.getenv("API_RELOAD", "false").strip().lower() in ("1", "true", "yes", "on") + uvicorn.run("main:app", host="0.0.0.0", port=port, reload=reload) diff --git a/backend/requirements-dev.txt b/backend/requirements-dev.txt new file mode 100644 index 0000000..192e3ca --- /dev/null +++ b/backend/requirements-dev.txt @@ -0,0 +1,3 @@ +-r requirements.txt +pytest==8.3.3 +pytest-asyncio==0.24.0 diff --git a/backend/requirements.txt b/backend/requirements.txt index b95e88b..0f1dc87 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -1,6 +1,7 @@ fastapi==0.115.0 uvicorn==0.27.0 pydantic==2.5.3 +email-validator==2.1.1 sqlalchemy==2.0.25 aiosqlite==0.19.0 python-multipart==0.0.22 @@ -12,3 +13,4 @@ passlib[argon2]==1.7.4 argon2-cffi==23.1.0 python-jose[cryptography]==3.3.0 bleach==6.1.0 +httpx==0.27.2 diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index e8f3182..4f289b6 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -12,6 +12,10 @@ import sys sys.path.insert(0, os.path.dirname(os.path.dirname(__file__))) +# The background scheduler would fire real subprocesses against the per-test +# database, so it stays off for the whole suite. +os.environ.setdefault("ENABLE_SCHEDULER", "false") + import app.db.database as _db_mod # Route modules that imported DB_PATH using `from app.db.database import DB_PATH` # hold a local string reference (not a module attribute lookup), so patching @@ -25,6 +29,9 @@ # All modules that carry their own DB_PATH reference alongside _db_mod _DB_PATH_MODULES = (_sched_mod, _fr_mod, _watch_mod) +ADMIN_USERNAME = "testadmin" +ADMIN_PASSWORD = "TestPass123!" + @pytest_asyncio.fixture async def app(): @@ -42,15 +49,18 @@ async def app(): await _db_mod.init_db() # Seed default roles into the test database - from app.services.auth import init_default_roles + from app.services.auth import init_default_roles, sync_role_permissions async with aiosqlite.connect(db_path) as db: db.row_factory = aiosqlite.Row + await _db_mod.apply_connection_pragmas(db) await init_default_roles(db) + await sync_role_permissions(db) # Override get_db to always use our temp database async def _override_get_db(): async with aiosqlite.connect(db_path) as db: db.row_factory = aiosqlite.Row + await _db_mod.apply_connection_pragmas(db) yield db # Snapshot and restore only the specific override we add so we don't @@ -75,37 +85,86 @@ async def _override_get_db(): @pytest_asyncio.fixture -async def client(app): - """Async HTTP client bound to the test app.""" +async def anon_client(app): + """ + Async HTTP client with no credentials. + + Use this for endpoints that must work before anyone can sign in (the setup + wizard) and for asserting that protected endpoints reject anonymous callers. + """ async with AsyncClient( transport=ASGITransport(app=app), base_url="http://test" ) as ac: yield ac -@pytest_asyncio.fixture -async def auth_client(client): - """Client with a valid admin JWT token pre-attached.""" - # Complete setup first so an admin account exists - resp = await client.post( +async def _bootstrap_admin(ac: AsyncClient) -> str: + """Complete setup so an admin account exists, then return its access token.""" + resp = await ac.post( "/api/setup/complete", json={ "mode": "development", "database": {"type": "sqlite"}, "admin": { - "username": "testadmin", + "username": ADMIN_USERNAME, "email": "testadmin@example.com", - "password": "TestPass123!", + "password": ADMIN_PASSWORD, }, }, ) assert resp.status_code in (200, 201, 409), resp.text - login = await client.post( + login = await ac.post( "/api/auth/login", - data={"username": "testadmin", "password": "TestPass123!"}, + data={"username": ADMIN_USERNAME, "password": ADMIN_PASSWORD}, ) assert login.status_code == 200, login.text - token = login.json()["access_token"] - client.headers.update({"Authorization": f"Bearer {token}"}) + return login.json()["access_token"] + + +@pytest_asyncio.fixture +async def client(app): + """ + Default client: authenticated as an administrator. + + The API enforces RBAC on every resource, so the common case for a test is + an authenticated caller. Tests that need an anonymous caller use + `anon_client`. + """ + async with AsyncClient( + transport=ASGITransport(app=app), base_url="http://test" + ) as ac: + token = await _bootstrap_admin(ac) + ac.headers.update({"Authorization": f"Bearer {token}"}) + yield ac + + +@pytest_asyncio.fixture +async def auth_client(client): + """Alias kept for tests that explicitly ask for an authenticated client.""" return client + + +@pytest_asyncio.fixture +async def viewer_client(app, client): + """A second client signed in as a read-only viewer, for RBAC assertions.""" + created = await client.post( + "/api/auth/register", + json={ + "username": "testviewer", + "email": "testviewer@example.com", + "password": "ViewerPass123!", + }, + ) + assert created.status_code in (200, 201), created.text + + async with AsyncClient( + transport=ASGITransport(app=app), base_url="http://test" + ) as ac: + login = await ac.post( + "/api/auth/login", + data={"username": "testviewer", "password": "ViewerPass123!"}, + ) + assert login.status_code == 200, login.text + ac.headers.update({"Authorization": f"Bearer {login.json()['access_token']}"}) + yield ac diff --git a/backend/tests/test_auth.py b/backend/tests/test_auth.py index 8f8d534..ba58afa 100644 --- a/backend/tests/test_auth.py +++ b/backend/tests/test_auth.py @@ -15,10 +15,10 @@ async def test_login_success(auth_client): @pytest.mark.asyncio -async def test_login_wrong_password(client): +async def test_login_wrong_password(anon_client): """Wrong credentials should return 401.""" # First complete setup so the user exists - await client.post( + await anon_client.post( "/api/setup/complete", json={ "mode": "development", @@ -30,7 +30,7 @@ async def test_login_wrong_password(client): }, }, ) - resp = await client.post( + resp = await anon_client.post( "/api/auth/login", data={"username": "admin", "password": "wrongpassword"}, ) @@ -38,9 +38,9 @@ async def test_login_wrong_password(client): @pytest.mark.asyncio -async def test_login_nonexistent_user(client): +async def test_login_nonexistent_user(anon_client): """Login for a non-existent user should return 401.""" - resp = await client.post( + resp = await anon_client.post( "/api/auth/login", data={"username": "nobody", "password": "pass"}, ) @@ -48,9 +48,9 @@ async def test_login_nonexistent_user(client): @pytest.mark.asyncio -async def test_me_requires_auth(client): +async def test_me_requires_auth(anon_client): """Accessing /me without a token should return 401.""" - resp = await client.get("/api/auth/me") + resp = await anon_client.get("/api/auth/me") assert resp.status_code == 401 diff --git a/backend/tests/test_cron.py b/backend/tests/test_cron.py new file mode 100644 index 0000000..36828eb --- /dev/null +++ b/backend/tests/test_cron.py @@ -0,0 +1,79 @@ +""" +Tests for the cron expression parser used by the scheduler. +""" +from datetime import datetime, timezone + +import pytest + +from app.services.cron import ( + CronError, describe, next_run_utc, validate_cron, validate_timezone, +) + +# A Sunday, so day-of-week edge cases are exercised. +BASE = datetime(2026, 9, 6, 13, 30, tzinfo=timezone.utc) + + +@pytest.mark.parametrize( + "expression,expected", + [ + ("0 * * * *", datetime(2026, 9, 6, 14, 0, tzinfo=timezone.utc)), + ("*/15 * * * *", datetime(2026, 9, 6, 13, 45, tzinfo=timezone.utc)), + ("30 2 * * 1-5", datetime(2026, 9, 7, 2, 30, tzinfo=timezone.utc)), + ("@daily", datetime(2026, 9, 7, 0, 0, tzinfo=timezone.utc)), + ("0 9 * * MON", datetime(2026, 9, 7, 9, 0, tzinfo=timezone.utc)), + # 2027 is not a leap year, so the next 29 February is in 2028. + ("0 0 29 2 *", datetime(2028, 2, 29, 0, 0, tzinfo=timezone.utc)), + ], +) +def test_next_run_utc(expression, expected): + assert next_run_utc(expression, "UTC", BASE) == expected + + +def test_next_run_respects_timezone(): + """Noon in New York is 16:00 UTC during daylight saving time.""" + assert next_run_utc("0 12 * * *", "America/New_York", BASE) == datetime( + 2026, 9, 6, 16, 0, tzinfo=timezone.utc + ) + + +def test_day_of_month_or_day_of_week_semantics(): + """When both day fields are restricted, cron fires when either matches.""" + schedule = validate_cron("0 0 1 * MON") + # 1 September 2026 is a Tuesday: matches on day-of-month alone. + assert schedule.matches(datetime(2026, 9, 1, 0, 0)) + # 7 September 2026 is a Monday: matches on day-of-week alone. + assert schedule.matches(datetime(2026, 9, 7, 0, 0)) + assert not schedule.matches(datetime(2026, 9, 8, 0, 0)) + + +@pytest.mark.parametrize( + "expression", + [ + "", + "* * *", + "* * * * * *", + "60 * * * *", + "* 24 * * *", + "* * * * 9", + "* * 0 * *", + "abc * * * *", + "*/0 * * * *", + "5-1 * * * *", + "* * * 13 *", + ], +) +def test_invalid_expressions_are_rejected(expression): + with pytest.raises(CronError): + validate_cron(expression) + + +def test_validate_timezone(): + assert validate_timezone(None) == "UTC" + assert validate_timezone("utc") == "UTC" + assert validate_timezone("Europe/Warsaw") == "Europe/Warsaw" + with pytest.raises(CronError): + validate_timezone("Mars/Olympus_Mons") + + +def test_describe_is_human_readable(): + assert "weekday" in describe("*/15 9-17 * * 1-5") diff --git a/backend/tests/test_data_integrity.py b/backend/tests/test_data_integrity.py new file mode 100644 index 0000000..74fc096 --- /dev/null +++ b/backend/tests/test_data_integrity.py @@ -0,0 +1,145 @@ +""" +Tests for data-layer behaviour that used to fail silently. +""" +import aiosqlite +import pytest + +import app.db.database as db_mod + + +@pytest.mark.asyncio +async def test_request_connection_enables_foreign_keys(app): + """ + Without `PRAGMA foreign_keys = ON` on the request connection every + ON DELETE CASCADE in the schema is a no-op, which orphaned scripts, + notes, tags, pings and executions. + """ + async with aiosqlite.connect(db_mod.DB_PATH) as db: + await db_mod.apply_connection_pragmas(db) + async with db.execute("PRAGMA foreign_keys") as cursor: + assert (await cursor.fetchone())[0] == 1 + + +@pytest.mark.asyncio +async def test_deleting_a_root_removes_its_scripts(client, tmp_path): + created = await client.post( + "/api/folder-roots/", json={"path": str(tmp_path), "name": "Cascade Root"} + ) + root_id = created.json()["id"] + + # Insert a script directly: scanning an empty directory would find none. + async with aiosqlite.connect(db_mod.DB_PATH) as db: + await db_mod.apply_connection_pragmas(db) + await db.execute( + "INSERT INTO scripts (root_id, path, name) VALUES (?, ?, ?)", + (root_id, str(tmp_path / "a.py"), "a.py"), + ) + await db.commit() + + listed = await client.get("/api/scripts/") + assert listed.json()["total"] == 1 + + assert (await client.delete(f"/api/folder-roots/{root_id}")).status_code == 200 + + listed = await client.get("/api/scripts/") + assert listed.json()["total"] == 0, "scripts survived their folder root" + + +@pytest.mark.asyncio +async def test_deleting_a_monitor_removes_its_pings(client): + created = await client.post( + "/api/monitors/", json={"name": "ping-cascade", "expected_interval_seconds": 60} + ) + monitor = created.json() + await client.post(f"/api/monitors/ping/{monitor['ping_key']}") + + assert (await client.delete(f"/api/monitors/{monitor['id']}")).status_code == 204 + + async with aiosqlite.connect(db_mod.DB_PATH) as db: + await db_mod.apply_connection_pragmas(db) + async with db.execute( + "SELECT COUNT(*) FROM monitor_pings WHERE monitor_id = ?", (monitor["id"],) + ) as cursor: + assert (await cursor.fetchone())[0] == 0 + + +@pytest.mark.asyncio +async def test_deleting_a_job_removes_its_executions(client): + created = await client.post( + "/api/schedules/", + json={"name": "exec-cascade", "command": "true", "cron_expression": "0 * * * *"}, + ) + job_id = created.json()["id"] + + async with aiosqlite.connect(db_mod.DB_PATH) as db: + await db_mod.apply_connection_pragmas(db) + await db.execute( + "INSERT INTO job_executions (job_id, started_at, status) " + "VALUES (?, CURRENT_TIMESTAMP, 'success')", + (job_id,), + ) + await db.commit() + + assert (await client.delete(f"/api/schedules/{job_id}")).status_code == 204 + + async with aiosqlite.connect(db_mod.DB_PATH) as db: + await db_mod.apply_connection_pragmas(db) + async with db.execute( + "SELECT COUNT(*) FROM job_executions WHERE job_id = ?", (job_id,) + ) as cursor: + assert (await cursor.fetchone())[0] == 0 + + +@pytest.mark.asyncio +async def test_cleanup_orphans_repairs_an_existing_database(app): + """Databases written before foreign keys were enabled still hold orphans.""" + async with aiosqlite.connect(db_mod.DB_PATH) as db: + db.row_factory = aiosqlite.Row + # Deliberately leave foreign keys OFF to reproduce the old behaviour. + cursor = await db.execute( + "INSERT INTO folder_roots (path, name) VALUES ('/orphan-root', 'Orphan')" + ) + root_id = cursor.lastrowid + await db.execute( + "INSERT INTO scripts (root_id, path, name) VALUES (?, '/orphan-root/a.py', 'a.py')", + (root_id,), + ) + await db.commit() + await db.execute("DELETE FROM folder_roots WHERE id = ?", (root_id,)) + await db.commit() + + async with db.execute("SELECT COUNT(*) FROM scripts") as cur: + assert (await cur.fetchone())[0] == 1, "expected an orphan to repair" + + removed = await db_mod.cleanup_orphans(db) + assert removed.get("scripts") == 1 + + async with db.execute("SELECT COUNT(*) FROM scripts") as cur: + assert (await cur.fetchone())[0] == 0 + + +@pytest.mark.asyncio +async def test_migration_adds_missing_columns(tmp_path, monkeypatch): + """A database from an older release must gain new columns, not stay stale.""" + db_path = str(tmp_path / "legacy.db") + async with aiosqlite.connect(db_path) as db: + # Minimal legacy shape: no next_run_at column. + await db.execute( + """ + CREATE TABLE schedule_jobs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, + cron_expression TEXT NOT NULL + ) + """ + ) + await db.commit() + + monkeypatch.setattr(db_mod, "DB_PATH", db_path) + await db_mod.init_db() + + async with aiosqlite.connect(db_path) as db: + async with db.execute("PRAGMA table_info(schedule_jobs)") as cursor: + columns = {row[1] for row in await cursor.fetchall()} + assert "next_run_at" in columns + assert "notify_channel_ids" in columns diff --git a/backend/tests/test_notifications.py b/backend/tests/test_notifications.py index 6508537..5c428c3 100644 --- a/backend/tests/test_notifications.py +++ b/backend/tests/test_notifications.py @@ -19,7 +19,12 @@ async def test_create_channel(client): """Creating a valid channel should succeed.""" resp = await client.post( "/api/notifications/channels/", - json={"name": "My Slack", "type": "slack", "config": {"token": "xoxb-test"}, "enabled": True}, + json={ + "name": "My Slack", + "type": "slack", + "config": {"webhook_url": "https://hooks.slack.com/services/T000/B000/XXX"}, + "enabled": True, + }, ) assert resp.status_code == 201 data = resp.json() @@ -52,7 +57,11 @@ async def test_get_channel(client): """Getting a channel by ID should return its data.""" create = await client.post( "/api/notifications/channels/", - json={"name": "Discord Alert", "type": "discord", "config": {}}, + json={ + "name": "Discord Alert", + "type": "discord", + "config": {"webhook_url": "https://discord.com/api/webhooks/1/abc"}, + }, ) channel_id = create.json()["id"] resp = await client.get(f"/api/notifications/channels/{channel_id}") @@ -72,7 +81,11 @@ async def test_update_channel(client): """Updating a channel should persist changes.""" create = await client.post( "/api/notifications/channels/", - json={"name": "Old Name", "type": "email", "config": {}}, + json={ + "name": "Old Name", + "type": "email", + "config": {"smtp_host": "smtp.example.com", "to": "ops@example.com"}, + }, ) channel_id = create.json()["id"] resp = await client.put( @@ -88,7 +101,11 @@ async def test_delete_channel(client): """Deleting a channel should return 204 and remove it.""" create = await client.post( "/api/notifications/channels/", - json={"name": "To Delete", "type": "webhook", "config": {}}, + json={ + "name": "To Delete", + "type": "webhook", + "config": {"url": "https://example.com/hook"}, + }, ) channel_id = create.json()["id"] resp = await client.delete(f"/api/notifications/channels/{channel_id}") @@ -98,30 +115,96 @@ async def test_delete_channel(client): @pytest.mark.asyncio -async def test_test_channel_requires_auth(client): +async def test_test_channel_requires_auth(client, anon_client): """Testing a channel should require authentication.""" create = await client.post( "/api/notifications/channels/", - json={"name": "Test Chan", "type": "slack", "config": {}}, + json={ + "name": "Test Chan", + "type": "slack", + "config": {"webhook_url": "https://hooks.slack.com/services/T/B/X"}, + }, ) channel_id = create.json()["id"] - resp = await client.post(f"/api/notifications/channels/{channel_id}/test") + resp = await anon_client.post(f"/api/notifications/channels/{channel_id}/test") assert resp.status_code == 401 @pytest.mark.asyncio async def test_test_channel_authenticated(auth_client): - """Authenticated channel test should succeed and redact secrets.""" + """Authenticated channel test should report an outcome and redact secrets.""" create = await auth_client.post( "/api/notifications/channels/", - json={"name": "Redact Test", "type": "slack", "config": {"token": "secret-value"}}, + json={ + "name": "Redact Test", + "type": "slack", + "config": { + # Unroutable host: delivery fails fast without touching the network. + "webhook_url": "http://127.0.0.1:1/slack", + "token": "secret-value", + }, + }, ) channel_id = create.json()["id"] resp = await auth_client.post(f"/api/notifications/channels/{channel_id}/test") assert resp.status_code == 200 data = resp.json() assert "message" in data + # A delivery failure is reported in-band, not as an HTTP error. + assert data["success"] is False assert data["channel"]["config"]["token"] == "***" + assert data["channel"]["config"]["webhook_url"] == "***" + + +@pytest.mark.asyncio +async def test_channel_secrets_never_returned(client): + """Secret config values must be redacted in list and get responses.""" + await client.post( + "/api/notifications/channels/", + json={ + "name": "Secret Slack", + "type": "slack", + "config": {"webhook_url": "https://hooks.slack.com/services/TOP/SECRET/VALUE"}, + }, + ) + listed = await client.get("/api/notifications/channels/") + assert listed.status_code == 200 + assert listed.json()[0]["config"]["webhook_url"] == "***" + + +@pytest.mark.asyncio +async def test_create_channel_rejects_unusable_config(client): + """A channel that could never deliver should be rejected at creation.""" + resp = await client.post( + "/api/notifications/channels/", + json={"name": "No Webhook", "type": "slack", "config": {}}, + ) + assert resp.status_code == 400 + assert "webhook_url" in resp.json()["detail"] + + +@pytest.mark.asyncio +async def test_update_channel_keeps_redacted_secret(client): + """Re-submitting the redaction placeholder must not wipe the stored secret.""" + real_url = "https://hooks.slack.com/services/KEEP/THIS/VALUE" + create = await client.post( + "/api/notifications/channels/", + json={"name": "Keep Secret", "type": "slack", "config": {"webhook_url": real_url}}, + ) + channel_id = create.json()["id"] + + # This is exactly what the UI round-trips after a redacted read. + resp = await client.put( + f"/api/notifications/channels/{channel_id}", + json={"name": "Renamed", "config": {"webhook_url": "***"}}, + ) + assert resp.status_code == 200 + assert resp.json()["name"] == "Renamed" + + test_resp = await client.post(f"/api/notifications/channels/{channel_id}/test") + # Delivery is attempted against the preserved URL rather than failing + # validation, which is what would happen if the secret had been erased. + assert "webhook_url" not in (test_resp.json().get("message") or "") # ── Incidents ──────────────────────────────────────────────────────────────── diff --git a/backend/tests/test_rbac.py b/backend/tests/test_rbac.py new file mode 100644 index 0000000..4f6417e --- /dev/null +++ b/backend/tests/test_rbac.py @@ -0,0 +1,149 @@ +""" +Tests that the RBAC model is actually enforced by the API. + +The permission model existed for a long time without any endpoint consulting +it, so these tests pin down that anonymous callers are refused and that a +read-only viewer cannot mutate anything. +""" +import pytest + +# (method, path) pairs that must never be reachable without credentials. +PROTECTED_READS = [ + "/api/scripts/", + "/api/tags/", + "/api/folder-roots/", + "/api/search/stats", + "/api/monitors/", + "/api/schedules/", + "/api/notifications/channels/", + "/api/notifications/incidents/", + "/api/auth/users", + "/api/auth/roles", +] + + +@pytest.mark.asyncio +@pytest.mark.parametrize("path", PROTECTED_READS) +async def test_anonymous_reads_are_refused(anon_client, path): + resp = await anon_client.get(path) + assert resp.status_code == 401, f"{path} was reachable anonymously" + + +@pytest.mark.asyncio +async def test_anonymous_writes_are_refused(anon_client): + resp = await anon_client.post("/api/tags/", json={"name": "sneaky"}) + assert resp.status_code == 401 + + +@pytest.mark.asyncio +async def test_invalid_token_is_refused(anon_client): + anon_client.headers.update({"Authorization": "Bearer not-a-real-token"}) + resp = await anon_client.get("/api/tags/") + assert resp.status_code == 401 + + +@pytest.mark.asyncio +async def test_viewer_can_read(viewer_client): + resp = await viewer_client.get("/api/tags/") + assert resp.status_code == 200 + + +@pytest.mark.asyncio +async def test_viewer_cannot_create_tags(viewer_client): + resp = await viewer_client.post("/api/tags/", json={"name": "viewer-tag"}) + assert resp.status_code == 403 + assert "tags.create" in resp.json()["detail"] + + +@pytest.mark.asyncio +async def test_viewer_cannot_manage_users(viewer_client): + resp = await viewer_client.get("/api/auth/users") + assert resp.status_code == 403 + + +@pytest.mark.asyncio +async def test_viewer_cannot_trigger_jobs(viewer_client, client): + created = await client.post( + "/api/schedules/", + json={"name": "viewer-blocked", "command": "echo hi", "cron_expression": "0 * * * *"}, + ) + job_id = created.json()["id"] + resp = await viewer_client.post(f"/api/schedules/{job_id}/trigger") + assert resp.status_code == 403 + + +@pytest.mark.asyncio +async def test_monitor_ping_stays_public(anon_client, client): + """A cron job holds only the ping key, so the ping endpoint must be open.""" + created = await client.post( + "/api/monitors/", json={"name": "public-ping", "expected_interval_seconds": 60} + ) + ping_key = created.json()["ping_key"] + resp = await anon_client.post(f"/api/monitors/ping/{ping_key}") + assert resp.status_code == 200 + + +@pytest.mark.asyncio +async def test_health_and_setup_status_stay_public(anon_client): + assert (await anon_client.get("/health")).status_code == 200 + assert (await anon_client.get("/api/setup/status")).status_code == 200 + assert (await anon_client.get("/api/auth/config")).status_code == 200 + + +@pytest.mark.asyncio +async def test_self_registration_disabled_by_default(anon_client): + resp = await anon_client.post( + "/api/auth/register", + json={"username": "stranger", "email": "s@example.com", "password": "Password123"}, + ) + assert resp.status_code == 403 + + +@pytest.mark.asyncio +async def test_last_admin_cannot_be_deleted_or_demoted(client): + users = (await client.get("/api/auth/users")).json() + admin = next(u for u in users if u["username"] == "testadmin") + + demote = await client.put( + f"/api/auth/users/{admin['id']}", json={"is_active": False} + ) + assert demote.status_code == 400 + assert "last administrator" in demote.json()["detail"] + + deleted = await client.delete(f"/api/auth/users/{admin['id']}") + # Deleting yourself is refused before the last-admin check even applies. + assert deleted.status_code == 400 + + +@pytest.mark.asyncio +async def test_change_password_uses_request_body(client): + """Credentials must not have to travel in the query string.""" + resp = await client.put( + "/api/auth/change-password", + json={"old_password": "TestPass123!", "new_password": "BrandNewPass9"}, + ) + assert resp.status_code == 200 + + relogin = await client.post( + "/api/auth/login", + data={"username": "testadmin", "password": "BrandNewPass9"}, + ) + assert relogin.status_code == 200 + + +@pytest.mark.asyncio +async def test_change_password_rejects_weak_password(client): + resp = await client.put( + "/api/auth/change-password", + json={"old_password": "TestPass123!", "new_password": "short"}, + ) + assert resp.status_code == 422 # below the schema's minimum length + + +@pytest.mark.asyncio +async def test_change_password_rejects_wrong_old_password(client): + resp = await client.put( + "/api/auth/change-password", + json={"old_password": "not-the-password", "new_password": "BrandNewPass9"}, + ) + assert resp.status_code == 400 diff --git a/backend/tests/test_scheduling.py b/backend/tests/test_scheduling.py new file mode 100644 index 0000000..f38866f --- /dev/null +++ b/backend/tests/test_scheduling.py @@ -0,0 +1,411 @@ +""" +Tests for scheduling behaviour: validation, next-run computation, overlap +prevention and the background evaluator. +""" +import asyncio +from datetime import datetime, timedelta, timezone + +import aiosqlite +import pytest + +import app.db.database as db_mod +from app.services import scheduler + + +async def _create_job(client, **overrides): + payload = { + "name": overrides.pop("name", "job"), + "command": overrides.pop("command", "true"), + "cron_expression": overrides.pop("cron_expression", "0 * * * *"), + **overrides, + } + resp = await client.post("/api/schedules/", json=payload) + assert resp.status_code == 201, resp.text + return resp.json() + + +# ── Validation ──────────────────────────────────────────────────────────────── + +@pytest.mark.asyncio +async def test_invalid_cron_is_rejected(client): + """A garbage schedule used to be accepted with 201 and then never run.""" + resp = await client.post( + "/api/schedules/", + json={"name": "bad-cron", "command": "true", "cron_expression": "every tuesday"}, + ) + assert resp.status_code == 400 + assert "cron" in resp.json()["detail"].lower() + + +@pytest.mark.asyncio +async def test_invalid_timezone_is_rejected(client): + resp = await client.post( + "/api/schedules/", + json={ + "name": "bad-tz", "command": "true", + "cron_expression": "0 * * * *", "timezone": "Mars/Olympus_Mons", + }, + ) + assert resp.status_code == 400 + + +@pytest.mark.asyncio +async def test_zero_timeout_is_rejected(client): + """timeout_seconds=0 silently meant 'no timeout' rather than 'fail fast'.""" + resp = await client.post( + "/api/schedules/", + json={"name": "zero", "command": "true", "cron_expression": "0 * * * *", "timeout_seconds": 0}, + ) + assert resp.status_code == 422 + + +@pytest.mark.asyncio +async def test_unknown_script_id_reports_the_real_reason(client): + """This used to surface as the IntegrityError handler's 'name already exists'.""" + resp = await client.post( + "/api/schedules/", + json={"name": "ghost", "script_id": 9999, "cron_expression": "0 * * * *"}, + ) + assert resp.status_code == 404 + assert "Script" in resp.json()["detail"] + + +# ── next_run_at ─────────────────────────────────────────────────────────────── + +@pytest.mark.asyncio +async def test_next_run_is_computed_on_create(client): + job = await _create_job(client, name="scheduled", cron_expression="*/5 * * * *") + assert job["next_run_at"] is not None + when = scheduler.parse_sqlite_timestamp(job["next_run_at"]) + assert when > datetime.now(timezone.utc) + + +@pytest.mark.asyncio +async def test_disabling_clears_next_run(client): + job = await _create_job(client, name="toggle") + assert (await client.post(f"/api/schedules/{job['id']}/disable")).status_code == 200 + assert (await client.get(f"/api/schedules/{job['id']}")).json()["next_run_at"] is None + + enabled = await client.post(f"/api/schedules/{job['id']}/enable") + assert enabled.status_code == 200 + assert enabled.json()["next_run_at"] is not None + + +@pytest.mark.asyncio +async def test_cron_preview(client): + resp = await client.get( + "/api/schedules/preview/cron", + params={"expression": "0 9 * * 1-5", "timezone": "UTC", "count": 3}, + ) + assert resp.status_code == 200 + data = resp.json() + assert len(data["next_runs"]) == 3 + assert data["next_runs"] == sorted(data["next_runs"]) + + +# ── Overlap prevention ──────────────────────────────────────────────────────── + +@pytest.mark.asyncio +async def test_overlap_prevention_refuses_a_second_run(client): + job = await _create_job(client, name="overlap", prevent_overlap=True) + + async with aiosqlite.connect(db_mod.DB_PATH) as db: + await db_mod.apply_connection_pragmas(db) + await db.execute( + "INSERT INTO job_executions (job_id, started_at, status) " + "VALUES (?, CURRENT_TIMESTAMP, 'running')", + (job["id"],), + ) + await db.commit() + + resp = await client.post(f"/api/schedules/{job['id']}/trigger") + assert resp.status_code == 409 + + +@pytest.mark.asyncio +async def test_only_one_guarded_execution_can_run(app): + """ + The database enforces it, not just the application check. + + Overlap prevention was a check-then-act race: two concurrent triggers could + both observe no running execution and both insert one. + """ + async with aiosqlite.connect(db_mod.DB_PATH) as db: + await db_mod.apply_connection_pragmas(db) + await db.execute( + "INSERT INTO schedule_jobs (name, command, cron_expression) VALUES ('race', 'true', '0 * * * *')" + ) + await db.execute( + "INSERT INTO job_executions (job_id, started_at, status, overlap_key) " + "VALUES (1, CURRENT_TIMESTAMP, 'running', 1)" + ) + await db.commit() + + with pytest.raises(aiosqlite.IntegrityError): + await db.execute( + "INSERT INTO job_executions (job_id, started_at, status, overlap_key) " + "VALUES (1, CURRENT_TIMESTAMP, 'running', 1)" + ) + await db.commit() + + +@pytest.mark.asyncio +async def test_unguarded_jobs_may_run_concurrently(app): + """prevent_overlap = false must stay usable, not be blocked by the guard.""" + async with aiosqlite.connect(db_mod.DB_PATH) as db: + await db_mod.apply_connection_pragmas(db) + await db.execute( + "INSERT INTO schedule_jobs (name, command, cron_expression, prevent_overlap) " + "VALUES ('parallel', 'true', '0 * * * *', 0)" + ) + # overlap_key is NULL for these, and NULLs never collide. + for _ in range(3): + await db.execute( + "INSERT INTO job_executions (job_id, started_at, status, overlap_key) " + "VALUES (1, CURRENT_TIMESTAMP, 'running', NULL)" + ) + await db.commit() + + async with db.execute( + "SELECT COUNT(*) FROM job_executions WHERE status = 'running'" + ) as cursor: + assert (await cursor.fetchone())[0] == 3 + + +@pytest.mark.asyncio +async def test_trigger_allows_overlap_when_disabled(client): + """The API must honour prevent_overlap = false too.""" + job = await _create_job(client, name="no-guard", command="sleep 0", prevent_overlap=False) + + async with aiosqlite.connect(db_mod.DB_PATH) as db: + await db_mod.apply_connection_pragmas(db) + await db.execute( + "INSERT INTO job_executions (job_id, started_at, status, overlap_key) " + "VALUES (?, CURRENT_TIMESTAMP, 'running', NULL)", + (job["id"],), + ) + await db.commit() + + resp = await client.post(f"/api/schedules/{job['id']}/trigger") + assert resp.status_code == 200, resp.text + + +# ── Execution ───────────────────────────────────────────────────────────────── + +@pytest.mark.asyncio +async def test_job_execution_captures_output(app): + async with aiosqlite.connect(db_mod.DB_PATH) as db: + await db_mod.apply_connection_pragmas(db) + await db.execute( + "INSERT INTO schedule_jobs (name, command, cron_expression) " + "VALUES ('echo', 'echo hello', '0 * * * *')" + ) + cursor = await db.execute( + "INSERT INTO job_executions (job_id, started_at, status) " + "VALUES (1, CURRENT_TIMESTAMP, 'running')" + ) + execution_id = cursor.lastrowid + await db.commit() + + await scheduler.run_job_execution( + execution_id=execution_id, job_id=1, command="echo hello", + timeout=30, max_retries=0, retry_delay=1, db_path=db_mod.DB_PATH, + ) + + async with aiosqlite.connect(db_mod.DB_PATH) as db: + db.row_factory = aiosqlite.Row + async with db.execute( + "SELECT * FROM job_executions WHERE id = ?", (execution_id,) + ) as cursor: + row = await cursor.fetchone() + + assert row["status"] == "success" + assert row["exit_code"] == 0 + assert "hello" in row["stdout"] + + +@pytest.mark.asyncio +async def test_failed_job_raises_an_incident(app): + """A failing job used to leave no trace beyond its execution row.""" + async with aiosqlite.connect(db_mod.DB_PATH) as db: + await db_mod.apply_connection_pragmas(db) + await db.execute( + "INSERT INTO schedule_jobs (name, command, cron_expression) " + "VALUES ('boom', 'exit 3', '0 * * * *')" + ) + cursor = await db.execute( + "INSERT INTO job_executions (job_id, started_at, status) " + "VALUES (1, CURRENT_TIMESTAMP, 'running')" + ) + execution_id = cursor.lastrowid + await db.commit() + + await scheduler.run_job_execution( + execution_id=execution_id, job_id=1, command="exit 3", + timeout=30, max_retries=0, retry_delay=1, db_path=db_mod.DB_PATH, + ) + + async with aiosqlite.connect(db_mod.DB_PATH) as db: + db.row_factory = aiosqlite.Row + async with db.execute( + "SELECT * FROM incidents WHERE source_type = 'schedule'" + ) as cursor: + incidents = await cursor.fetchall() + + assert len(incidents) == 1 + assert "boom" in incidents[0]["title"] + + +@pytest.mark.asyncio +async def test_timeout_kills_the_job(app): + async with aiosqlite.connect(db_mod.DB_PATH) as db: + await db_mod.apply_connection_pragmas(db) + await db.execute( + "INSERT INTO schedule_jobs (name, command, cron_expression) " + "VALUES ('slow', 'sleep 30', '0 * * * *')" + ) + cursor = await db.execute( + "INSERT INTO job_executions (job_id, started_at, status) " + "VALUES (1, CURRENT_TIMESTAMP, 'running')" + ) + execution_id = cursor.lastrowid + await db.commit() + + await asyncio.wait_for( + scheduler.run_job_execution( + execution_id=execution_id, job_id=1, command="sleep 30", + timeout=1, max_retries=0, retry_delay=1, db_path=db_mod.DB_PATH, + ), + # Generous, but far below the 30s the command asked for: the point is + # that the killed process's pipes no longer block the drain forever. + timeout=20, + ) + + async with aiosqlite.connect(db_mod.DB_PATH) as db: + db.row_factory = aiosqlite.Row + async with db.execute( + "SELECT * FROM job_executions WHERE id = ?", (execution_id,) + ) as cursor: + row = await cursor.fetchone() + + assert row["status"] == "timeout" + assert "TIMEOUT" in row["stderr"] + + +@pytest.mark.asyncio +async def test_stale_running_executions_are_reaped(app): + """A restart mid-run used to deadlock overlap-protected jobs forever.""" + async with aiosqlite.connect(db_mod.DB_PATH) as db: + await db_mod.apply_connection_pragmas(db) + await db.execute( + "INSERT INTO schedule_jobs (name, command, cron_expression) " + "VALUES ('stale', 'true', '0 * * * *')" + ) + old = (datetime.now(timezone.utc) - timedelta(days=2)).isoformat() + await db.execute( + "INSERT INTO job_executions (job_id, started_at, status) VALUES (1, ?, 'running')", + (old,), + ) + await db.commit() + + assert await scheduler.reap_stale_executions(db_mod.DB_PATH) == 1 + + async with aiosqlite.connect(db_mod.DB_PATH) as db: + async with db.execute( + "SELECT status FROM job_executions WHERE job_id = 1" + ) as cursor: + assert (await cursor.fetchone())[0] == "failed" + + +# ── Monitor evaluation ──────────────────────────────────────────────────────── + +@pytest.mark.asyncio +async def test_overdue_monitor_is_flagged_without_anyone_watching(client): + """Detection used to happen only while somebody had the page open.""" + created = await client.post( + "/api/monitors/", + json={"name": "overdue", "expected_interval_seconds": 10, "grace_period_seconds": 0}, + ) + monitor_id = created.json()["id"] + + async with aiosqlite.connect(db_mod.DB_PATH) as db: + await db_mod.apply_connection_pragmas(db) + stale = (datetime.now(timezone.utc) - timedelta(minutes=5)).isoformat() + await db.execute( + "UPDATE monitors SET last_ping_at = ?, status = 'ok' WHERE id = ?", + (stale, monitor_id), + ) + await db.commit() + + assert await scheduler.tick_monitors(db_mod.DB_PATH) == 1 + + detail = await client.get(f"/api/monitors/{monitor_id}") + assert detail.json()["status"] == "failing" + + incidents = await client.get("/api/notifications/incidents/", params={"source_type": "monitor"}) + assert len(incidents.json()) == 1 + + +@pytest.mark.asyncio +async def test_never_pinged_monitor_alerts_from_creation(client): + """A cron job that never ran once should still raise an alert.""" + created = await client.post( + "/api/monitors/", + json={"name": "silent", "expected_interval_seconds": 10, "grace_period_seconds": 0}, + ) + monitor_id = created.json()["id"] + + async with aiosqlite.connect(db_mod.DB_PATH) as db: + await db_mod.apply_connection_pragmas(db) + old = (datetime.now(timezone.utc) - timedelta(hours=1)).isoformat() + await db.execute("UPDATE monitors SET created_at = ? WHERE id = ?", (old, monitor_id)) + await db.commit() + + assert await scheduler.tick_monitors(db_mod.DB_PATH) == 1 + assert (await client.get(f"/api/monitors/{monitor_id}")).json()["status"] == "failing" + + +@pytest.mark.asyncio +async def test_ping_recovers_the_monitor_and_resolves_its_incident(client): + created = await client.post( + "/api/monitors/", + json={"name": "recovers", "expected_interval_seconds": 10, "grace_period_seconds": 0}, + ) + monitor = created.json() + ping_key = monitor["ping_key"] + + async with aiosqlite.connect(db_mod.DB_PATH) as db: + await db_mod.apply_connection_pragmas(db) + stale = (datetime.now(timezone.utc) - timedelta(minutes=5)).isoformat() + await db.execute( + "UPDATE monitors SET last_ping_at = ?, status = 'ok' WHERE id = ?", + (stale, monitor["id"]), + ) + await db.commit() + + await scheduler.tick_monitors(db_mod.DB_PATH) + + ping = await client.post(f"/api/monitors/ping/{ping_key}") + assert ping.status_code == 200 + assert ping.json()["recovered"] is True + + incidents = await client.get("/api/notifications/incidents/", params={"source_type": "monitor"}) + assert [i["status"] for i in incidents.json()] == ["resolved"] + + +@pytest.mark.asyncio +async def test_ping_key_is_not_exposed_in_listings(client): + """The key is the credential a heartbeat needs; it should not be broadcast.""" + await client.post( + "/api/monitors/", json={"name": "private", "expected_interval_seconds": 60} + ) + + listed = await client.get("/api/monitors/") + assert listed.json()[0]["ping_key"] is None + + detail_id = listed.json()[0]["id"] + assert (await client.get(f"/api/monitors/{detail_id}")).json()["ping_key"] is None + + revealed = await client.get(f"/api/monitors/{detail_id}/ping-url") + assert revealed.status_code == 200 + assert revealed.json()["ping_key"] diff --git a/backend/tests/test_setup.py b/backend/tests/test_setup.py index ee925a9..2864b1c 100644 --- a/backend/tests/test_setup.py +++ b/backend/tests/test_setup.py @@ -1,13 +1,16 @@ """ Tests for the Setup Wizard API endpoints. + +These all use `anon_client`: the wizard runs before any account exists, so it +must stay reachable without credentials. """ import pytest @pytest.mark.asyncio -async def test_setup_status_not_completed(client): +async def test_setup_status_not_completed(anon_client): """Setup status should report not-completed on a fresh database.""" - resp = await client.get("/api/setup/status") + resp = await anon_client.get("/api/setup/status") assert resp.status_code == 200 data = resp.json() assert data["setup_completed"] is False @@ -15,31 +18,31 @@ async def test_setup_status_not_completed(client): @pytest.mark.asyncio -async def test_setup_demo_mode(client): +async def test_setup_demo_mode(anon_client): """Demo mode should activate and seed sample data.""" - resp = await client.post("/api/setup/demo") + resp = await anon_client.post("/api/setup/demo") assert resp.status_code == 200 data = resp.json() assert data["mode"] == "demo" # Setup should now be marked completed - status = await client.get("/api/setup/status") + status = await anon_client.get("/api/setup/status") assert status.json()["setup_completed"] is True assert status.json()["mode"] == "demo" @pytest.mark.asyncio -async def test_setup_demo_cannot_run_twice(client): +async def test_setup_demo_cannot_run_twice(anon_client): """Running demo setup twice should return 400.""" - await client.post("/api/setup/demo") - resp = await client.post("/api/setup/demo") + await anon_client.post("/api/setup/demo") + resp = await anon_client.post("/api/setup/demo") assert resp.status_code == 400 @pytest.mark.asyncio -async def test_setup_complete_development(client): +async def test_setup_complete_development(anon_client): """Development mode setup should succeed and create an admin account.""" - resp = await client.post( + resp = await anon_client.post( "/api/setup/complete", json={ "mode": "development", @@ -58,9 +61,9 @@ async def test_setup_complete_development(client): @pytest.mark.asyncio -async def test_setup_complete_invalid_mode(client): +async def test_setup_complete_invalid_mode(anon_client): """An invalid mode should return 400.""" - resp = await client.post( + resp = await anon_client.post( "/api/setup/complete", json={ "mode": "invalid", @@ -71,9 +74,9 @@ async def test_setup_complete_invalid_mode(client): @pytest.mark.asyncio -async def test_setup_complete_production_requires_admin(client): +async def test_setup_complete_production_requires_admin(anon_client): """Production mode without admin config should return 400.""" - resp = await client.post( + resp = await anon_client.post( "/api/setup/complete", json={"mode": "production", "database": {"type": "sqlite"}}, ) @@ -81,9 +84,9 @@ async def test_setup_complete_production_requires_admin(client): @pytest.mark.asyncio -async def test_setup_test_db_sqlite(client): +async def test_setup_test_db_sqlite(anon_client): """Testing an SQLite connection should always succeed.""" - resp = await client.post( + resp = await anon_client.post( "/api/setup/test-db", json={"type": "sqlite"} ) assert resp.status_code == 200 diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 432151f..3cb0b51 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -54,15 +54,13 @@ services: container_name: script-manager-frontend expose: - 3000 - environment: - - VITE_API_URL=http://localhost/api restart: unless-stopped networks: - script-manager-network depends_on: - backend healthcheck: - test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3000/"] + test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3000/healthz"] interval: 30s timeout: 10s retries: 3 diff --git a/docker-compose.yml b/docker-compose.yml index 3ab32c6..0aef1ca 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -17,7 +17,9 @@ services: environment: - API_PORT=8000 - DATABASE_PATH=/app/data/scripts.db - - SECRET_KEY=${SECRET_KEY:-your-secret-key-change-this-in-production} + # No default: an unset SECRET_KEY makes the backend generate and persist + # a random one rather than signing tokens with a value published in this file. + - SECRET_KEY=${SECRET_KEY:-} - ALLOWED_ORIGINS=${ALLOWED_ORIGINS:-http://localhost:3000,http://localhost:5173} - LOG_LEVEL=${LOG_LEVEL:-INFO} - LOG_FORMAT=${LOG_FORMAT:-text} @@ -39,15 +41,15 @@ services: container_name: script-manager-frontend ports: - "3000:3000" - environment: - - VITE_API_URL=http://localhost:8000 + # The SPA calls the API on its own origin; nginx-frontend.conf proxies + # /api to the backend, so no build-time API URL is needed. restart: unless-stopped networks: - script-manager-network depends_on: - backend healthcheck: - test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3000/"] + test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3000/healthz"] interval: 30s timeout: 10s retries: 3 diff --git a/docs/API.md b/docs/API.md index 59fc570..f6fc98f 100644 --- a/docs/API.md +++ b/docs/API.md @@ -6,18 +6,50 @@ http://localhost:8000/api ``` +The bundled frontend container proxies `/api` to the backend, so a browser +client can use same-origin relative URLs. + +## Authorization + +Every endpoint requires a bearer token and the permission named beside it, +except: + +- `/api/setup/*` — the installation wizard, which runs before any account exists +- `POST /api/auth/login` and `GET /api/auth/config` +- `GET /health` +- `POST /api/monitors/ping/{ping_key}` — the random ping key is the credential + +Send the token as `Authorization: Bearer `. A request without one gets +`401`; one whose account lacks the permission gets `403` naming the permission +required. Set `REQUIRE_AUTH=false` to disable enforcement entirely for a local +single-user setup. + +Permissions are `.`; `.*` and `superuser` are +wildcards. See the Authentication & RBAC section of the README for the roles. + ## Endpoints ### Health Check **GET /health** - Returns API health status -### Authentication (NEW) +### Authentication -- **POST /api/auth/login** - Login and get JWT token -- **GET /api/auth/me** - Get current user info -- **POST /api/auth/register** - Register new user -- **PUT /api/auth/change-password** - Change password +- **POST /api/auth/login** - Log in (form-encoded) and receive a JWT access token +- **GET /api/auth/me** - Current user, including the flattened permission set +- **GET /api/auth/config** - Public: whether auth is enforced and self-registration is on +- **POST /api/auth/register** - Create a user. Administrators always may; anonymous + callers only when `ALLOW_SELF_REGISTRATION=true` +- **PUT /api/auth/change-password** - Change the current user's password. + Takes a JSON body `{old_password, new_password}`; the credentials are not + accepted as query parameters +- **GET /api/auth/users** - List users (admin) +- **GET /api/auth/users/{id}** - Read a user (admin, or yourself) +- **PUT /api/auth/users/{id}** - Update profile, roles, active state or password (admin) +- **DELETE /api/auth/users/{id}** - Delete a user (admin) +- **GET /api/auth/roles** - List roles and their permissions (admin) + +The last remaining administrator cannot be deleted, deactivated or demoted. ### Folder Roots @@ -25,7 +57,12 @@ http://localhost:8000/api - **POST /api/folder-roots/** - Create a new folder root (with enable_content_indexing, enable_watch_mode) - **GET /api/folder-roots/{id}** - Get a specific folder root - **DELETE /api/folder-roots/{id}** - Delete a folder root -- **POST /api/folder-roots/{id}/scan** - Scan a folder root for scripts +- **PUT /api/folder-roots/{id}** - Update a root's settings, including + `enable_content_indexing` and `enable_watch_mode`. The path is immutable +- **GET /api/folder-roots/stats** - Per-root script counts and last scan outcome +- **POST /api/folder-roots/{id}/scan** - Start a scan (returns `202` with a `scan_id`) +- **GET /api/folder-roots/{id}/scan/{scan_id}** - Poll a scan's progress +- **GET /api/folder-roots/{id}/scans** - Recent scan history ### Scripts @@ -127,22 +164,59 @@ http://localhost:8000/api - **DELETE /api/attachments/{id}** - Delete attachment - **GET /api/attachments/stats/all** - Get attachment statistics -## Interactive Documentation +## Monitors -FastAPI provides interactive API documentation: -- Swagger UI: http://localhost:8000/docs -- ReDoc: http://localhost:8000/redoc +- **GET /api/monitors/** - List monitors, evaluating overdue status first +- **POST /api/monitors/** - Create a monitor (the response includes its ping key once) +- **GET/PUT/DELETE /api/monitors/{id}** - Read, update or delete a monitor +- **POST /api/monitors/{id}/pause** and **/resume** - Suspend or restore alerting +- **GET /api/monitors/{id}/ping-url** - Reveal the ping key deliberately +- **POST /api/monitors/ping/{ping_key}** - Record a heartbeat (unauthenticated) +- **GET /api/monitors/{id}/pings** - Ping history +- **GET /api/monitors/{id}/incidents** - Incidents raised for this monitor -## Authentication +Ping keys are omitted from listings; fetch one from the dedicated endpoint. -For protected endpoints, include JWT token in header: -``` -Authorization: Bearer -``` +## Schedules -Get token via POST /api/auth/login +- **GET /api/schedules/** - List jobs +- **POST /api/schedules/** - Create a job. The cron expression and timezone are + validated, and `next_run_at` is computed +- **GET/PUT/DELETE /api/schedules/{id}** - Read, update or delete a job +- **POST /api/schedules/{id}/enable** and **/disable** +- **POST /api/schedules/{id}/trigger** - Run immediately, outside the schedule +- **GET /api/schedules/{id}/executions** - Execution history +- **GET /api/schedules/{id}/executions/{execution_id}** - One execution with its logs +- **GET /api/schedules/{id}/metrics** - Duration and success-rate trends +- **GET /api/schedules/preview/cron** - Validate an expression and preview its next runs -## Total Endpoints: 73 +Jobs are executed by the backend's own scheduler; no external cron is needed. + +## Notifications + +- **GET/POST /api/notifications/channels/** - List or create channels +- **GET /api/notifications/channels/types** - Config fields each channel type expects +- **GET/PUT/DELETE /api/notifications/channels/{id}** +- **POST /api/notifications/channels/{id}/test** - Send a real test message; the + outcome is reported in the body as `{success, message}` rather than as an HTTP error +- **GET /api/notifications/incidents/** - List incidents (`status`, `source_type`, `limit`) +- **GET /api/notifications/incidents/stats** - Counts by status and severity +- **GET/PUT/DELETE /api/notifications/incidents/{id}** + +Secret configuration values are always returned as `***`. Submitting `***` back +on an update preserves the stored value. + +## Timestamps + +All timestamps are returned in UTC with an explicit offset (for example +`2026-09-06T22:28:45Z`), so clients can render them in the viewer's own timezone +without guessing. + +## Interactive Documentation + +FastAPI serves the generated, always-current reference: + +- Swagger UI: http://localhost:8000/docs +- ReDoc: http://localhost:8000/redoc -- Core Features: 41 endpoints -- Optional Features: 32 endpoints +Treat those as authoritative; this file is a summary. diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index d6bf624..886dee6 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,101 +1,305 @@ -import { useState, useEffect } from 'react'; -import { BrowserRouter as Router, Routes, Route, Link, useLocation } from 'react-router-dom'; +import { useCallback, useEffect, useState } from 'react'; +import { + BrowserRouter as Router, Link, NavLink, Navigate, Route, Routes, useLocation, +} from 'react-router-dom'; + import Dashboard from './pages/Dashboard'; +import Duplicates from './pages/Duplicates'; import FolderRoots from './pages/FolderRoots'; -import Scripts from './pages/Scripts'; +import Login from './pages/Login'; +import Monitors from './pages/Monitors'; +import Notifications from './pages/Notifications'; +import Schedules from './pages/Schedules'; import ScriptDetail from './pages/ScriptDetail'; -import Tags from './pages/Tags'; +import Scripts from './pages/Scripts'; import Search from './pages/Search'; +import Settings from './pages/Settings'; import SetupWizard from './pages/SetupWizard'; -import Monitors from './pages/Monitors'; -import Schedules from './pages/Schedules'; -import Notifications from './pages/Notifications'; +import Tags from './pages/Tags'; import Team from './pages/Team'; + +import ErrorBoundary from './components/ErrorBoundary'; +import ThemeToggle from './components/ThemeToggle'; +import { EmptyState, Spinner } from './components/ui'; +import { AuthProvider, useAuth } from './context/AuthContext'; +import { ToastProvider } from './context/ToastContext'; import { setupApi } from './services/api'; -function Navigation() { - const location = useLocation(); - - const isActive = (path) => { - return location.pathname === path ? 'active' : ''; - }; - +const NAV_ITEMS = [ + { to: '/', label: 'Dashboard', icon: '▤', end: true }, + { to: '/folder-roots', label: 'Folder Roots', icon: '▣', permission: 'roots.read' }, + { to: '/scripts', label: 'Scripts', icon: '◈', permission: 'scripts.read' }, + { to: '/duplicates', label: 'Duplicates', icon: '⧉', permission: 'scripts.read' }, + { to: '/tags', label: 'Tags', icon: '◆', permission: 'tags.read' }, + { to: '/search', label: 'Search', icon: '⌕', permission: 'search.read' }, + { to: '/monitors', label: 'Monitors', icon: '♥', permission: 'monitors.read' }, + { to: '/schedules', label: 'Schedules', icon: '⏱', permission: 'schedules.read' }, + { to: '/notifications', label: 'Notifications', icon: '✉', permission: 'notifications.read' }, + { to: '/team', label: 'Team', icon: '☰', adminOnly: true }, + { to: '/settings', label: 'Settings', icon: '⚙' }, +]; + +function Navigation({ onNavigate }) { + const { can, isAdmin, config } = useAuth(); + const authOff = config?.auth_required === false; + + // Only show what this account can actually reach, so a viewer is not sent to + // a page that answers 403. + const visible = NAV_ITEMS.filter((item) => { + if (item.adminOnly) return authOff || isAdmin; + if (item.permission) return can(item.permission); + return true; + }); + return ( -