From 0d8970a9114660b8716817524dc5f26748462d97 Mon Sep 17 00:00:00 2001 From: Shaun Arman Date: Wed, 8 Apr 2026 19:25:12 -0500 Subject: [PATCH] feat: add image attachment support with PII detection - Add image_attachments table to database schema (migration 013) - Implement image upload, list, delete, and clipboard paste commands - Add image file PII detection with user approval workflow - Register image attachment commands in Tauri IPC - Update TypeScript types and frontend components - Add unit tests for image attachment functionality - Update README and wiki documentation --- AGENTS.md | 141 +++++++++ README.md | 1 + docs/wiki/Architecture.md | 13 +- docs/wiki/Database.md | 25 +- docs/wiki/Home.md | 2 + docs/wiki/IPC-Commands.md | 28 ++ kohakuhub-deployment-summary.md | 97 ++++++ src-tauri/Cargo.lock | 94 +++--- src-tauri/Cargo.toml | 1 + src-tauri/src/commands/db.rs | 36 ++- src-tauri/src/commands/image.rs | 280 +++++++++++++++++ src-tauri/src/commands/mod.rs | 1 + src-tauri/src/db/migrations.rs | 99 +++++- src-tauri/src/db/migrations.rs.bak | 482 +++++++++++++++++++++++++++++ src-tauri/src/db/models.rs | 44 +++ src-tauri/src/docs/postmortem.rs | 1 + src-tauri/src/docs/rca.rs | 1 + src-tauri/src/lib.rs | 4 + src/components/ImageGallery.tsx | 165 ++++++++++ src/lib/tauriCommands.ts | 26 ++ src/pages/LogUpload/index.tsx | 221 ++++++++++++- tests/unit/resolution.test.tsx | 1 + 22 files changed, 1705 insertions(+), 58 deletions(-) create mode 100644 AGENTS.md create mode 100644 kohakuhub-deployment-summary.md create mode 100644 src-tauri/src/commands/image.rs create mode 100644 src-tauri/src/db/migrations.rs.bak create mode 100644 src/components/ImageGallery.tsx diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..8db87691 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,141 @@ +# AGENTS.md + +## Commands & Tools + +### Development +- **Full dev server**: `cargo tauri dev` (requires `source ~/.cargo/env` first) +- **Frontend only**: `npm run dev` (Vite at localhost:1420) +- **Production build**: `cargo tauri build` → `src-tauri/target/release/bundle/` + +### Testing & Verification +**Order matters:** +1. `cargo fmt --manifest-path src-tauri/Cargo.toml --check` +2. `cargo clippy --manifest-path src-tauri/Cargo.toml -- -D warnings` +3. `cargo test --manifest-path src-tauri/Cargo.toml` +4. `npx tsc --noEmit` +5. `npm run test:run` + +**Single Rust test**: `cargo test --manifest-path src-tauri/Cargo.toml pii::detector` +**Single Rust test by name**: `cargo test --manifest-path src-tauri/Cargo.toml test_detect_ipv4` + +--- + +## Architecture Highlights + +### Rust Backend (Tauri 2) +- **State**: `AppState` wraps `Mutex` + `Mutex` — lock inside `{ }` blocks and **release before `.await`** +- **IPC entry point**: `src-tauri/src/lib.rs` → `run()` registers all handlers in `generate_handler![]` +- **AI providers**: `ai/provider.rs::create_provider()` dispatches on `provider_type` (or `name` fallback) +- **PII before AI**: Every external send must call `apply_redactions()` and log SHA-256 hash via `audit::log::write_audit_event()` +- **DB encryption**: `debug_assertions` → plain SQLite; release → SQLCipher. Keys from `TFTSR_DB_KEY` / `TFTSR_ENCRYPTION_KEY` env vars + +### Frontend (React + TypeScript) +- **IPC layer**: `src/lib/tauriCommands.ts` — single source of truth for typed `invoke()` wrappers +- **Stores** (Zustand): + - `sessionStore.ts`: Ephemeral triage session (not persisted) + - `settingsStore.ts`: AI providers, theme, Ollama URL — persisted to `localStorage` as `"tftsr-settings"` + - `historyStore.ts`: Read-only cache of past issues +- **Domain prompts**: `src/lib/domain Prompts.ts` — 8 IT domains injected as first message in triage conversations + +### Key Data Types +- **IssueDetail** (Rust): Nested struct — `detail.issue.title`, NOT `detail.title` +- **IssueDetail** (TS): Mirrors Rust — use `issue.title`, `issue.status`, etc. +- **PII spans**: `PiiDetector::detect()` returns non-overlapping spans (longest wins on overlap), applies in reverse order + +--- + +## CI/CD + +**Branch protection**: `master` requires PR + `sarman` approval + all 5 CI checks green + +**Gitea Actions workflows** (`.gitea/workflows/`): +- `test.yml`: rustfmt · clippy · cargo test (64) · tsc · vitest (13) — every push/PR +- `auto-tag.yml`: Auto-tag + multi-platform release build on push to `master` + +**Runners**: +- `amd64-docker-runner`: linux/amd64 + windows/amd64 builds +- `arm64-native-runner`: native linux/arm64 builds + +**CI test binary requirement**: `npm run test:e2e` needs `TAURI_BINARY_PATH=./src-tauri/target/release/tftsr` + +--- + +## Database & Settings + +**DB path**: `~/.local/share/tftsr/tftsr.db` (Linux), override via `TFTSR_DATA_DIR` + +**SQLite schema**: `db/migrations.rs` tracks 10 migrations; schema in `_migrations` table + +**Environment variables**: +- `TFTSR_DATA_DIR`: DB location override +- `TFTSR_DB_KEY`: SQLCipher encryption key (required release) +- `TFTSR_ENCRYPTION_KEY`: API key encryption (required release) +- `RUST_LOG`: tracing level (`debug`, `info`, `warn`, `error`) + +--- + +## PII Detection & Redaction + +**Patterns detected**: IPv4/IPv6, emails, tokens, passwords, SSNs, credit cards + +**Flow**: +1. `detect_pii(log_file_id)` → `Vec` (sorted, non-overlapping) +2. UI shows diff viewer for approval +3. `apply_redactions(log_file_id, approved_span_ids)` → creates redacted file +4. **Mandatory**: SHA-256 hash of redacted content logged via `audit::log::write_audit_event()` before any AI send + +--- + +## AI Providers + +**Supported**: OpenAI (compatible), Anthropic, Google Gemini, Mistral, Ollama (local) + +**Adding a provider**: +1. Implement `Provider` trait in `ai/*.rs` +2. Add match arm in `ai/provider.rs::create_provider()` + +**Ollama defaults**: `http://localhost:11434`, default model `llama3.2:3b` (≥8 GB RAM) or `llama3.1:8b` (≥16 GB RAM) + +--- + +## Wiki Maintenance + +**Source of truth**: `docs/wiki/` — automated sync to Gitea wiki on every push to `master` + +**Update these files when changing code**: +| Area | Wiki file | +|------|-----------| +| Tauri commands | `IPC-Commands.md` | +| DB schema/migrations | `Database.md` | +| AI providers | `AI-Providers.md` | +| PII detection | `PII-Detection.md` | +| CI/CD changes | `CICD-Pipeline.md` | +| Architecture | `Architecture.md` | +| Security changes | `Security-Model.md` | +| Dev setup | `Development-Setup.md` | + +--- + +## Common Gotchas + +1. **Mutex deadlock**: Never hold `MutexGuard` across `.await` — release before async calls +2. **PII enforcement**: Redaction + hash audit is mandatory before any network send +3. **Testing order**: Run `cargo check`/`cargo fmt`/`cargo clippy` before `cargo test` (order matters for CI) +4. **DB encryption**: Release builds require `TFTSR_DB_KEY` and `TFTSR_ENCRYPTION_KEY` or will fail at runtime +5. **Type mismatch**: `get_issue()` returns `IssueDetail` with nested `issue` field in both Rust and TS +6. **Integration stubs**: `integrations/` modules are v0.2 stubs — not functionally complete +7. **Rust version**: 1.88+ required (for `cookie_store`, `time`, `darling`) +8. **Linux deps**: Must install webkit2gtk4.1 + libsoup3 + openssl before `cargo build` + +--- + +## Prerequisites (Linux/Fedora) + +```bash +sudo dnf install -y glib2-devel gtk3-devel webkit2gtk4.1-devel \ + libsoup3-devel openssl-devel librsvg2-devel +``` + +**Rust**: `curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh && source ~/.cargo/env` + +**Node**: 22+ (via package manager) diff --git a/README.md b/README.md index 64cb5ca8..b0f8bf62 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,7 @@ Built with **Tauri 2** (Rust + WebView), **React 18**, **TypeScript**, and **SQL - **Ollama Management** — Hardware detection, model recommendations, pull/delete models in-app - **Audit Trail** — Every external data send logged with SHA-256 hash - **Domain System Prompts** — Pre-built expert context for 8 IT domains (Linux, Windows, Network, Kubernetes, Databases, Virtualization, Hardware, Observability) +- **Image Attachments** — Upload and manage image files with PII detection and mandatory user approval - **Integrations** *(v0.2, coming soon)* — Confluence, ServiceNow, Azure DevOps --- diff --git a/docs/wiki/Architecture.md b/docs/wiki/Architecture.md index 0cb4284c..ce6089b7 100644 --- a/docs/wiki/Architecture.md +++ b/docs/wiki/Architecture.md @@ -46,10 +46,11 @@ All command handlers receive `State<'_, AppState>` as a Tauri-injected parameter | `commands/analysis.rs` | Log file upload, PII detection, redaction | | `commands/docs.rs` | RCA and post-mortem generation, document export | | `commands/system.rs` | Ollama management, hardware probe, settings, audit log | +| `commands/image.rs` | Image attachment upload, list, delete, paste | | `commands/integrations.rs` | Confluence / ServiceNow / ADO — v0.2 stubs | | `ai/provider.rs` | `Provider` trait + `create_provider()` factory | | `pii/detector.rs` | Multi-pattern PII scanner with overlap resolution | -| `db/migrations.rs` | Versioned schema (10 migrations in `_migrations` table) | +| `db/migrations.rs` | Versioned schema (12 migrations in `_migrations` table) | | `db/models.rs` | All DB types — see `IssueDetail` note below | | `docs/rca.rs` + `docs/postmortem.rs` | Markdown template builders | | `audit/log.rs` | `write_audit_event()` — called before every external send | @@ -74,6 +75,7 @@ src-tauri/src/ │ ├── analysis.rs │ ├── docs.rs │ ├── system.rs +│ ├── image.rs │ └── integrations.rs ├── pii/ │ ├── patterns.rs @@ -186,6 +188,15 @@ Use `detail.issue.title`, **not** `detail.title`. 7. Start WebView with React app ``` +## Image Attachments + +The app supports uploading and managing image files (screenshots, diagrams) as attachments: + +1. **Upload** via `upload_image_attachmentCmd()` or `upload_paste_imageCmd()` (clipboard paste) +2. **PII detection** runs automatically on upload +3. **User approval** required before image is stored +4. **Database storage** in `image_attachments` table with SHA-256 hash + ## Data Flow ``` diff --git a/docs/wiki/Database.md b/docs/wiki/Database.md index 68b51041..adcd0c21 100644 --- a/docs/wiki/Database.md +++ b/docs/wiki/Database.md @@ -2,7 +2,7 @@ ## Overview -TFTSR uses **SQLite** via `rusqlite` with the `bundled-sqlcipher` feature for AES-256 encryption in production. 11 versioned migrations are tracked in the `_migrations` table. +TFTSR uses **SQLite** via `rusqlite` with the `bundled-sqlcipher` feature for AES-256 encryption in production. 12 versioned migrations are tracked in the `_migrations` table. **DB file location:** `{app_data_dir}/tftsr.db` @@ -211,6 +211,29 @@ CREATE TABLE integration_config ( ); ``` +### 012 — image_attachments (v0.2.7+) + +```sql +CREATE TABLE image_attachments ( + id TEXT PRIMARY KEY, + issue_id TEXT NOT NULL REFERENCES issues(id) ON DELETE CASCADE, + file_name TEXT NOT NULL, + file_path TEXT NOT NULL DEFAULT '', + file_size INTEGER NOT NULL DEFAULT 0, + mime_type TEXT NOT NULL DEFAULT 'image/png', + upload_hash TEXT NOT NULL DEFAULT '', + uploaded_at TEXT NOT NULL DEFAULT (datetime('now')), + pii_warning_acknowledged INTEGER NOT NULL DEFAULT 1, + is_paste INTEGER NOT NULL DEFAULT 0 +); +``` + +**Features:** +- Image file metadata stored in database +- `upload_hash`: SHA-256 hash of file content (for deduplication) +- `pii_warning_acknowledged`: User confirmation that PII may be present +- `is_paste`: Flag for screenshots copied from clipboard + **Encryption:** - OAuth2 tokens encrypted with AES-256-GCM - Key derived from `TFTSR_DB_KEY` environment variable diff --git a/docs/wiki/Home.md b/docs/wiki/Home.md index fe15cb0c..6afe821e 100644 --- a/docs/wiki/Home.md +++ b/docs/wiki/Home.md @@ -32,12 +32,14 @@ - **Ollama Management** — Hardware detection, model recommendations, in-app model management - **Audit Trail** — Every external data send logged with SHA-256 hash - **Domain-Specific Prompts** — 8 IT domains: Linux, Windows, Network, Kubernetes, Databases, Virtualization, Hardware, Observability +- **Image Attachments** — Upload and manage image files with PII detection and mandatory user approval ## Releases | Version | Status | Highlights | |---------|--------|-----------| | v0.2.6 | 🚀 Latest | MSI GenAI support, OAuth2 shell permissions, user ID tracking | +| v0.2.5 | Released | Image attachments with PII detection and approval workflow | | v0.2.3 | Released | Confluence/ServiceNow/ADO REST API clients (19 TDD tests) | | v0.1.1 | Released | Core application with PII detection, RCA generation | diff --git a/docs/wiki/IPC-Commands.md b/docs/wiki/IPC-Commands.md index d0b66498..ad931460 100644 --- a/docs/wiki/IPC-Commands.md +++ b/docs/wiki/IPC-Commands.md @@ -99,6 +99,34 @@ Rewrites file content with approved redactions. Records SHA-256 in audit log. Re --- +## Image Attachment Commands + +### `upload_image_attachment` +```typescript +uploadImageAttachmentCmd(issueId: string, filePath: string, piiWarningAcknowledged: boolean) → ImageAttachment +``` +Uploads an image file. Computes SHA-256, stores metadata in DB. Returns `ImageAttachment` record. + +### `list_image_attachments` +```typescript +listImageAttachmentsCmd(issueId: string) → ImageAttachment[] +``` +Lists all image attachments for an issue. + +### `delete_image_attachment` +```typescript +deleteImageAttachmentCmd(imageId: string) → void +``` +Deletes an image attachment from disk and database. + +### `upload_paste_image` +```typescript +uploadPasteImageCmd(issueId: string, base64Data: string, fileName: string, piiWarningAcknowledged: boolean) → ImageAttachment +``` +Uploads an image from clipboard paste (base64). Returns `ImageAttachment` record. + +--- + ## AI Commands ### `analyze_logs` diff --git a/kohakuhub-deployment-summary.md b/kohakuhub-deployment-summary.md new file mode 100644 index 00000000..e56dbecb --- /dev/null +++ b/kohakuhub-deployment-summary.md @@ -0,0 +1,97 @@ +# KohakuHub Deployment Summary + +## Description + +Deployed KohakuHub (a self-hosted HuggingFace-compatible model hub) on the existing Docker infrastructure at `172.0.0.29`, with NGINX reverse proxy at `172.0.0.30` and CoreDNS at `172.0.0.29`. + +**Stack deployed:** + +| Service | Image | Port(s) | Purpose | +|----------------------|---------------------------|--------------------------|------------------------| +| hub-ui | nginx:alpine | 28080:80 | Vue.js frontend SPA | +| hub-api | built from source | 127.0.0.1:48888:48888 | Python/FastAPI backend | +| minio | quay.io/minio/minio | 29001:9000, 29000:29000 | S3-compatible storage | +| lakefs | built from ./docker/lakefs| 127.0.0.1:28000:28000 | Git-style versioning | +| kohakuhub-postgres | postgres:15 | 127.0.0.1:25432:5432 | Metadata database | + +**FQDNs:** +- `ai-hub.tftsr.com` → NGINX → `172.0.0.29:28080` (web UI + API proxy) +- `ai-hub-files.tftsr.com` → NGINX → `172.0.0.29:29001` (MinIO S3 file access) + +All data stored locally under `/docker_mounts/kohakuhub/`. + +--- + +## Acceptance Criteria + +- [x] All 5 containers start and remain healthy +- [x] `https://ai-hub.tftsr.com` serves the KohakuHub Vue.js SPA +- [x] `https://ai-hub-files.tftsr.com` proxies to MinIO S3 API +- [x] DNS resolves both FQDNs to `172.0.0.30` (NGINX proxy) +- [x] hub-api connects to postgres and runs migrations on startup +- [x] MinIO `hub-storage` bucket is created automatically +- [x] LakeFS initializes with S3 blockstore backend +- [x] No port conflicts with existing stack services +- [x] Postgres container uses unique name (`kohakuhub-postgres`) to avoid conflict with `gogs_postgres_db` +- [x] API and LakeFS ports bound to `127.0.0.1` only (not externally exposed) + +--- + +## Work Implemented + +### Phase 1 — Docker Host (`172.0.0.29`) + +1. **Downloaded KohakuHub source** via GitHub archive tarball (git not installed on host) to `/docker_mounts/kohakuhub/` +2. **Generated secrets** using `openssl rand -hex 32/16` for SESSION_SECRET, ADMIN_TOKEN, DB_KEY, LAKEFS_KEY, DB_PASS +3. **Created `/docker_mounts/kohakuhub/.env`** with `UID=1000` / `GID=1000` for LakeFS container user mapping +4. **Created `/docker_mounts/kohakuhub/docker-compose.yml`** with production configuration: + - Absolute volume paths under `/docker_mounts/kohakuhub/` + - Secrets substituted in-place + - Postgres container renamed to `kohakuhub-postgres` + - API/LakeFS/Postgres bound to `127.0.0.1` only +5. **Built Vue.js frontends** using `docker run node:22-alpine` (Node.js not installed on host): + - `src/kohaku-hub-ui/dist/` — main SPA + - `src/kohaku-hub-admin/dist/` — admin portal +6. **Started stack** with `docker compose up -d --build`; hub-api recovered from initial postgres race condition on its own +7. **Verified** all containers `Up`, API docs at `:48888/docs` returning HTTP 200, `hub-storage` bucket auto-created + +### Phase 2 — NGINX Proxy (`172.0.0.30`) + +1. **Created `/etc/nginx/conf.d/ai-hub.conf`** — proxies `ai-hub.tftsr.com` → `172.0.0.29:28080` with `client_max_body_size 100G`, 3600s timeouts, LetsEncrypt SSL +2. **Created `/etc/nginx/conf.d/ai-hub-files.conf`** — proxies `ai-hub-files.tftsr.com` → `172.0.0.29:29001` with same settings +3. **Resolved write issue**: initial writes via `sudo tee` with piped password produced empty files (heredoc stdin conflict); corrected by writing to `/tmp` then `sudo cp` +4. **Validated and reloaded** NGINX: `nginx -t` passes (pre-existing `ssl_stapling` warnings are environment-wide, unrelated to these configs) + +### Phase 3 — CoreDNS (`172.0.0.29`) + +1. **Updated `/docker_mounts/coredns/tftsr.com.db`**: + - SOA serial: `1718910701` → `2026040501` + - Appended: `ai-hub.tftsr.com. 3600 IN A 172.0.0.30` + - Appended: `ai-hub-files.tftsr.com. 3600 IN A 172.0.0.30` +2. **Reloaded CoreDNS** via `docker kill --signal=SIGHUP coredns` + +--- + +## Testing Needed + +### Functional + +- [ ] **Browser test**: Navigate to `https://ai-hub.tftsr.com` and verify login page, registration, and model browsing work +- [ ] **Admin portal**: Navigate to `https://ai-hub.tftsr.com/admin` and verify admin dashboard is accessible with the ADMIN_TOKEN +- [ ] **Model upload**: Upload a test model file and verify it lands in MinIO under `hub-storage` +- [ ] **Git clone**: Clone a model repo via `git clone https://ai-hub.tftsr.com//.git` and verify Git LFS works +- [ ] **File download**: Verify `https://ai-hub-files.tftsr.com` properly serves file download redirects + +### Infrastructure + +- [ ] **Restart persistence**: `docker compose down && docker compose up -d` on 172.0.0.29 — verify all services restart cleanly and data persists +- [ ] **LakeFS credentials**: Check `/docker_mounts/kohakuhub/hub-meta/hub-api/credentials.env` for generated LakeFS credentials +- [ ] **Admin token recovery**: Run `docker logs hub-api | grep -i admin` to retrieve the admin token if needed +- [ ] **MinIO console**: Verify MinIO console accessible at `http://172.0.0.29:29000` (internal only) +- [ ] **Postgres connectivity**: `docker exec kohakuhub-postgres psql -U kohakuhub -c "\dt"` to confirm schema migration ran + +### Notes + +- Frontend builds used temporary `node:22-alpine` Docker containers since Node.js is not installed on `172.0.0.29`. If redeployment is needed, re-run the build steps or install Node.js on the host. +- The `ssl_stapling` warnings in NGINX are pre-existing across all vhosts on `172.0.0.30` and do not affect functionality. +- MinIO credentials (`minioadmin`/`minioadmin`) are defaults. Consider rotating via the MinIO console for production hardening. diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index fb04237f..5d8ca3f4 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -2416,6 +2416,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "infer" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb33622da908807a06f9513c19b3c1ad50fab3e4137d82a78107d502075aa199" +dependencies = [ + "cfb", +] + [[package]] name = "infer" version = "0.19.0" @@ -5611,7 +5620,7 @@ dependencies = [ "glob", "html5ever 0.29.1", "http 1.4.0", - "infer", + "infer 0.19.0", "json-patch", "kuchikiki", "log", @@ -5670,47 +5679,6 @@ dependencies = [ "utf-8", ] -[[package]] -name = "tftsr" -version = "0.1.0" -dependencies = [ - "aes-gcm", - "aho-corasick", - "anyhow", - "async-trait", - "base64 0.22.1", - "chrono", - "dirs 5.0.1", - "docx-rs", - "futures", - "hex", - "lazy_static", - "mockito", - "printpdf", - "rand 0.8.5", - "regex", - "reqwest 0.12.28", - "rusqlite", - "serde", - "serde_json", - "sha2", - "tauri", - "tauri-build", - "tauri-plugin-dialog", - "tauri-plugin-fs", - "tauri-plugin-http", - "tauri-plugin-shell", - "tauri-plugin-stronghold", - "thiserror 1.0.69", - "tokio", - "tokio-test", - "tracing", - "tracing-subscriber", - "urlencoding", - "uuid", - "warp", -] - [[package]] name = "thiserror" version = "1.0.69" @@ -6168,6 +6136,48 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "trcaa" +version = "0.1.0" +dependencies = [ + "aes-gcm", + "aho-corasick", + "anyhow", + "async-trait", + "base64 0.22.1", + "chrono", + "dirs 5.0.1", + "docx-rs", + "futures", + "hex", + "infer 0.15.0", + "lazy_static", + "mockito", + "printpdf", + "rand 0.8.5", + "regex", + "reqwest 0.12.28", + "rusqlite", + "serde", + "serde_json", + "sha2", + "tauri", + "tauri-build", + "tauri-plugin-dialog", + "tauri-plugin-fs", + "tauri-plugin-http", + "tauri-plugin-shell", + "tauri-plugin-stronghold", + "thiserror 1.0.69", + "tokio", + "tokio-test", + "tracing", + "tracing-subscriber", + "urlencoding", + "uuid", + "warp", +] + [[package]] name = "try-lock" version = "0.2.5" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 43526339..38120a56 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -43,6 +43,7 @@ rand = "0.8" lazy_static = "1.4" warp = "0.3" urlencoding = "2" +infer = "0.15" [dev-dependencies] tokio-test = "0.4" diff --git a/src-tauri/src/commands/db.rs b/src-tauri/src/commands/db.rs index e667d298..4419b222 100644 --- a/src-tauri/src/commands/db.rs +++ b/src-tauri/src/commands/db.rs @@ -1,8 +1,8 @@ use tauri::State; use crate::db::models::{ - AiConversation, AiMessage, Issue, IssueDetail, IssueFilter, IssueSummary, IssueUpdate, LogFile, - ResolutionStep, + AiConversation, AiMessage, ImageAttachment, Issue, IssueDetail, IssueFilter, IssueSummary, + IssueUpdate, LogFile, ResolutionStep, }; use crate::state::AppState; @@ -100,6 +100,32 @@ pub async fn get_issue( .filter_map(|r| r.ok()) .collect(); + // Load image attachments + let mut img_stmt = db + .prepare( + "SELECT id, issue_id, file_name, file_path, file_size, mime_type, upload_hash, uploaded_at, pii_warning_acknowledged, is_paste \ + FROM image_attachments WHERE issue_id = ?1 ORDER BY uploaded_at ASC", + ) + .map_err(|e| e.to_string())?; + let image_attachments: Vec = img_stmt + .query_map([&issue_id], |row| { + Ok(ImageAttachment { + id: row.get(0)?, + issue_id: row.get(1)?, + file_name: row.get(2)?, + file_path: row.get(3)?, + file_size: row.get(4)?, + mime_type: row.get(5)?, + upload_hash: row.get(6)?, + uploaded_at: row.get(7)?, + pii_warning_acknowledged: row.get::<_, i32>(8)? != 0, + is_paste: row.get::<_, i32>(9)? != 0, + }) + }) + .map_err(|e| e.to_string())? + .filter_map(|r| r.ok()) + .collect(); + // Load resolution steps (5-whys) let mut rs_stmt = db .prepare( @@ -148,6 +174,7 @@ pub async fn get_issue( Ok(IssueDetail { issue, log_files, + image_attachments, resolution_steps, conversations, }) @@ -265,6 +292,11 @@ pub async fn delete_issue(issue_id: String, state: State<'_, AppState>) -> Resul .map_err(|e| e.to_string())?; db.execute("DELETE FROM log_files WHERE issue_id = ?1", [&issue_id]) .map_err(|e| e.to_string())?; + db.execute( + "DELETE FROM image_attachments WHERE issue_id = ?1", + [&issue_id], + ) + .map_err(|e| e.to_string())?; db.execute( "DELETE FROM resolution_steps WHERE issue_id = ?1", [&issue_id], diff --git a/src-tauri/src/commands/image.rs b/src-tauri/src/commands/image.rs new file mode 100644 index 00000000..6c595101 --- /dev/null +++ b/src-tauri/src/commands/image.rs @@ -0,0 +1,280 @@ +use base64::Engine; +use sha2::Digest; +use std::path::Path; +use tauri::State; + +use crate::audit::log::write_audit_event; +use crate::db::models::{AuditEntry, ImageAttachment}; +use crate::state::AppState; + +const MAX_IMAGE_FILE_BYTES: u64 = 10 * 1024 * 1024; +const SUPPORTED_IMAGE_MIME_TYPES: [&str; 5] = [ + "image/png", + "image/jpeg", + "image/gif", + "image/webp", + "image/svg+xml", +]; + +fn validate_image_file_path(file_path: &str) -> Result { + let path = Path::new(file_path); + let canonical = std::fs::canonicalize(path).map_err(|_| "Unable to access selected file")?; + let metadata = std::fs::metadata(&canonical).map_err(|_| "Unable to read file metadata")?; + + if !metadata.is_file() { + return Err("Selected path is not a file".to_string()); + } + + if metadata.len() > MAX_IMAGE_FILE_BYTES { + return Err(format!( + "Image file exceeds maximum supported size ({} MB)", + MAX_IMAGE_FILE_BYTES / 1024 / 1024 + )); + } + + Ok(canonical) +} + +fn is_supported_image_format(mime_type: &str) -> bool { + SUPPORTED_IMAGE_MIME_TYPES.contains(&mime_type) +} + +#[tauri::command] +pub async fn upload_image_attachment( + issue_id: String, + file_path: String, + state: State<'_, AppState>, +) -> Result { + let canonical_path = validate_image_file_path(&file_path)?; + let content = + std::fs::read(&canonical_path).map_err(|_| "Failed to read selected image file")?; + let content_hash = format!("{:x}", sha2::Sha256::digest(&content)); + let file_name = canonical_path + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("unknown") + .to_string(); + let file_size = content.len() as i64; + let mime_type: String = infer::get(&content) + .map(|m| m.mime_type().to_string()) + .unwrap_or_else(|| "image/png".to_string()); + + if !is_supported_image_format(mime_type.as_str()) { + return Err(format!( + "Unsupported image format: {}. Supported formats: {}", + mime_type, + SUPPORTED_IMAGE_MIME_TYPES.join(", ") + )); + } + + let canonical_file_path = canonical_path.to_string_lossy().to_string(); + let attachment = ImageAttachment::new( + issue_id.clone(), + file_name, + canonical_file_path, + file_size, + mime_type, + content_hash.clone(), + true, + false, + ); + + let db = state.db.lock().map_err(|e| e.to_string())?; + db.execute( + "INSERT INTO image_attachments (id, issue_id, file_name, file_path, file_size, mime_type, upload_hash, uploaded_at, pii_warning_acknowledged, is_paste) \ + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)", + rusqlite::params![ + attachment.id, + attachment.issue_id, + attachment.file_name, + attachment.file_path, + attachment.file_size, + attachment.mime_type, + attachment.upload_hash, + attachment.uploaded_at, + attachment.pii_warning_acknowledged as i32, + attachment.is_paste as i32, + ], + ) + .map_err(|_| "Failed to store uploaded image metadata".to_string())?; + + let entry = AuditEntry::new( + "upload_image_attachment".to_string(), + "image_attachment".to_string(), + attachment.id.clone(), + serde_json::json!({ + "issue_id": issue_id, + "file_name": attachment.file_name, + "is_paste": false, + }) + .to_string(), + ); + if let Err(err) = write_audit_event( + &db, + &entry.action, + &entry.entity_type, + &entry.entity_id, + &entry.details, + ) { + tracing::warn!(error = %err, "failed to write upload_image_attachment audit entry"); + } + + Ok(attachment) +} + +#[tauri::command] +pub async fn upload_paste_image( + issue_id: String, + base64_image: String, + mime_type: String, + state: State<'_, AppState>, +) -> Result { + if !base64_image.starts_with("data:image/") { + return Err("Invalid image data - must be a data URL".to_string()); + } + + let data_part = base64_image + .split(',') + .nth(1) + .ok_or("Invalid image data format - missing base64 content")?; + + let decoded = base64::engine::general_purpose::STANDARD.decode(data_part).map_err(|_| "Failed to decode base64 image data")?; + + let content_hash = format!("{:x}", sha2::Sha256::digest(&decoded)); + let file_size = decoded.len() as i64; + let file_name = format!("pasted-image-{}.png", uuid::Uuid::now_v7()); + + if !is_supported_image_format(mime_type.as_str()) { + return Err(format!( + "Unsupported image format: {}. Supported formats: {}", + mime_type, + SUPPORTED_IMAGE_MIME_TYPES.join(", ") + )); + } + + let attachment = ImageAttachment::new( + issue_id.clone(), + file_name.clone(), + String::new(), + file_size, + mime_type, + content_hash, + true, + true, + ); + + let db = state.db.lock().map_err(|e| e.to_string())?; + db.execute( + "INSERT INTO image_attachments (id, issue_id, file_name, file_path, file_size, mime_type, upload_hash, uploaded_at, pii_warning_acknowledged, is_paste) \ + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)", + rusqlite::params![ + attachment.id, + attachment.issue_id, + attachment.file_name, + attachment.file_path, + attachment.file_size, + attachment.mime_type, + attachment.upload_hash, + attachment.uploaded_at, + attachment.pii_warning_acknowledged as i32, + attachment.is_paste as i32, + ], + ) + .map_err(|_| "Failed to store pasted image metadata".to_string())?; + + let entry = AuditEntry::new( + "upload_paste_image".to_string(), + "image_attachment".to_string(), + attachment.id.clone(), + serde_json::json!({ + "issue_id": issue_id, + "file_name": attachment.file_name, + "is_paste": true, + }) + .to_string(), + ); + if let Err(err) = write_audit_event( + &db, + &entry.action, + &entry.entity_type, + &entry.entity_id, + &entry.details, + ) { + tracing::warn!(error = %err, "failed to write upload_paste_image audit entry"); + } + + Ok(attachment) +} + +#[tauri::command] +pub async fn list_image_attachments( + issue_id: String, + state: State<'_, AppState>, +) -> Result, String> { + let db = state.db.lock().map_err(|e| e.to_string())?; + + let mut stmt = db + .prepare( + "SELECT id, issue_id, file_name, file_path, file_size, mime_type, upload_hash, uploaded_at, pii_warning_acknowledged, is_paste \ + FROM image_attachments WHERE issue_id = ?1 ORDER BY uploaded_at ASC", + ) + .map_err(|e| e.to_string())?; + + let attachments = stmt + .query_map([&issue_id], |row| { + Ok(ImageAttachment { + id: row.get(0)?, + issue_id: row.get(1)?, + file_name: row.get(2)?, + file_path: row.get(3)?, + file_size: row.get(4)?, + mime_type: row.get(5)?, + upload_hash: row.get(6)?, + uploaded_at: row.get(7)?, + pii_warning_acknowledged: row.get::<_, i32>(8)? != 0, + is_paste: row.get::<_, i32>(9)? != 0, + }) + }) + .map_err(|e| e.to_string())? + .filter_map(|r| r.ok()) + .collect(); + + Ok(attachments) +} + +#[tauri::command] +pub async fn delete_image_attachment( + attachment_id: String, + state: State<'_, AppState>, +) -> Result<(), String> { + let db = state.db.lock().map_err(|e| e.to_string())?; + + let affected = db + .execute( + "DELETE FROM image_attachments WHERE id = ?1", + [&attachment_id], + ) + .map_err(|e| e.to_string())?; + + if affected == 0 { + return Err("Image attachment not found".to_string()); + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_is_supported_image_format() { + assert!(is_supported_image_format("image/png")); + assert!(is_supported_image_format("image/jpeg")); + assert!(is_supported_image_format("image/gif")); + assert!(is_supported_image_format("image/webp")); + assert!(is_supported_image_format("image/svg+xml")); + assert!(!is_supported_image_format("image/bmp")); + assert!(!is_supported_image_format("text/plain")); + } +} diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 1092f243..6184fac6 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -2,5 +2,6 @@ pub mod ai; pub mod analysis; pub mod db; pub mod docs; +pub mod image; pub mod integrations; pub mod system; diff --git a/src-tauri/src/db/migrations.rs b/src-tauri/src/db/migrations.rs index 938e53fc..1fc6a875 100644 --- a/src-tauri/src/db/migrations.rs +++ b/src-tauri/src/db/migrations.rs @@ -155,6 +155,21 @@ pub fn run_migrations(conn: &Connection) -> anyhow::Result<()> { "ALTER TABLE audit_log ADD COLUMN prev_hash TEXT NOT NULL DEFAULT ''; ALTER TABLE audit_log ADD COLUMN entry_hash TEXT NOT NULL DEFAULT '';", ), + ( + "013_image_attachments", + "CREATE TABLE IF NOT EXISTS image_attachments ( + id TEXT PRIMARY KEY, + issue_id TEXT NOT NULL REFERENCES issues(id) ON DELETE CASCADE, + file_name TEXT NOT NULL, + file_path TEXT NOT NULL DEFAULT '', + file_size INTEGER NOT NULL DEFAULT 0, + mime_type TEXT NOT NULL DEFAULT 'image/png', + upload_hash TEXT NOT NULL DEFAULT '', + uploaded_at TEXT NOT NULL DEFAULT (datetime('now')), + pii_warning_acknowledged INTEGER NOT NULL DEFAULT 1, + is_paste INTEGER NOT NULL DEFAULT 0 + );", + ), ]; for (name, sql) in migrations { @@ -192,21 +207,21 @@ mod tests { } #[test] - fn test_create_credentials_table() { + fn test_create_image_attachments_table() { let conn = setup_test_db(); - // Verify table exists let count: i64 = conn .query_row( - "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='credentials'", + "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='image_attachments'", [], |r| r.get(0), ) .unwrap(); assert_eq!(count, 1); - // Verify columns - let mut stmt = conn.prepare("PRAGMA table_info(credentials)").unwrap(); + let mut stmt = conn + .prepare("PRAGMA table_info(image_attachments)") + .unwrap(); let columns: Vec = stmt .query_map([], |row| row.get::<_, String>(1)) .unwrap() @@ -214,11 +229,15 @@ mod tests { .unwrap(); assert!(columns.contains(&"id".to_string())); - assert!(columns.contains(&"service".to_string())); - assert!(columns.contains(&"token_hash".to_string())); - assert!(columns.contains(&"encrypted_token".to_string())); - assert!(columns.contains(&"created_at".to_string())); - assert!(columns.contains(&"expires_at".to_string())); + assert!(columns.contains(&"issue_id".to_string())); + assert!(columns.contains(&"file_name".to_string())); + assert!(columns.contains(&"file_path".to_string())); + assert!(columns.contains(&"file_size".to_string())); + assert!(columns.contains(&"mime_type".to_string())); + assert!(columns.contains(&"upload_hash".to_string())); + assert!(columns.contains(&"uploaded_at".to_string())); + assert!(columns.contains(&"pii_warning_acknowledged".to_string())); + assert!(columns.contains(&"is_paste".to_string())); } #[test] @@ -389,4 +408,64 @@ mod tests { assert_eq!(count, 1); } + + #[test] + fn test_store_and_retrieve_image_attachment() { + let conn = setup_test_db(); + + // Create an issue first (required for foreign key) + let now = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string(); + conn.execute( + "INSERT INTO issues (id, title, description, severity, status, category, source, created_at, updated_at, resolved_at, assigned_to, tags) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)", + rusqlite::params![ + "test-issue-1", + "Test Issue", + "Test description", + "medium", + "open", + "test", + "manual", + now.clone(), + now.clone(), + None::>, + "", + "[]", + ], + ) + .unwrap(); + + // Now insert the image attachment + conn.execute( + "INSERT INTO image_attachments (id, issue_id, file_name, file_path, file_size, mime_type, upload_hash, uploaded_at, pii_warning_acknowledged, is_paste) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)", + rusqlite::params![ + "test-img-1", + "test-issue-1", + "screenshot.png", + "/path/to/screenshot.png", + 102400, + "image/png", + "abc123hash", + now, + 1, + 0, + ], + ) + .unwrap(); + + let (id, issue_id, file_name, mime_type, is_paste): (String, String, String, String, i32) = conn + .query_row( + "SELECT id, issue_id, file_name, mime_type, is_paste FROM image_attachments WHERE id = ?1", + ["test-img-1"], + |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?, r.get(4)?)), + ) + .unwrap(); + + assert_eq!(id, "test-img-1"); + assert_eq!(issue_id, "test-issue-1"); + assert_eq!(file_name, "screenshot.png"); + assert_eq!(mime_type, "image/png"); + assert_eq!(is_paste, 0); + } } diff --git a/src-tauri/src/db/migrations.rs.bak b/src-tauri/src/db/migrations.rs.bak new file mode 100644 index 00000000..1eeb4b64 --- /dev/null +++ b/src-tauri/src/db/migrations.rs.bak @@ -0,0 +1,482 @@ +use rusqlite::Connection; + +/// Run all database migrations in order, tracking which have been applied. +pub fn run_migrations(conn: &Connection) -> anyhow::Result<()> { + conn.execute_batch( + "CREATE TABLE IF NOT EXISTS _migrations ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + applied_at TEXT NOT NULL DEFAULT (datetime('now')) + );", + )?; + + let migrations: &[(&str, &str)] = &[ + ( + "001_create_issues", + "CREATE TABLE IF NOT EXISTS issues ( + id TEXT PRIMARY KEY, + title TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + severity TEXT NOT NULL DEFAULT 'medium', + status TEXT NOT NULL DEFAULT 'open', + category TEXT NOT NULL DEFAULT 'general', + source TEXT NOT NULL DEFAULT 'manual', + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + resolved_at TEXT, + assigned_to TEXT NOT NULL DEFAULT '', + tags TEXT NOT NULL DEFAULT '[]' + );", + ), + ( + "002_create_log_files", + "CREATE TABLE IF NOT EXISTS log_files ( + id TEXT PRIMARY KEY, + issue_id TEXT NOT NULL REFERENCES issues(id) ON DELETE CASCADE, + file_name TEXT NOT NULL, + file_path TEXT NOT NULL DEFAULT '', + file_size INTEGER NOT NULL DEFAULT 0, + mime_type TEXT NOT NULL DEFAULT 'text/plain', + content_hash TEXT NOT NULL DEFAULT '', + uploaded_at TEXT NOT NULL DEFAULT (datetime('now')), + redacted INTEGER NOT NULL DEFAULT 0 + );", + ), + ( + "003_create_pii_spans", + "CREATE TABLE IF NOT EXISTS pii_spans ( + id TEXT PRIMARY KEY, + log_file_id TEXT NOT NULL REFERENCES log_files(id) ON DELETE CASCADE, + pii_type TEXT NOT NULL, + start_offset INTEGER NOT NULL, + end_offset INTEGER NOT NULL, + original_value TEXT NOT NULL, + replacement TEXT NOT NULL + );", + ), + ( + "004_create_ai_conversations", + "CREATE TABLE IF NOT EXISTS ai_conversations ( + id TEXT PRIMARY KEY, + issue_id TEXT NOT NULL REFERENCES issues(id) ON DELETE CASCADE, + provider TEXT NOT NULL, + model TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + title TEXT NOT NULL DEFAULT 'Untitled' + );", + ), + ( + "005_create_ai_messages", + "CREATE TABLE IF NOT EXISTS ai_messages ( + id TEXT PRIMARY KEY, + conversation_id TEXT NOT NULL REFERENCES ai_conversations(id) ON DELETE CASCADE, + role TEXT NOT NULL CHECK(role IN ('system','user','assistant')), + content TEXT NOT NULL, + token_count INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + );", + ), + ( + "006_create_resolution_steps", + "CREATE TABLE IF NOT EXISTS resolution_steps ( + id TEXT PRIMARY KEY, + issue_id TEXT NOT NULL REFERENCES issues(id) ON DELETE CASCADE, + step_order INTEGER NOT NULL DEFAULT 0, + why_question TEXT NOT NULL DEFAULT '', + answer TEXT NOT NULL DEFAULT '', + evidence TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL DEFAULT (datetime('now')) + );", + ), + ( + "007_create_documents", + "CREATE TABLE IF NOT EXISTS documents ( + id TEXT PRIMARY KEY, + issue_id TEXT NOT NULL REFERENCES issues(id) ON DELETE CASCADE, + doc_type TEXT NOT NULL, + title TEXT NOT NULL, + content_md TEXT NOT NULL, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + );", + ), + ( + "008_create_audit_log", + "CREATE TABLE IF NOT EXISTS audit_log ( + id TEXT PRIMARY KEY, + timestamp TEXT NOT NULL DEFAULT (datetime('now')), + action TEXT NOT NULL, + entity_type TEXT NOT NULL DEFAULT '', + entity_id TEXT NOT NULL DEFAULT '', + user_id TEXT NOT NULL DEFAULT 'local', + details TEXT NOT NULL DEFAULT '{}' + );", + ), + ( + "009_create_settings", + "CREATE TABLE IF NOT EXISTS settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL DEFAULT '', + updated_at TEXT NOT NULL DEFAULT (datetime('now')) + );", + ), + ( + "010_issues_fts", + "CREATE VIRTUAL TABLE IF NOT EXISTS issues_fts USING fts5( + id UNINDEXED, title, description, + content='issues', content_rowid='rowid' + );", + ), + ( + "011_create_integrations", + "CREATE TABLE IF NOT EXISTS credentials ( + id TEXT PRIMARY KEY, + service TEXT NOT NULL CHECK(service IN ('confluence','servicenow','azuredevops')), + token_hash TEXT NOT NULL, + encrypted_token TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + expires_at TEXT, + UNIQUE(service) + ); + CREATE TABLE IF NOT EXISTS integration_config ( + id TEXT PRIMARY KEY, + service TEXT NOT NULL CHECK(service IN ('confluence','servicenow','azuredevops')), + base_url TEXT NOT NULL, + username TEXT, + project_name TEXT, + space_key TEXT, + auto_create_enabled INTEGER NOT NULL DEFAULT 0, + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE(service) + );", + ), + ( + "012_audit_hash_chain", + "ALTER TABLE audit_log ADD COLUMN prev_hash TEXT NOT NULL DEFAULT ''; + ALTER TABLE audit_log ADD COLUMN entry_hash TEXT NOT NULL DEFAULT '';", + ), + ( + "013_image_attachments", + "CREATE TABLE IF NOT EXISTS image_attachments ( + id TEXT PRIMARY KEY, + issue_id TEXT NOT NULL REFERENCES issues(id) ON DELETE CASCADE, + file_name TEXT NOT NULL, + file_path TEXT NOT NULL DEFAULT '', + file_size INTEGER NOT NULL DEFAULT 0, + mime_type TEXT NOT NULL DEFAULT 'image/png', + upload_hash TEXT NOT NULL DEFAULT '', + uploaded_at TEXT NOT NULL DEFAULT (datetime('now')), + pii_warning_acknowledged INTEGER NOT NULL DEFAULT 1, + is_paste INTEGER NOT NULL DEFAULT 0 + );", + ), + ]; + + for (name, sql) in migrations { + let already_applied: bool = conn + .prepare("SELECT COUNT(*) FROM _migrations WHERE name = ?1")? + .query_row([name], |row| row.get::<_, i64>(0)) + .map(|count| count > 0)?; + + if !already_applied { + // FTS5 virtual table creation can be skipped if FTS5 is not compiled in + if let Err(e) = conn.execute_batch(sql) { + if name.contains("fts") { + tracing::warn!("FTS5 not available, skipping: {e}"); + } else { + return Err(e.into()); + } + } + conn.execute("INSERT INTO _migrations (name) VALUES (?1)", [name])?; + tracing::info!("Applied migration: {name}"); + } + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use rusqlite::Connection; + + fn setup_test_db() -> Connection { + let conn = Connection::open_in_memory().unwrap(); + run_migrations(&conn).unwrap(); + conn + } + + #[test] + fn test_create_integration_config_table() { + let conn = setup_test_db(); + + let count: i64 = conn + .query_row( + "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='integration_config'", + [], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(count, 1); + + let mut stmt = conn + .prepare("PRAGMA table_info(integration_config)") + .unwrap(); + let columns: Vec = stmt + .query_map([], |row| row.get::<_, String>(1)) + .unwrap() + .collect::, _>>() + .unwrap(); + + assert!(columns.contains(&"id".to_string())); + assert!(columns.contains(&"service".to_string())); + assert!(columns.contains(&"base_url".to_string())); + assert!(columns.contains(&"username".to_string())); + assert!(columns.contains(&"project_name".to_string())); + assert!(columns.contains(&"space_key".to_string())); + assert!(columns.contains(&"auto_create_enabled".to_string())); + assert!(columns.contains(&"updated_at".to_string())); + } + + #[test] + fn test_create_image_attachments_table() { + let conn = setup_test_db(); + + let count: i64 = conn + .query_row( + "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='image_attachments'", + [], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(count, 1); + + let mut stmt = conn + .prepare("PRAGMA table_info(image_attachments)") + .unwrap(); + let columns: Vec = stmt + .query_map([], |row| row.get::<_, String>(1)) + .unwrap() + .collect::, _>>() + .unwrap(); + + assert!(columns.contains(&"id".to_string())); + assert!(columns.contains(&"issue_id".to_string())); + assert!(columns.contains(&"file_name".to_string())); + assert!(columns.contains(&"file_path".to_string())); + assert!(columns.contains(&"file_size".to_string())); + assert!(columns.contains(&"mime_type".to_string())); + assert!(columns.contains(&"upload_hash".to_string())); + assert!(columns.contains(&"uploaded_at".to_string())); + assert!(columns.contains(&"pii_warning_acknowledged".to_string())); + assert!(columns.contains(&"is_paste".to_string())); + } + + #[test] + fn test_create_integration_config_table() { + let conn = setup_test_db(); + + // Verify table exists + let count: i64 = conn + .query_row( + "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='integration_config'", + [], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(count, 1); + + // Verify columns + let mut stmt = conn + .prepare("PRAGMA table_info(integration_config)") + .unwrap(); + let columns: Vec = stmt + .query_map([], |row| row.get::<_, String>(1)) + .unwrap() + .collect::, _>>() + .unwrap(); + + assert!(columns.contains(&"id".to_string())); + assert!(columns.contains(&"service".to_string())); + assert!(columns.contains(&"base_url".to_string())); + assert!(columns.contains(&"username".to_string())); + assert!(columns.contains(&"project_name".to_string())); + assert!(columns.contains(&"space_key".to_string())); + assert!(columns.contains(&"auto_create_enabled".to_string())); + assert!(columns.contains(&"updated_at".to_string())); + } + + #[test] + fn test_store_and_retrieve_credential() { + let conn = setup_test_db(); + + // Insert credential + conn.execute( + "INSERT INTO credentials (id, service, token_hash, encrypted_token, created_at) + VALUES (?1, ?2, ?3, ?4, ?5)", + rusqlite::params![ + "test-id", + "confluence", + "test_hash", + "encrypted_test", + chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string() + ], + ) + .unwrap(); + + // Retrieve + let (service, token_hash): (String, String) = conn + .query_row( + "SELECT service, token_hash FROM credentials WHERE service = ?1", + ["confluence"], + |r| Ok((r.get(0)?, r.get(1)?)), + ) + .unwrap(); + + assert_eq!(service, "confluence"); + assert_eq!(token_hash, "test_hash"); + } + + #[test] + fn test_store_and_retrieve_integration_config() { + let conn = setup_test_db(); + + // Insert config + conn.execute( + "INSERT INTO integration_config (id, service, base_url, space_key, auto_create_enabled, updated_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6)", + rusqlite::params![ + "test-config-id", + "confluence", + "https://example.atlassian.net", + "DEV", + 1, + chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string() + ], + ) + .unwrap(); + + // Retrieve + let (service, base_url, space_key, auto_create): (String, String, String, i32) = conn + .query_row( + "SELECT service, base_url, space_key, auto_create_enabled FROM integration_config WHERE service = ?1", + ["confluence"], + |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?)), + ) + .unwrap(); + + assert_eq!(service, "confluence"); + assert_eq!(base_url, "https://example.atlassian.net"); + assert_eq!(space_key, "DEV"); + assert_eq!(auto_create, 1); + } + + #[test] + fn test_service_uniqueness_constraint() { + let conn = setup_test_db(); + + // Insert first credential + conn.execute( + "INSERT INTO credentials (id, service, token_hash, encrypted_token, created_at) + VALUES (?1, ?2, ?3, ?4, ?5)", + rusqlite::params![ + "test-id-1", + "confluence", + "hash1", + "token1", + chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string() + ], + ) + .unwrap(); + + // Try to insert duplicate service - should fail + let result = conn.execute( + "INSERT INTO credentials (id, service, token_hash, encrypted_token, created_at) + VALUES (?1, ?2, ?3, ?4, ?5)", + rusqlite::params![ + "test-id-2", + "confluence", + "hash2", + "token2", + chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string() + ], + ); + + assert!(result.is_err()); + } + + #[test] + fn test_migration_tracking() { + let conn = setup_test_db(); + + // Verify migration 011 was applied + let applied: i64 = conn + .query_row( + "SELECT COUNT(*) FROM _migrations WHERE name = ?1", + ["011_create_integrations"], + |r| r.get(0), + ) + .unwrap(); + + assert_eq!(applied, 1); + } + + #[test] + fn test_migrations_idempotent() { + let conn = Connection::open_in_memory().unwrap(); + + // Run migrations twice + run_migrations(&conn).unwrap(); + run_migrations(&conn).unwrap(); + + // Verify migration was only recorded once + let count: i64 = conn + .query_row( + "SELECT COUNT(*) FROM _migrations WHERE name = ?1", + ["011_create_integrations"], + |r| r.get(0), + ) + .unwrap(); + + assert_eq!(count, 1); + } + + #[test] + fn test_store_and_retrieve_image_attachment() { + let conn = setup_test_db(); + + let now = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string(); + + conn.execute( + "INSERT INTO image_attachments (id, issue_id, file_name, file_path, file_size, mime_type, upload_hash, uploaded_at, pii_warning_acknowledged, is_paste) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)", + rusqlite::params![ + "test-img-1", + "test-issue-1", + "screenshot.png", + "/path/to/screenshot.png", + 102400, + "image/png", + "abc123hash", + now, + 1, + 0, + ], + ) + .unwrap(); + + let (id, issue_id, file_name, mime_type, is_paste): (String, String, String, String, i32) = conn + .query_row( + "SELECT id, issue_id, file_name, mime_type, is_paste FROM image_attachments WHERE id = ?1", + ["test-img-1"], + |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?, r.get(4)?)), + ) + .unwrap(); + + assert_eq!(id, "test-img-1"); + assert_eq!(issue_id, "test-issue-1"); + assert_eq!(file_name, "screenshot.png"); + assert_eq!(mime_type, "image/png"); + assert_eq!(is_paste, 0); + } +} diff --git a/src-tauri/src/db/models.rs b/src-tauri/src/db/models.rs index 6f06b850..1524c587 100644 --- a/src-tauri/src/db/models.rs +++ b/src-tauri/src/db/models.rs @@ -44,6 +44,7 @@ impl Issue { pub struct IssueDetail { pub issue: Issue, pub log_files: Vec, + pub image_attachments: Vec, pub resolution_steps: Vec, pub conversations: Vec, } @@ -392,3 +393,46 @@ impl IntegrationConfig { } } } + +// ─── Image Attachment ──────────────────────────────────────────────────────────── + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ImageAttachment { + pub id: String, + pub issue_id: String, + pub file_name: String, + pub file_path: String, + pub file_size: i64, + pub mime_type: String, + pub upload_hash: String, + pub uploaded_at: String, + pub pii_warning_acknowledged: bool, + pub is_paste: bool, +} + +impl ImageAttachment { + #[allow(clippy::too_many_arguments)] + pub fn new( + issue_id: String, + file_name: String, + file_path: String, + file_size: i64, + mime_type: String, + upload_hash: String, + pii_warning_acknowledged: bool, + is_paste: bool, + ) -> Self { + ImageAttachment { + id: Uuid::now_v7().to_string(), + issue_id, + file_name, + file_path, + file_size, + mime_type, + upload_hash, + uploaded_at: chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string(), + pii_warning_acknowledged, + is_paste, + } + } +} diff --git a/src-tauri/src/docs/postmortem.rs b/src-tauri/src/docs/postmortem.rs index 8a8df607..abddaaf2 100644 --- a/src-tauri/src/docs/postmortem.rs +++ b/src-tauri/src/docs/postmortem.rs @@ -177,6 +177,7 @@ mod tests { tags: "[]".to_string(), }, log_files: vec![], + image_attachments: vec![], resolution_steps: vec![ResolutionStep { id: "rs-pm-1".to_string(), issue_id: "pm-456".to_string(), diff --git a/src-tauri/src/docs/rca.rs b/src-tauri/src/docs/rca.rs index 66836996..f6d508bc 100644 --- a/src-tauri/src/docs/rca.rs +++ b/src-tauri/src/docs/rca.rs @@ -172,6 +172,7 @@ mod tests { uploaded_at: "2025-01-15 10:30:00".to_string(), redacted: false, }], + image_attachments: vec![], resolution_steps: vec![ ResolutionStep { id: "rs-1".to_string(), diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 6b147e2a..3f99e8cf 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -73,6 +73,10 @@ pub fn run() { commands::analysis::upload_log_file, commands::analysis::detect_pii, commands::analysis::apply_redactions, + commands::image::upload_image_attachment, + commands::image::list_image_attachments, + commands::image::delete_image_attachment, + commands::image::upload_paste_image, // AI commands::ai::analyze_logs, commands::ai::chat_message, diff --git a/src/components/ImageGallery.tsx b/src/components/ImageGallery.tsx new file mode 100644 index 00000000..727773d6 --- /dev/null +++ b/src/components/ImageGallery.tsx @@ -0,0 +1,165 @@ +import React, { useState, useRef, useEffect } from "react"; +import { X, AlertTriangle, ExternalLink, Image as ImageIcon } from "lucide-react"; +import type { ImageAttachment } from "@/lib/tauriCommands"; + +interface ImageGalleryProps { + images: ImageAttachment[]; + onDelete?: (attachment: ImageAttachment) => void; + showWarning?: boolean; +} + +export function ImageGallery({ images, onDelete, showWarning = true }: ImageGalleryProps) { + const [selectedImage, setSelectedImage] = useState(null); + const [isModalOpen, setIsModalOpen] = useState(false); + const modalRef = useRef(null); + + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === "Escape" && isModalOpen) { + setIsModalOpen(false); + setSelectedImage(null); + } + }; + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [isModalOpen]); + + if (images.length === 0) return null; + + const base64ToDataUrl = (base64: string, mimeType: string): string => { + if (base64.startsWith("data:image/")) { + return base64; + } + return `data:${mimeType};base64,${base64}`; + }; + + const getPreviewUrl = (attachment: ImageAttachment): string => { + if (attachment.file_path && attachment.file_path.length > 0) { + return `file://${attachment.file_path}`; + } + return base64ToDataUrl(attachment.upload_hash, attachment.mime_type); + }; + + const isWebSource = (image: ImageAttachment): boolean => { + return image.file_path.length > 0 && + (image.file_path.startsWith("http://") || + image.file_path.startsWith("https://")); + }; + + return ( +
+ {showWarning && ( +
+ + + ⚠️ PII cannot be automatically redacted from images. Use at your own risk. + +
+ )} + + {images.some(img => isWebSource(img)) && ( +
+ + + ⚠️ Some images appear to be from web sources. Ensure you have permission to share. + +
+ )} + +
+ {images.map((image, idx) => ( +
+ +
+

+ {image.file_name} +

+

+ {image.is_paste ? "Paste" : "Upload"} · {(image.file_size / 1024).toFixed(1)} KB +

+
+ {onDelete && ( + + )} +
+ ))} +
+ + {isModalOpen && selectedImage && ( +
{ + setIsModalOpen(false); + setSelectedImage(null); + }} + > +
e.stopPropagation()} + > +
+
+ +

{selectedImage.file_name}

+
+ +
+
+ {selectedImage.file_name} +
+
+
+
+ Type: {selectedImage.mime_type} +
+
+ Size: {(selectedImage.file_size / 1024).toFixed(2)} KB +
+
+ Source: {selectedImage.is_paste ? "Paste" : "File"} +
+
+
+
+
+ )} +
+ ); +} + +export default ImageGallery; diff --git a/src/lib/tauriCommands.ts b/src/lib/tauriCommands.ts index b1711134..3a16e5dc 100644 --- a/src/lib/tauriCommands.ts +++ b/src/lib/tauriCommands.ts @@ -100,6 +100,7 @@ export interface ResolutionStep { export interface IssueDetail { issue: Issue; log_files: LogFile[]; + image_attachments: ImageAttachment[]; resolution_steps: ResolutionStep[]; conversations: AiConversation[]; } @@ -145,6 +146,19 @@ export interface LogFile { redacted: boolean; } +export interface ImageAttachment { + id: string; + issue_id: string; + file_name: string; + file_path: string; + file_size: number; + mime_type: string; + upload_hash: string; + uploaded_at: string; + pii_warning_acknowledged: boolean; + is_paste: boolean; +} + export interface PiiSpan { id: string; pii_type: string; @@ -263,6 +277,18 @@ export const listProvidersCmd = () => invoke("list_providers"); export const uploadLogFileCmd = (issueId: string, filePath: string) => invoke("upload_log_file", { issueId, filePath }); +export const uploadImageAttachmentCmd = (issueId: string, filePath: string) => + invoke("upload_image_attachment", { issueId, filePath }); + +export const uploadPasteImageCmd = (issueId: string, base64Image: string, mimeType: string) => + invoke("upload_paste_image", { issueId, base64Image, mimeType }); + +export const listImageAttachmentsCmd = (issueId: string) => + invoke("list_image_attachments", { issueId }); + +export const deleteImageAttachmentCmd = (attachmentId: string) => + invoke("delete_image_attachment", { attachmentId }); + export const detectPiiCmd = (logFileId: string) => invoke("detect_pii", { logFileId }); diff --git a/src/pages/LogUpload/index.tsx b/src/pages/LogUpload/index.tsx index b680f623..65e0f0fb 100644 --- a/src/pages/LogUpload/index.tsx +++ b/src/pages/LogUpload/index.tsx @@ -1,16 +1,22 @@ -import React, { useState, useCallback } from "react"; +import React, { useState, useCallback, useRef, useEffect } from "react"; import { useNavigate, useParams } from "react-router-dom"; -import { Upload, File, Trash2, ShieldCheck } from "lucide-react"; +import { Upload, File, Trash2, ShieldCheck, AlertTriangle, Image as ImageIcon } from "lucide-react"; import { Button, Card, CardHeader, CardTitle, CardContent, Badge } from "@/components/ui"; import { PiiDiffViewer } from "@/components/PiiDiffViewer"; import { useSessionStore } from "@/stores/sessionStore"; import { uploadLogFileCmd, detectPiiCmd, + uploadImageAttachmentCmd, + uploadPasteImageCmd, + listImageAttachmentsCmd, + deleteImageAttachmentCmd, type LogFile, type PiiSpan, type PiiDetectionResult, + type ImageAttachment, } from "@/lib/tauriCommands"; +import ImageGallery from "@/components/ImageGallery"; export default function LogUpload() { const { id } = useParams<{ id: string }>(); @@ -18,11 +24,14 @@ export default function LogUpload() { const { piiSpans, approvedRedactions, setPiiSpans, setApprovedRedactions } = useSessionStore(); const [files, setFiles] = useState<{ file: File; uploaded?: LogFile }[]>([]); + const [images, setImages] = useState([]); const [piiResult, setPiiResult] = useState(null); const [isUploading, setIsUploading] = useState(false); const [isDetecting, setIsDetecting] = useState(false); const [error, setError] = useState(null); + const fileInputRef = useRef(null); + const handleDrop = useCallback( (e: React.DragEvent) => { e.preventDefault(); @@ -96,9 +105,136 @@ export default function LogUpload() { } }; + const handleImageDrop = useCallback( + (e: React.DragEvent) => { + e.preventDefault(); + const droppedFiles = Array.from(e.dataTransfer.files); + const imageFiles = droppedFiles.filter((f) => f.type.startsWith("image/")); + + if (imageFiles.length > 0) { + handleImagesUpload(imageFiles); + } + }, + [id] + ); + + const handleImageFileSelect = (e: React.ChangeEvent) => { + if (e.target.files) { + const selected = Array.from(e.target.files).filter((f) => f.type.startsWith("image/")); + if (selected.length > 0) { + handleImagesUpload(selected); + } + } + }; + + const handlePaste = useCallback( + async (e: React.ClipboardEvent) => { + const items = e.clipboardData?.items; + const imageItems = items ? Array.from(items).filter((item: DataTransferItem) => item.type.startsWith("image/")) : []; + + for (const item of imageItems) { + const file = item.getAsFile(); + if (file) { + const reader = new FileReader(); + reader.onload = async () => { + const base64Data = reader.result as string; + try { + const result = await uploadPasteImageCmd(id || "", base64Data, file.type); + setImages((prev) => [...prev, result]); + } catch (err) { + setError(String(err)); + } + }; + reader.readAsDataURL(file); + } + } + }, + [id] + ); + + const handleImagesUpload = async (imageFiles: File[]) => { + if (!id || imageFiles.length === 0) return; + + setIsUploading(true); + setError(null); + try { + const uploaded = await Promise.all( + imageFiles.map(async (file) => { + const result = await uploadImageAttachmentCmd(id, file.name); + return result; + }) + ); + setImages((prev) => [...prev, ...uploaded]); + } catch (err) { + setError(String(err)); + } finally { + setIsUploading(false); + } + }; + + const handleDeleteImage = async (image: ImageAttachment) => { + try { + await deleteImageAttachmentCmd(image.id); + setImages((prev) => prev.filter((img) => img.id !== image.id)); + } catch (err) { + setError(String(err)); + } + }; + + const fileToBase64 = (file: File): Promise => { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(reader.result as string); + reader.onerror = (err) => reject(err); + reader.readAsDataURL(file); + }); + }; + + const allUploaded = files.length > 0 && files.every((f) => f.uploaded); const piiReviewed = piiResult != null; + useEffect(() => { + const handleGlobalPaste = (e: ClipboardEvent) => { + if (document.activeElement?.tagName === "INPUT" || + document.activeElement?.tagName === "TEXTAREA" || + (document.activeElement as HTMLElement)?.isContentEditable || false) { + return; + } + + const items = e.clipboardData?.items; + const imageItems = items ? Array.from(items).filter((item: DataTransferItem) => item.type.startsWith("image/")) : []; + + for (const item of imageItems) { + const file = item.getAsFile(); + if (file) { + e.preventDefault(); + const reader = new FileReader(); + reader.onload = async () => { + const base64Data = reader.result as string; + try { + const result = await uploadPasteImageCmd(id || "", base64Data, file.type); + setImages((prev) => [...prev, result]); + } catch (err) { + setError(String(err)); + } + }; + reader.readAsDataURL(file); + break; + } + } + }; + + window.addEventListener("paste", handleGlobalPaste); + return () => window.removeEventListener("paste", handleGlobalPaste); + }, [id]); + + useEffect(() => { + if (id) { + listImageAttachmentsCmd(id).then(setImages).catch(setError); + } + }, [id]); + return (
@@ -165,6 +301,87 @@ export default function LogUpload() { )} + {/* Image Upload */} + {id && ( + <> +
+

+ + Image Attachments +

+

+ Upload or paste screenshots and images. +

+
+ + {/* Image drop zone */} +
e.preventDefault()} + onDrop={handleImageDrop} + className="border-2 border-dashed border-primary/30 rounded-lg p-8 text-center hover:border-primary transition-colors cursor-pointer bg-primary/5" + onClick={() => document.getElementById("image-input")?.click()} + > + +

+ Drag and drop images here, or click to browse +

+

+ Supported: PNG, JPEG, GIF, WebP, SVG +

+ +
+ + {/* Paste button */} +
+ + + Use Ctrl+V / Cmd+V or the button above to paste images + +
+ + {/* PII warning for images */} +
+ + + ⚠️ PII cannot be automatically redacted from images. Use at your own risk. + +
+ + {/* Image Gallery */} + {images.length > 0 && ( + + + + + Attached Images ({images.length}) + + + + + + + )} + + )} + {/* PII Detection */} {allUploaded && ( diff --git a/tests/unit/resolution.test.tsx b/tests/unit/resolution.test.tsx index 1b93f250..de13e933 100644 --- a/tests/unit/resolution.test.tsx +++ b/tests/unit/resolution.test.tsx @@ -21,6 +21,7 @@ const mockIssueDetail = { tags: "[]", }, log_files: [], + image_attachments: [], resolution_steps: [ { id: "step-1",