From 5ff12a4aa3bdcc2795805dd8f18127f3c73a3568 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 6 Sep 2026 22:10:08 +0000 Subject: [PATCH 1/4] Enforce RBAC, run scheduled jobs, and deliver notifications The backend shipped a full permission model, a cron field on every job and six notification channel types, none of which did anything. This wires them up and fixes the data-layer bugs found alongside them. Security and access control - Add app/routes/deps.py with require_permission/require_admin and apply it across every router. The REST API was reachable anonymously, including the endpoint that stores a shell command and the one that executes it. - Resolve SECRET_KEY from the environment, else generate and persist a random key. The previous fallback was a published constant, so anyone could forge an admin token against a default deployment. - Redact secrets in notification channel responses and preserve them when a client sends the redaction placeholder back on update. - Move change-password credentials from query parameters into a request body. - Gate self-registration behind ALLOW_SELF_REGISTRATION (default off) and stop an admin from demoting, deactivating or deleting the last administrator. - Escape HTML in the plain-text note render/preview path. - Escape LIKE metacharacters so a search for "%" no longer matches everything. - Keep monitor ping keys out of list responses; expose them on their own route. Data layer - Apply PRAGMA foreign_keys on every connection. It was set only during init, so every ON DELETE CASCADE was dead and deleting a folder root orphaned all of its scripts. Adds a one-shot repair for databases already affected. - Add an additive migration step; CREATE TABLE IF NOT EXISTS never delivered a new column to an existing database. - Enable WAL and a busy timeout, and index the columns the hot queries filter on. - Serialize every timestamp as UTC-aware so the UI stops shifting them. - Aggregate tags with a unit separator; a tag containing a comma became two. Scheduling, monitoring and notifications - Add app/services/cron.py (validated 5-field parser with timezone support) and app/services/scheduler.py, which fires due jobs, evaluates monitors on a timer and reaps executions stranded by a restart. - Add app/services/notifier.py with working Slack, Discord, webhook, PagerDuty, SMTP and Twilio delivery; "test channel" now sends and reports the outcome. - Validate cron expressions and timezones on write and compute next_run_at. - Kill the whole process group on job timeout; the drain used to hang forever. - Raise incidents and alert channels on job failure, and resolve them on recovery. Scanning, search and attachments - Run directory walks off the event loop and guard against symlink loops. - Populate the folders table so the folder tree and folder notes work at all. - Maintain the FTS index on scan, watch and note writes, and honour the search_content/search_notes flags that were computed and then ignored. - Rebuild watch mode around one worker per root instead of a thread per event, and apply the root's include/exclude patterns. - Stream attachment uploads with an early size abort, sanitise filenames, and remove files from disk when their parent is deleted. - Turn off difflib's autojunk heuristic, which zeroed similarity scores for any file over ~2 KB, and make the sweep load each file once with a bounded cap. - Add PUT /api/folder-roots/{id} so content indexing and watch mode can be enabled without deleting and recreating the root. Deployment - Proxy /api to the backend in nginx-frontend.conf. Without it the composed frontend answered every API call with index.html. - Stop shipping a default SECRET_KEY in docker-compose.yml. Tests: 110 passing (was 57). Adds coverage for cron parsing, RBAC enforcement, cascade deletes, migrations and secret redaction. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01JybLja9BWK1Q6yfejNYmwr --- .gitignore | 3 + Dockerfile.frontend | 2 +- backend/app/db/database.py | 165 ++++++- backend/app/db/sql.py | 34 ++ backend/app/models/schemas.py | 169 ++++--- backend/app/routes/attachments.py | 201 ++++++-- backend/app/routes/auth.py | 485 ++++++++++++------- backend/app/routes/deps.py | 189 ++++++++ backend/app/routes/folder_roots.py | 481 ++++++++++++++---- backend/app/routes/folders.py | 14 +- backend/app/routes/fts.py | 13 +- backend/app/routes/monitors.py | 251 +++++----- backend/app/routes/notes.py | 37 +- backend/app/routes/notifications.py | 322 ++++++++++--- backend/app/routes/saved_searches.py | 16 +- backend/app/routes/schedules.py | 390 ++++++--------- backend/app/routes/scripts.py | 211 +++++--- backend/app/routes/search.py | 21 +- backend/app/routes/setup.py | 80 +-- backend/app/routes/similarity.py | 26 +- backend/app/routes/tags.py | 15 +- backend/app/routes/watch.py | 14 +- backend/app/services/auth.py | 177 +++++-- backend/app/services/cron.py | 298 ++++++++++++ backend/app/services/fts.py | 100 ++-- backend/app/services/notifier.py | 402 +++++++++++++++ backend/app/services/scanner.py | 125 +++-- backend/app/services/scheduler.py | 697 +++++++++++++++++++++++++++ backend/app/services/similarity.py | 394 ++++++++------- backend/app/services/watch.py | 430 ++++++++++------- backend/main.py | 110 ++++- backend/requirements-dev.txt | 3 + backend/requirements.txt | 2 + backend/tests/conftest.py | 87 +++- backend/tests/test_auth.py | 14 +- backend/tests/test_cron.py | 79 +++ backend/tests/test_data_integrity.py | 145 ++++++ backend/tests/test_notifications.py | 101 +++- backend/tests/test_rbac.py | 149 ++++++ backend/tests/test_setup.py | 35 +- docker-compose.prod.yml | 2 +- docker-compose.yml | 10 +- nginx-frontend.conf | 41 +- 43 files changed, 5044 insertions(+), 1496 deletions(-) create mode 100644 backend/app/db/sql.py create mode 100644 backend/app/routes/deps.py create mode 100644 backend/app/services/cron.py create mode 100644 backend/app/services/notifier.py create mode 100644 backend/app/services/scheduler.py create mode 100644 backend/requirements-dev.txt create mode 100644 backend/tests/test_cron.py create mode 100644 backend/tests/test_data_integrity.py create mode 100644 backend/tests/test_rbac.py 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/backend/app/db/database.py b/backend/app/db/database.py index c4f39c3..c778a20 100644 --- a/backend/app/db/database.py +++ b/backend/app/db/database.py @@ -2,25 +2,132 @@ 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") + + +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 +146,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 +160,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 +182,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 +195,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 +206,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 +218,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 +230,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 +245,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 +262,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 +276,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 +293,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 +304,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 +320,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 +332,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 +345,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 +354,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 ( @@ -360,22 +468,33 @@ 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)") + 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..18a0069 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): @@ -208,17 +248,17 @@ class MonitorResponse(BaseModel): expected_interval_seconds: int grace_period_seconds: int ping_key: str - last_ping_at: Optional[datetime] + 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 +272,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 +293,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 +307,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 +341,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 +361,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 +377,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..c03f388 100644 --- a/backend/app/routes/attachments.py +++ b/backend/app/routes/attachments.py @@ -8,22 +8,54 @@ import os import uuid from pathlib import Path +import logging import mimetypes 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 + +# 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 +63,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 +142,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 +160,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 +178,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 +206,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 +222,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 +242,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 +252,73 @@ 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}") + + for name in entries: + if name in known: + continue + path = os.path.join(ATTACHMENTS_DIR, name) + if not os.path.isfile(path): + 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..2bd1704 100644 --- a/backend/app/routes/auth.py +++ b/backend/app/routes/auth.py @@ -1,236 +1,266 @@ """ 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) + if 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. + requested_role_ids = data.role_ids if (data.role_ids and is_admin(current_user)) 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 +271,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 +287,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 +372,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) + + +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) +@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..78956f7 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,17 +22,21 @@ 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: @@ -37,42 +45,60 @@ def _monitor_from_row(row) -> dict: 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 +110,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, ), @@ -98,18 +124,19 @@ async def create_monitor( return _monitor_from_row(row) -@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 +145,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 +164,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 +209,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 +218,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 +237,95 @@ 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=[read_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 so the key is fetched deliberately rather than + being handed out in every listing. + """ + 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 +336,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 +345,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 +360,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..cedf87d 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,7 +320,6 @@ 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) @@ -252,20 +331,22 @@ async def trigger_job( await db.commit() 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 +360,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 +369,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 +387,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 +404,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 +419,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..1e73861 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,17 +470,31 @@ 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}") -@router.post("/bulk/tags") + return { + "content": content, + "path": file_path, + "size": size, + "truncated": truncated, + } + +@router.post("/bulk/tags", dependencies=[update_access]) async def bulk_add_tags( request: BulkTagRequest, db: aiosqlite.Connection = Depends(get_db) @@ -496,7 +543,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 +621,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 +699,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 +717,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 +743,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 +784,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 +813,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 +835,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 +857,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 +893,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..5d67efe 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") @@ -269,7 +313,7 @@ async def complete_setup( await _seed_demo_data(db) 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 +326,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() 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..25628bd --- /dev/null +++ b/backend/app/services/scheduler.py @@ -0,0 +1,697 @@ +""" +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__) + +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. + + Returns the ids of monitors that newly transitioned to failing. + """ + now = datetime.now(timezone.utc) + transitioned: List[int] = [] + + 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: + await notifier.dispatch( + db, channel_ids, + notifier.Alert( + title=title, body=description, + severity="critical", source="script-manager/monitors", + ), + ) + + if transitioned: + await db.commit() + 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 + + cursor = await db.execute( + """ + INSERT INTO job_executions (job_id, started_at, status, triggered_by) + VALUES (?, ?, 'running', 'scheduler') + """, + (job_id, now.isoformat()), + ) + execution_id = cursor.lastrowid + await db.commit() + + 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, + )) + 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..e786616 100644 --- a/backend/app/services/watch.py +++ b/backend/app/services/watch.py @@ -1,238 +1,300 @@ """ -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.schedule(handler, root_path, recursive=bool(recursive)) observer.start() - - self.observers[root_id] = observer - print(f"Started watching folder root {root_id}: {root_path}") - + + 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] - observer.stop() - observer.join(timeout=2) - del self.observers[root_id] - print(f"Stopped watching folder root {root_id}") - + entry = self.observers.pop(root_id, None) + if not entry: + return + observer, worker, work_queue = entry + observer.stop() + observer.join(timeout=2) + work_queue.put(_STOP) + worker.join(timeout=5) + 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 +302,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_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..7a70e9b 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -62,7 +62,7 @@ services: 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/nginx-frontend.conf b/nginx-frontend.conf index 8b1edb5..5726c7c 100644 --- a/nginx-frontend.conf +++ b/nginx-frontend.conf @@ -5,13 +5,50 @@ server { root /usr/share/nginx/html; index index.html; - # Cache static assets + # Uploaded attachments and imported metadata can be large. + client_max_body_size 100M; + + resolver 127.0.0.11 valid=10s ipv6=off; + + # The SPA calls the API on the same origin ("/api/..."), so this container + # has to forward those requests to the backend. Without this block every + # API call fell through to the catch-all below and was answered with + # index.html, which broke the whole application whenever it was run from + # docker-compose without the optional reverse proxy in front. + location /api/ { + set $backend_upstream backend:8000; + proxy_pass http://$backend_upstream; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_connect_timeout 60s; + proxy_send_timeout 300s; + proxy_read_timeout 300s; + } + + # Backend liveness, proxied so an operator can probe a single origin. + location = /api-health { + set $backend_upstream backend:8000; + proxy_pass http://$backend_upstream/health; + access_log off; + } + + # Container liveness for the Docker healthcheck. + location = /healthz { + access_log off; + default_type text/plain; + return 200 "ok\n"; + } + + # Hashed asset filenames are immutable, so they can be cached hard. location ~ ^/assets/ { expires 30d; add_header Cache-Control "public, immutable"; } - # React routing - all routes serve index.html + # React routing - all remaining routes serve index.html location / { try_files $uri $uri/ /index.html; add_header Cache-Control "no-cache, no-store, must-revalidate"; From d840bdf5711422da2b4c3a7934af94aa82b2a00b Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 6 Sep 2026 22:38:08 +0000 Subject: [PATCH 2/4] Add authentication to the UI and rebuild the front end around it The React app had no sign-in screen and never sent a bearer token, so every authenticated endpoint was unreachable from the browser and the Team page could only ever show "Not authenticated". Several whole backend feature areas had no UI at all. This wires the client to the API and rebuilds the interface on a shared design system. Authentication - Add a sign-in screen, an auth context, and axios interceptors that attach the token and drop back to sign-in on 401 instead of leaving a dead error page. - Show who is signed in, offer sign-out, and add a Settings page for changing your own password. - Hide navigation entries and actions the account lacks permission for, and block direct URLs to pages it cannot reach, mirroring the server's rules. Features that had no interface - Duplicates page: exact (hash) duplicates and near-duplicate similarity groups. - Full-text content search and saved searches on the Search page, plus the eight filters SearchRequest supported but the form never exposed. - Script detail: note editing and deletion with Markdown rendering, attachments, custom fields, similar scripts, and a change history that shows old to new values and who made the change. - Folder roots: an edit dialog, so content indexing and watch mode can finally be turned on, plus scan history and per-root script counts. - Bulk tagging and status changes, and metadata export, from the Scripts page. - Notification channel pickers on monitors and jobs, so alerts reach someone. - A typed notification channel form (the old one was a JSON textarea seeded with a template the backend rejected), a live cron preview, and index maintenance and watch-mode controls in Settings. Interface - Replace two conflicting palettes and 100+ inline style objects with CSS design tokens, and add a dark theme that follows the OS with a manual override. - Replace 43 blocking alert() calls with a toast layer, and window.confirm with a dialog that states the impact of a destructive action. - Add an accessible Modal (dialog role, focus trap, Escape, restored focus), associate every label with its control, make table headers sortable with aria-sort, add a skip link, and fix text colours that failed AA contrast. - Collapse the sidebar into a drawer below 900px; it used to push ~540px of navigation above every page on a phone. - Add an error boundary, a 404 route, per-page document titles, skeleton loaders, empty states, and retryable error banners. Bugs - Surface the server's error detail; users saw "Request failed with status code 400" and, for validation errors, "[object Object]". - Parse offset-less timestamps as UTC. Every time in the UI was shifted by the viewer's timezone offset. - Clear the scan-status polling interval on unmount and time it out; it leaked one interval per scan and polled forever. - Discard stale list responses so a slow request cannot overwrite newer results, and clamp the page number when a result set shrinks. - Guard every submit against double-clicks, and send only changed status fields so saving no longer writes four no-op audit entries. - Re-arm the mounted ref on mount: under StrictMode it was only ever cleared, which left the app stuck on "Checking your session". Backend and deployment - Keep monitor ping keys out of listings; they are the credential a ping needs. - Serve demo mode a real administrator account and show its one-time password, instead of finishing setup with no way to sign in. Docs: README, docs/API.md and both .env.example files now describe the scheduler, real notification delivery, RBAC enforcement and the new settings. Verified end to end in a browser: sign-in, all ten pages, script detail, tag creation, modal Escape, impact-stating confirmations, full-text search, live cron validation, dark mode, the 404 route, the mobile drawer, sign-out, and a viewer account seeing a correctly reduced interface. 110 backend tests pass and the frontend builds clean. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01JybLja9BWK1Q6yfejNYmwr --- .env.example | 69 +- README.md | 131 +- backend/.env.example | 54 +- backend/app/models/schemas.py | 5 +- backend/app/routes/monitors.py | 7 +- docker-compose.prod.yml | 2 - docs/API.md | 112 +- frontend/src/App.jsx | 348 ++++- frontend/src/components/ChannelPicker.jsx | 46 + frontend/src/components/ConfirmDialog.jsx | 80 ++ frontend/src/components/ErrorBoundary.jsx | 58 + frontend/src/components/Modal.jsx | 106 ++ frontend/src/components/ThemeToggle.jsx | 55 + frontend/src/components/ui.jsx | 295 ++++ frontend/src/context/AuthContext.jsx | 142 ++ frontend/src/context/ToastContext.jsx | 103 ++ frontend/src/index.css | 1581 ++++++++++++++------- frontend/src/lib/format.js | 120 ++ frontend/src/pages/Dashboard.jsx | 420 ++++-- frontend/src/pages/Duplicates.jsx | 226 +++ frontend/src/pages/FolderRoots.jsx | 684 ++++++--- frontend/src/pages/Login.jsx | 124 ++ frontend/src/pages/Monitors.jsx | 673 +++++---- frontend/src/pages/Notifications.jsx | 685 +++++---- frontend/src/pages/Schedules.jsx | 1027 ++++++++----- frontend/src/pages/ScriptDetail.jsx | 916 +++++++++--- frontend/src/pages/Scripts.jsx | 558 +++++--- frontend/src/pages/Search.jsx | 842 +++++++---- frontend/src/pages/Settings.jsx | 316 ++++ frontend/src/pages/SetupWizard.jsx | 22 +- frontend/src/pages/Tags.jsx | 376 +++-- frontend/src/pages/Team.jsx | 585 +++++--- frontend/src/services/api.js | 232 ++- frontend/vite.config.js | 2 +- 34 files changed, 8121 insertions(+), 2881 deletions(-) create mode 100644 frontend/src/components/ChannelPicker.jsx create mode 100644 frontend/src/components/ConfirmDialog.jsx create mode 100644 frontend/src/components/ErrorBoundary.jsx create mode 100644 frontend/src/components/Modal.jsx create mode 100644 frontend/src/components/ThemeToggle.jsx create mode 100644 frontend/src/components/ui.jsx create mode 100644 frontend/src/context/AuthContext.jsx create mode 100644 frontend/src/context/ToastContext.jsx create mode 100644 frontend/src/lib/format.js create mode 100644 frontend/src/pages/Duplicates.jsx create mode 100644 frontend/src/pages/Login.jsx create mode 100644 frontend/src/pages/Settings.jsx 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/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/models/schemas.py b/backend/app/models/schemas.py index 18a0069..db610df 100644 --- a/backend/app/models/schemas.py +++ b/backend/app/models/schemas.py @@ -247,7 +247,10 @@ class MonitorResponse(BaseModel): description: Optional[str] expected_interval_seconds: int grace_period_seconds: int - ping_key: str + # 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] = [] diff --git a/backend/app/routes/monitors.py b/backend/app/routes/monitors.py index 78956f7..0e9641c 100644 --- a/backend/app/routes/monitors.py +++ b/backend/app/routes/monitors.py @@ -39,9 +39,11 @@ def _parse_channel_ids(raw: Optional[str]) -> List[int]: 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 @@ -121,7 +123,8 @@ 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, dependencies=[read_access]) diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 7a70e9b..3cb0b51 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -54,8 +54,6 @@ services: container_name: script-manager-frontend expose: - 3000 - environment: - - VITE_API_URL=http://localhost/api restart: unless-stopped networks: - script-manager-network 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 ( -