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