From 43a5d2cc277b4928498d49e4fcd2fc3d311e653e Mon Sep 17 00:00:00 2001 From: ebarzin Date: Thu, 2 Jul 2026 07:49:12 +0000 Subject: [PATCH 01/55] immich(frame-emo): show photos from the last 365 days (was 730) Emil asked his Sofia Portal Mini photo-frame to show only the past year of photos rolling from today, instead of the last two years. Changes ImagesFromDays 730 -> 365 in the frame-emo Settings.yml. Co-Authored-By: Claude Opus 4.8 --- stacks/immich/frame-emo.tf | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/stacks/immich/frame-emo.tf b/stacks/immich/frame-emo.tf index 577d84af..6a9e08b6 100644 --- a/stacks/immich/frame-emo.tf +++ b/stacks/immich/frame-emo.tf @@ -34,7 +34,7 @@ resource "kubernetes_config_map" "frame_config_emo" { Accounts: - ImmichServerUrl: http://immich.viktorbarzin.me ApiKey: ${data.vault_kv_secret_v2.secrets.data["frame_api_key_emo"]} - ImagesFromDays: 730 + ImagesFromDays: 365 EOF } } From 6f03ccd1aad80e0161d0cdc8a92dfc6369413415 Mon Sep 17 00:00:00 2001 From: Viktor Barzin Date: Thu, 2 Jul 2026 11:08:28 +0000 Subject: [PATCH 02/55] excalidraw: grant emo-browser SA port-forward for drawing uploads Viktor asked to fix emo's permission so his Claude can upload to the Excalidraw service. emo's recent sessions show the documented upload recipe (kubectl port-forward svc/draw + X-Authentik-Username header, from his ~/.claude/CLAUDE.md) failing with: pods/portforward forbidden for system:serviceaccount:chrome-service:emo-browser in namespace excalidraw because his default kubeconfig is the read-only emo-browser SA (its port-forward grant covers only chrome-service) and his old admin kubeconfig at /home/emo/code/config expired and was removed. Add a namespace-scoped Role (pods/portforward create) + RoleBinding for that SA in the excalidraw namespace, mirroring the 2026-06-28 chrome-service grant. Trade-off (any-user drawings via the trusted username header) documented in the file and accepted. Also record the grant in docs/architecture/chrome-service.md. Co-Authored-By: Claude Fable 5 --- docs/architecture/chrome-service.md | 6 ++++ stacks/excalidraw/rbac.tf | 49 +++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+) create mode 100644 stacks/excalidraw/rbac.tf diff --git a/docs/architecture/chrome-service.md b/docs/architecture/chrome-service.md index 118c0895..f77518b4 100644 --- a/docs/architecture/chrome-service.md +++ b/docs/architecture/chrome-service.md @@ -329,6 +329,12 @@ Two independent grants make up "browser access" for a user: the provisioner. To revoke: remove from `CHROME_ALLOWED` and delete the SA (rotate a token by deleting its `-browser-token` Secret). +Because the SA is the user's DEFAULT kubectl credential, other per-namespace +port-forward grants hang off the same identity: `stacks/excalidraw/rbac.tf` +grants `emo-browser` `pods/portforward` in `excalidraw` (2026-07-02) so emo's +agent can upload drawings via the port-forward + `X-Authentik-Username` recipe +in his `~/.claude/CLAUDE.md`. Revoking the SA revokes those too. + ## Limits + risks - **Anti-bot vs stealth arms race** — when an upstream beats us (DRM diff --git a/stacks/excalidraw/rbac.tf b/stacks/excalidraw/rbac.tf new file mode 100644 index 00000000..a40898fa --- /dev/null +++ b/stacks/excalidraw/rbac.tf @@ -0,0 +1,49 @@ +# emo's Claude → Excalidraw upload RBAC. +# +# emo's agent uploads drawings with `kubectl -n excalidraw port-forward svc/draw` +# + `PUT /api/drawings/` carrying the X-Authentik-Username header (the +# documented recipe in emo's ~/.claude/CLAUDE.md — the app sits behind Authentik +# forward-auth, so direct curl gets redirected). His hands-off credential is the +# chrome-service/emo-browser ServiceAccount kubeconfig (stacks/chrome-service/rbac.tf); +# its cluster-wide grant (oidc-power-user-readonly) is read-only, so pods/portforward +# must be granted per namespace. This is the excalidraw-namespace grant +# (Viktor's call, 2026-07-02; same pattern as the chrome-service one). +# +# TRADE-OFF (accepted): port-forward into this namespace bypasses the Authentik +# ingress and the drawings API trusts the X-Authentik-Username header, so the SA +# can read/write ANY user's drawings, not only emo's. The namespace runs nothing +# but the drawings app, and the same class of trade-off was already accepted for +# the shared browser (CDP reach into Viktor's sessions). + +resource "kubernetes_role" "portforward" { + metadata { + name = "excalidraw-portforward" + namespace = kubernetes_namespace.excalidraw.metadata[0].name + } + rule { + api_groups = [""] + resources = ["pods/portforward"] + verbs = ["create"] + } +} + +resource "kubernetes_role_binding" "emo_browser_portforward" { + metadata { + name = "emo-browser-portforward" + namespace = kubernetes_namespace.excalidraw.metadata[0].name + } + role_ref { + api_group = "rbac.authorization.k8s.io" + kind = "Role" + name = kubernetes_role.portforward.metadata[0].name + } + subject { + kind = "ServiceAccount" + # Defined in stacks/chrome-service/rbac.tf — referenced by name across + # stacks, same as that file references the oidc-power-user-readonly + # ClusterRole. get/list on pods+services (needed to resolve svc/draw) comes + # from the SA's cluster-read binding there. + name = "emo-browser" + namespace = "chrome-service" + } +} From d94f267c9323e601c0f6e4cc19062febec8631b9 Mon Sep 17 00:00:00 2001 From: Viktor Barzin Date: Thu, 2 Jul 2026 14:18:22 +0000 Subject: [PATCH 03/55] =?UTF-8?q?immich:=20upgrade=20v2.7.5=20=E2=86=92=20?= =?UTF-8?q?v3.0.0=20(postgres=20=E2=86=92=20vectorchord=200.4.3,=20frames?= =?UTF-8?q?=20=E2=86=92=20immich=5Fv3=20tag)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Viktor asked to upgrade Immich to the just-released v3.0.0 (release notes, migration guide and release discussion #29439 reviewed — no config-breaking changes for this stack: we already use the split MACHINE_LEARNING_PRELOAD vars, don't set DB_VECTOR_EXTENSION, OAuth goes through Authentik over HTTPS, and the GPU node's CPU meets the new x86-64-v2 requirement). The Immich Postgres image moves to VectorChord 0.4.3 to match the upstream v3 reference stack (0.3.0 is still within v3's supported range '>=0.3 <2'; Immich upgrades the extension itself at startup). Both photo frames switch to ImmichFrame's immich_v3 compatibility tag because every versioned ImmichFrame release (≤ v1.0.33.0) crashes deserializing Immich v3 API responses; repin to a versioned tag once upstream ships stable v3 support. Deployment images are Keel-managed (KEEL_IGNORE_IMAGE, policy=patch), so this commit is the source-of-truth record; the live rollout happens via kubectl set image in the same session. Pre-upgrade pg_dumpall taken (job postgresql-backup-pre-v3). Co-Authored-By: Claude Fable 5 --- stacks/immich/frame-emo.tf | 4 +++- stacks/immich/frame.tf | 6 +++++- stacks/immich/main.tf | 8 ++++---- 3 files changed, 12 insertions(+), 6 deletions(-) diff --git a/stacks/immich/frame-emo.tf b/stacks/immich/frame-emo.tf index 577d84af..221577ba 100644 --- a/stacks/immich/frame-emo.tf +++ b/stacks/immich/frame-emo.tf @@ -73,7 +73,9 @@ resource "kubernetes_deployment" "immich-frame-emo" { } spec { container { - image = "ghcr.io/immichframe/immichframe:v1.0.32.0" + # immich_v3: upstream compat tag for Immich v3 — see frame.tf for the + # full story; repin to a versioned tag once upstream releases v3 support. + image = "ghcr.io/immichframe/immichframe:immich_v3" name = "immich-frame-emo" resources { requests = { diff --git a/stacks/immich/frame.tf b/stacks/immich/frame.tf index c86a47eb..97d83966 100644 --- a/stacks/immich/frame.tf +++ b/stacks/immich/frame.tf @@ -69,7 +69,11 @@ resource "kubernetes_deployment" "immich-frame" { } spec { container { - image = "ghcr.io/immichframe/immichframe:v1.0.32.0" + # immich_v3 is the upstream compat tag for Immich v3 servers — every + # versioned release (≤ v1.0.33.0) crashes deserializing v3 API + # responses (immichFrame/immichFrame#653). Pin back to a vX.Y.Z.W tag + # once a stable release ships v3 support (upstream PR #654). + image = "ghcr.io/immichframe/immichframe:immich_v3" name = "immich-frame" resources { requests = { diff --git a/stacks/immich/main.tf b/stacks/immich/main.tf index 09f03ca9..b0c0eef8 100644 --- a/stacks/immich/main.tf +++ b/stacks/immich/main.tf @@ -15,7 +15,7 @@ locals { variable "immich_version" { type = string # Change me to upgrade - default = "v2.7.5" + default = "v3.0.0" } variable "proxmox_host" { type = string } variable "redis_host" { type = string } @@ -492,7 +492,7 @@ resource "kubernetes_deployment" "immich-postgres" { } spec { container { - image = "ghcr.io/immich-app/postgres:15-vectorchord0.3.0-pgvectors0.2.0" + image = "ghcr.io/immich-app/postgres:15-vectorchord0.4.3-pgvectors0.2.0" name = "immich-postgresql" port { container_port = 5432 @@ -882,7 +882,7 @@ resource "kubernetes_cron_job_v1" "clip-index-prewarm" { restart_policy = "Never" container { name = "prewarm" - image = "ghcr.io/immich-app/postgres:15-vectorchord0.3.0-pgvectors0.2.0" + image = "ghcr.io/immich-app/postgres:15-vectorchord0.4.3-pgvectors0.2.0" # command overrides the postgres entrypoint → runs psql directly. command = [ "psql", "-v", "ON_ERROR_STOP=1", "-c", @@ -964,7 +964,7 @@ resource "kubernetes_cron_job_v1" "immich-search-probe" { } init_container { name = "measure" - image = "ghcr.io/immich-app/postgres:15-vectorchord0.3.0-pgvectors0.2.0" + image = "ghcr.io/immich-app/postgres:15-vectorchord0.4.3-pgvectors0.2.0" command = ["/bin/bash", "-c", <<-EOT set -uo pipefail OUT=/shared/metrics.prom From 1cbc1e962b1bfedc223c44b6bb94c9ed32cb46eb Mon Sep 17 00:00:00 2001 From: Viktor Barzin Date: Thu, 2 Jul 2026 14:29:10 +0000 Subject: [PATCH 04/55] excalidraw: native export menu + drawing rename Users couldn't see Excalidraw's built-in Save as / Export image options: the app's custom toolbar was drawn exactly on top of the native hamburger menu button, hiding it. Removed the overlay and integrated Back to Library / Save now / Rename into the native menu, so the native export formats (.excalidraw file, PNG, SVG, clipboard) are now reachable. Viktor asked for exports to work via the native Excalidraw feature and for drawings to be renameable by clicking their name. Rename: new PATCH /api/drawings/{id} endpoint (server-side name sanitization, 409 on conflict) + click-to-rename title pill in the editor (updates URL in place) + Rename button/modal in the dashboard. Existing GET/PUT/DELETE semantics unchanged for API compatibility (emo's upload pipeline). Added main_test.go (httptest) covering rename + existing handler behavior; dashboard rows now DOM-built (XSS-safe). Co-Authored-By: Claude Fable 5 --- stacks/excalidraw/project/README.md | 102 +++----- stacks/excalidraw/project/main.go | 177 +++++++++++-- stacks/excalidraw/project/main_test.go | 249 +++++++++++++++++++ stacks/excalidraw/project/static/editor.html | 182 ++++++++++---- 4 files changed, 577 insertions(+), 133 deletions(-) create mode 100644 stacks/excalidraw/project/main_test.go diff --git a/stacks/excalidraw/project/README.md b/stacks/excalidraw/project/README.md index 0f017e85..c9c95078 100644 --- a/stacks/excalidraw/project/README.md +++ b/stacks/excalidraw/project/README.md @@ -4,18 +4,28 @@ A self-hosted Excalidraw library with per-user drawing storage and management. ## Features -- Dashboard to manage all your drawings +- Dashboard to manage all your drawings (create, open, rename, delete) - Per-user storage (via Authentik SSO headers) -- Create, edit, and delete drawings +- Rename drawings from the dashboard or by clicking the drawing name in the editor +- Native Excalidraw export via the editor's hamburger menu: "Save to..." + (.excalidraw file) and "Export image..." (PNG / SVG / clipboard) +- Autosave (2s debounce) + manual save (Ctrl+S or menu "Save now") - Persistent storage via NFS ## Docker Image ``` -viktorbarzin/excalidraw-library:v4 +ghcr.io/viktorbarzin/excalidraw-library:latest ``` -Available on Docker Hub: https://hub.docker.com/r/viktorbarzin/excalidraw-library +Built by GitHub Actions (`.github/workflows/build-excalidraw.yml` in the infra +repo, ADR-0002) on every master push touching `stacks/excalidraw/project/**`; +tags `:latest` + `:`. The package is PRIVATE — cluster pulls use the +Kyverno-synced `ghcr-credentials` secret. Keel polls `:latest` and rolls the +deployment on digest change. + +The legacy manually-built DockerHub image `viktorbarzin/excalidraw-library:v4` +is frozen as the rollback target; nothing pushes to it anymore. ## Configuration @@ -39,54 +49,13 @@ Mount a persistent volume to the `DATA_DIR` path. Drawings are stored as `.excal └── my-diagram.excalidraw ``` +The filename (without extension) is both the drawing ID and its display name; +renaming a drawing renames the file (`os.Rename`, mtime preserved). + ## Deployment -### Docker - -```bash -docker run -d \ - --name excalidraw-rooms \ - -p 8080:8080 \ - -v /path/to/storage:/data \ - viktorbarzin/excalidraw-library:v4 -``` - -### Kubernetes - -```yaml -apiVersion: apps/v1 -kind: Deployment -metadata: - name: excalidraw -spec: - replicas: 1 - selector: - matchLabels: - app: excalidraw - template: - metadata: - labels: - app: excalidraw - spec: - containers: - - name: excalidraw - image: viktorbarzin/excalidraw-library:v4 - ports: - - containerPort: 8080 - env: - - name: DATA_DIR - value: /data - - name: PORT - value: "8080" - volumeMounts: - - name: data - mountPath: /data - volumes: - - name: data - nfs: - server: 192.168.1.127 - path: /srv/nfs/excalidraw -``` +Deployed by the `stacks/excalidraw` Terraform stack (namespace `excalidraw`, +service `draw`, ingress `draw.viktorbarzin.me` with `auth = "required"`). ### With Authentik SSO @@ -96,23 +65,7 @@ The application reads user identity from Authentik headers: - `X-Authentik-Email` - Displayed in UI - `X-Authentik-Name` - Displayed in UI -Configure your ingress to pass these headers: - -```yaml -annotations: - nginx.ingress.kubernetes.io/auth-response-headers: "X-authentik-username,X-authentik-email,X-authentik-name" -``` - -## Building - -```bash -# Build the Docker image -docker build -t excalidraw-library . - -# Or build locally -go build -o excalidraw-library . -./excalidraw-library -``` +Requests without `X-Authentik-Username` fall back to the `anonymous` user. ## API Endpoints @@ -122,10 +75,25 @@ go build -o excalidraw-library . | GET | `/api/drawings` | List all drawings for current user | | GET | `/api/drawings/:id` | Get drawing data | | PUT | `/api/drawings/:id` | Save drawing | +| PATCH | `/api/drawings/:id` | Rename drawing — body `{"name": ""}`; returns `{"status":"renamed","id":""}`; 409 if the target name exists | | DELETE | `/api/drawings/:id` | Delete drawing | | GET | `/api/user` | Get current user info | | GET | `/draw/:id` | Open drawing in editor | +Rename names are sanitized server-side to `[a-zA-Z0-9-_]` (other characters +become `-`; a trailing `.excalidraw` is stripped). Existing IDs are accepted +as-is for backward compatibility with API clients. + +## Development + +```bash +# Run tests +go test ./... + +# Run locally +DATA_DIR=/tmp/excalidraw-data go run . +``` + ## License MIT diff --git a/stacks/excalidraw/project/main.go b/stacks/excalidraw/project/main.go index e6dfbd83..b444f6cf 100644 --- a/stacks/excalidraw/project/main.go +++ b/stacks/excalidraw/project/main.go @@ -9,6 +9,7 @@ import ( "net/http" "os" "path/filepath" + "regexp" "sort" "strings" "time" @@ -63,6 +64,21 @@ func getUsername(r *http.Request) string { return username } +var invalidNameChars = regexp.MustCompile(`[^a-zA-Z0-9-_]`) + +// sanitizeName normalizes a user-supplied drawing name into a safe file ID +// (same charset the dashboard applies on create). Returns "" if nothing +// meaningful remains. +func sanitizeName(name string) string { + name = strings.TrimSpace(name) + name = strings.TrimSuffix(name, ".excalidraw") + name = invalidNameChars.ReplaceAllString(name, "-") + if strings.Trim(name, "-") == "" { + return "" + } + return name +} + // getUserDataDir returns the data directory for a specific user and ensures it exists func getUserDataDir(username string) string { userDir := filepath.Join(dataDir, username) @@ -168,6 +184,41 @@ func handleDrawing(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]string{"status": "saved", "id": id}) + case http.MethodPatch: + var req struct { + Name string `json:"name"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Invalid JSON body", http.StatusBadRequest) + return + } + newID := sanitizeName(req.Name) + if newID == "" { + http.Error(w, "Invalid name", http.StatusBadRequest) + return + } + if _, err := os.Stat(filePath); err != nil { + if os.IsNotExist(err) { + http.Error(w, "Drawing not found", http.StatusNotFound) + } else { + http.Error(w, err.Error(), http.StatusInternalServerError) + } + return + } + if newID != id { + newPath := filepath.Join(userDataDir, newID+".excalidraw") + if _, err := os.Stat(newPath); err == nil { + http.Error(w, "A drawing with that name already exists", http.StatusConflict) + return + } + if err := os.Rename(filePath, newPath); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{"status": "renamed", "id": newID}) + case http.MethodDelete: if err := os.Remove(filePath); err != nil { if os.IsNotExist(err) { @@ -264,6 +315,8 @@ const dashboardHTML = ` .btn:hover { background: #5b4cdb; } .btn-danger { background: #e74c3c; } .btn-danger:hover { background: #c0392b; } + .btn-secondary { background: #3d3d5c; } + .btn-secondary:hover { background: #4a4a70; } .btn-small { padding: 0.4rem 0.8rem; font-size: 0.85rem; } .drawings { display: grid; gap: 1rem; } .drawing { @@ -342,11 +395,11 @@ const dashboardHTML = ` @@ -369,31 +422,63 @@ const dashboardHTML = ` } } + function drawingRow(d) { + var row = document.createElement('div'); + row.className = 'drawing'; + + var info = document.createElement('div'); + info.className = 'drawing-info'; + var nameLink = document.createElement('a'); + nameLink.className = 'drawing-name'; + nameLink.href = '/draw/' + encodeURIComponent(d.id); + nameLink.textContent = d.name; + var meta = document.createElement('div'); + meta.className = 'drawing-meta'; + meta.textContent = 'Modified: ' + new Date(d.modified).toLocaleDateString() + ' ' + + new Date(d.modified).toLocaleTimeString() + ' - ' + formatSize(d.size); + info.appendChild(nameLink); + info.appendChild(meta); + + var actions = document.createElement('div'); + actions.className = 'drawing-actions'; + var open = document.createElement('a'); + open.className = 'btn btn-small'; + open.href = '/draw/' + encodeURIComponent(d.id); + open.textContent = 'Open'; + var rename = document.createElement('button'); + rename.className = 'btn btn-small btn-secondary'; + rename.textContent = 'Rename'; + rename.onclick = function() { showRenameModal(d.id); }; + var del = document.createElement('button'); + del.className = 'btn btn-small btn-danger'; + del.textContent = 'Delete'; + del.onclick = function() { deleteDrawing(d.id); }; + actions.appendChild(open); + actions.appendChild(rename); + actions.appendChild(del); + + row.appendChild(info); + row.appendChild(actions); + return row; + } + async function loadDrawings() { const resp = await fetch('/api/drawings'); const drawings = await resp.json(); const container = document.getElementById('drawings'); + container.replaceChildren(); if (!drawings || drawings.length === 0) { - container.innerHTML = '
No drawings yet. Create your first one!
'; + var empty = document.createElement('div'); + empty.className = 'empty'; + empty.textContent = 'No drawings yet. Create your first one!'; + container.appendChild(empty); return; } - container.innerHTML = drawings.map(function(d) { - return '
' + - '
' + - '' + d.name + '' + - '
' + - 'Modified: ' + new Date(d.modified).toLocaleDateString() + ' ' + new Date(d.modified).toLocaleTimeString() + - ' - ' + formatSize(d.size) + - '
' + - '
' + - '
' + - 'Open' + - '' + - '
' + - '
'; - }).join(''); + drawings.forEach(function(d) { + container.appendChild(drawingRow(d)); + }); } function formatSize(bytes) { @@ -402,18 +487,64 @@ const dashboardHTML = ` return (bytes / (1024 * 1024)).toFixed(1) + ' MB'; } - function showNewModal() { + var modalAction = null; // invoked with the input value on confirm + + function showModal(title, confirmLabel, initialValue, action) { + document.getElementById('modal-title').textContent = title; + document.getElementById('modal-confirm').textContent = confirmLabel; + var input = document.getElementById('drawingName'); + input.value = initialValue || ''; + modalAction = action; document.getElementById('modal').classList.add('active'); - document.getElementById('drawingName').focus(); + input.focus(); + input.select(); + } + + function showNewModal() { + showModal('New Drawing', 'Create', '', createDrawing); + } + + function showRenameModal(id) { + showModal('Rename Drawing', 'Rename', id, function(value) { + renameDrawing(id, value); + }); } function hideModal() { document.getElementById('modal').classList.remove('active'); document.getElementById('drawingName').value = ''; + modalAction = null; } - async function createDrawing() { - var name = document.getElementById('drawingName').value.trim(); + function confirmModal() { + if (modalAction) modalAction(document.getElementById('drawingName').value); + } + + async function renameDrawing(id, newName) { + newName = (newName || '').trim(); + if (!newName || newName === id) { + hideModal(); + return; + } + var resp = await fetch('/api/drawings/' + encodeURIComponent(id), { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: newName }) + }); + if (resp.status === 409) { + alert('A drawing with that name already exists.'); + return; // keep the modal open so the user can pick another name + } + if (!resp.ok) { + alert('Rename failed: ' + await resp.text()); + return; + } + hideModal(); + loadDrawings(); + } + + async function createDrawing(name) { + name = (name || '').trim(); if (!name) { name = 'drawing-' + Date.now(); } @@ -446,7 +577,7 @@ const dashboardHTML = ` } document.getElementById('drawingName').addEventListener('keypress', function(e) { - if (e.key === 'Enter') createDrawing(); + if (e.key === 'Enter') confirmModal(); }); document.getElementById('modal').addEventListener('click', function(e) { diff --git a/stacks/excalidraw/project/main_test.go b/stacks/excalidraw/project/main_test.go new file mode 100644 index 00000000..b4ab14f8 --- /dev/null +++ b/stacks/excalidraw/project/main_test.go @@ -0,0 +1,249 @@ +package main + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" +) + +const testDrawing = `{"type":"excalidraw","version":2,"source":"excalidraw-library","elements":[{"id":"e1"}],"appState":{"viewBackgroundColor":"#ffffff"}}` + +func setupDataDir(t *testing.T) { + t.Helper() + dataDir = t.TempDir() +} + +// doDrawing sends a request to handleDrawing for the given user and returns the recorder. +func doDrawing(t *testing.T, method, id, body, user string) *httptest.ResponseRecorder { + t.Helper() + var reader *strings.Reader + if body == "" { + reader = strings.NewReader("") + } else { + reader = strings.NewReader(body) + } + req := httptest.NewRequest(method, "/api/drawings/"+id, reader) + if user != "" { + req.Header.Set("X-Authentik-Username", user) + } + w := httptest.NewRecorder() + handleDrawing(w, req) + return w +} + +func listDrawings(t *testing.T, user string) []Drawing { + t.Helper() + req := httptest.NewRequest(http.MethodGet, "/api/drawings", nil) + if user != "" { + req.Header.Set("X-Authentik-Username", user) + } + w := httptest.NewRecorder() + handleListDrawings(w, req) + if w.Code != http.StatusOK { + t.Fatalf("list: expected 200, got %d", w.Code) + } + var drawings []Drawing + if err := json.Unmarshal(w.Body.Bytes(), &drawings); err != nil { + t.Fatalf("list: bad JSON: %v", err) + } + return drawings +} + +func TestPutGetRoundtrip(t *testing.T) { + setupDataDir(t) + if w := doDrawing(t, http.MethodPut, "foo", testDrawing, "alice"); w.Code != http.StatusOK { + t.Fatalf("PUT: expected 200, got %d: %s", w.Code, w.Body.String()) + } + w := doDrawing(t, http.MethodGet, "foo", "", "alice") + if w.Code != http.StatusOK { + t.Fatalf("GET: expected 200, got %d", w.Code) + } + if w.Body.String() != testDrawing { + t.Errorf("GET: content mismatch: %s", w.Body.String()) + } +} + +func TestGetMissing(t *testing.T) { + setupDataDir(t) + if w := doDrawing(t, http.MethodGet, "nope", "", "alice"); w.Code != http.StatusNotFound { + t.Fatalf("expected 404, got %d", w.Code) + } +} + +func TestListDrawings(t *testing.T) { + setupDataDir(t) + doDrawing(t, http.MethodPut, "one", testDrawing, "alice") + doDrawing(t, http.MethodPut, "two", testDrawing, "alice") + drawings := listDrawings(t, "alice") + if len(drawings) != 2 { + t.Fatalf("expected 2 drawings, got %d", len(drawings)) + } + ids := map[string]bool{drawings[0].ID: true, drawings[1].ID: true} + if !ids["one"] || !ids["two"] { + t.Errorf("unexpected ids: %v", ids) + } + for _, d := range drawings { + if d.Name != d.ID { + t.Errorf("name should equal id: %+v", d) + } + } +} + +func TestDelete(t *testing.T) { + setupDataDir(t) + doDrawing(t, http.MethodPut, "foo", testDrawing, "alice") + if w := doDrawing(t, http.MethodDelete, "foo", "", "alice"); w.Code != http.StatusOK { + t.Fatalf("DELETE: expected 200, got %d", w.Code) + } + if w := doDrawing(t, http.MethodGet, "foo", "", "alice"); w.Code != http.StatusNotFound { + t.Fatalf("GET after delete: expected 404, got %d", w.Code) + } + if w := doDrawing(t, http.MethodDelete, "foo", "", "alice"); w.Code != http.StatusNotFound { + t.Fatalf("second DELETE: expected 404, got %d", w.Code) + } +} + +func TestPerUserIsolation(t *testing.T) { + setupDataDir(t) + doDrawing(t, http.MethodPut, "secret", testDrawing, "alice") + if w := doDrawing(t, http.MethodGet, "secret", "", "bob"); w.Code != http.StatusNotFound { + t.Fatalf("bob should not see alice's drawing, got %d", w.Code) + } + if drawings := listDrawings(t, "bob"); len(drawings) != 0 { + t.Fatalf("bob's list should be empty, got %d", len(drawings)) + } +} + +// --- rename (PATCH) --- + +func renameReq(t *testing.T, id, newName, user string) *httptest.ResponseRecorder { + t.Helper() + return doDrawing(t, http.MethodPatch, id, `{"name":`+strconv(newName)+`}`, user) +} + +// strconv JSON-quotes a string without importing encoding/json for a one-liner. +func strconv(s string) string { + b, _ := json.Marshal(s) + return string(b) +} + +func TestRenameSuccess(t *testing.T) { + setupDataDir(t) + doDrawing(t, http.MethodPut, "foo", testDrawing, "alice") + w := renameReq(t, "foo", "bar", "alice") + if w.Code != http.StatusOK { + t.Fatalf("PATCH: expected 200, got %d: %s", w.Code, w.Body.String()) + } + var resp map[string]string + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("PATCH: bad JSON: %v", err) + } + if resp["id"] != "bar" || resp["status"] != "renamed" { + t.Errorf("unexpected response: %v", resp) + } + if w := doDrawing(t, http.MethodGet, "bar", "", "alice"); w.Code != http.StatusOK || w.Body.String() != testDrawing { + t.Errorf("GET new id: code=%d content=%q", w.Code, w.Body.String()) + } + if w := doDrawing(t, http.MethodGet, "foo", "", "alice"); w.Code != http.StatusNotFound { + t.Errorf("GET old id: expected 404, got %d", w.Code) + } +} + +func TestRenameConflict(t *testing.T) { + setupDataDir(t) + doDrawing(t, http.MethodPut, "a", testDrawing, "alice") + doDrawing(t, http.MethodPut, "b", testDrawing, "alice") + if w := renameReq(t, "a", "b", "alice"); w.Code != http.StatusConflict { + t.Fatalf("expected 409, got %d", w.Code) + } + // both drawings intact + for _, id := range []string{"a", "b"} { + if w := doDrawing(t, http.MethodGet, id, "", "alice"); w.Code != http.StatusOK { + t.Errorf("drawing %q should be intact, got %d", id, w.Code) + } + } +} + +func TestRenameMissing(t *testing.T) { + setupDataDir(t) + if w := renameReq(t, "nope", "new", "alice"); w.Code != http.StatusNotFound { + t.Fatalf("expected 404, got %d", w.Code) + } +} + +func TestRenameSameName(t *testing.T) { + setupDataDir(t) + doDrawing(t, http.MethodPut, "foo", testDrawing, "alice") + w := renameReq(t, "foo", "foo", "alice") + if w.Code != http.StatusOK { + t.Fatalf("same-name rename: expected 200, got %d: %s", w.Code, w.Body.String()) + } + if w := doDrawing(t, http.MethodGet, "foo", "", "alice"); w.Code != http.StatusOK { + t.Errorf("drawing should be intact, got %d", w.Code) + } +} + +func TestRenameInvalidNames(t *testing.T) { + setupDataDir(t) + doDrawing(t, http.MethodPut, "foo", testDrawing, "alice") + for _, name := range []string{"", " ", "../..", "---"} { + if w := renameReq(t, "foo", name, "alice"); w.Code != http.StatusBadRequest { + t.Errorf("rename to %q: expected 400, got %d", name, w.Code) + } + } + // malformed body + if w := doDrawing(t, http.MethodPatch, "foo", `{not json`, "alice"); w.Code != http.StatusBadRequest { + t.Errorf("malformed body: expected 400, got %d", w.Code) + } +} + +func TestRenameSanitization(t *testing.T) { + setupDataDir(t) + cases := []struct{ in, want string }{ + {"My Drawing!", "My-Drawing-"}, + {"net diag.excalidraw", "net-diag"}, // .excalidraw suffix stripped, not mangled + {"a/b\\c", "a-b-c"}, + } + for _, c := range cases { + doDrawing(t, http.MethodPut, "src", testDrawing, "alice") + w := renameReq(t, "src", c.in, "alice") + if w.Code != http.StatusOK { + t.Errorf("rename to %q: expected 200, got %d: %s", c.in, w.Code, w.Body.String()) + continue + } + var resp map[string]string + json.Unmarshal(w.Body.Bytes(), &resp) + if resp["id"] != c.want { + t.Errorf("rename to %q: expected id %q, got %q", c.in, c.want, resp["id"]) + } + // file must be inside the user dir under the sanitized name + if _, err := os.Stat(filepath.Join(dataDir, "alice", c.want+".excalidraw")); err != nil { + t.Errorf("rename to %q: expected file %q on disk: %v", c.in, c.want, err) + } + doDrawing(t, http.MethodDelete, resp["id"], "", "alice") + } +} + +func TestRenameTraversalStaysInUserDir(t *testing.T) { + setupDataDir(t) + doDrawing(t, http.MethodPut, "foo", testDrawing, "alice") + w := renameReq(t, "foo", "../../../etc/passwd", "alice") + if w.Code == http.StatusOK { + var resp map[string]string + json.Unmarshal(w.Body.Bytes(), &resp) + if strings.Contains(resp["id"], "/") || strings.Contains(resp["id"], "..") { + t.Fatalf("traversal characters survived: %q", resp["id"]) + } + if _, err := os.Stat(filepath.Join(dataDir, "alice", resp["id"]+".excalidraw")); err != nil { + t.Fatalf("renamed file escaped user dir: %v", err) + } + } + // nothing outside the data dir + if _, err := os.Stat(filepath.Join(dataDir, "..", "etc")); err == nil { + t.Fatal("file escaped the data dir") + } +} diff --git a/stacks/excalidraw/project/static/editor.html b/stacks/excalidraw/project/static/editor.html index aba6390b..f374c115 100644 --- a/stacks/excalidraw/project/static/editor.html +++ b/stacks/excalidraw/project/static/editor.html @@ -8,41 +8,41 @@ * { margin: 0; padding: 0; } html, body { width: 100%; height: 100%; overflow: hidden; } #root { width: 100%; height: 100%; } - .toolbar { - position: fixed; - top: 10px; - left: 10px; - z-index: 1000; + .top-right-ui { display: flex; + align-items: center; gap: 8px; - background: rgba(255,255,255,0.95); + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + } + .top-right-ui a, .top-right-ui button { + display: inline-flex; + align-items: center; + gap: 6px; padding: 8px 12px; + border: 1px solid transparent; border-radius: 8px; - box-shadow: 0 2px 8px rgba(0,0,0,0.15); - } - .toolbar button, .toolbar a { - padding: 6px 14px; - border: none; - border-radius: 6px; cursor: pointer; - font-size: 14px; - background: #6c5ce7; - color: white; + font-size: 13px; text-decoration: none; - display: inline-block; + box-shadow: 0 1px 4px rgba(0,0,0,0.12); + max-width: 40vw; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; } - .toolbar button:hover, .toolbar a:hover { background: #5b4cdb; } - .toolbar .secondary { background: #ddd; color: #333; } - .toolbar .secondary:hover { background: #ccc; } - .toolbar .title { - font-weight: 600; - padding: 6px 0; - color: #333; + .top-right-ui.theme-light a, .top-right-ui.theme-light button { + background: #ffffff; + color: #1b1b1f; } + .top-right-ui.theme-dark a, .top-right-ui.theme-dark button { + background: #232329; + color: #e9ecef; + } + .top-right-ui button:hover, .top-right-ui a:hover { border-color: #a29bfe; } .status { position: fixed; bottom: 10px; - right: 10px; + right: 60px; padding: 6px 12px; background: rgba(0,0,0,0.7); color: white; @@ -51,6 +51,7 @@ z-index: 1000; opacity: 0; transition: opacity 0.3s; + pointer-events: none; } .status.show { opacity: 1; } .loading { @@ -67,11 +68,6 @@ -
- Back to Library - Loading... - -
Loading Excalidraw...
@@ -81,16 +77,33 @@
Saved