commit 7272c9cd5590154948c60027caaad4a438ffc560 Author: Viktor Barzin Date: Mon Jan 26 20:07:00 2026 +0000 Initial commit: Excalidraw Rooms with per-user storage diff --git a/._.git b/._.git new file mode 100644 index 0000000..0edb525 Binary files /dev/null and b/._.git differ diff --git a/._.gitignore b/._.gitignore new file mode 100644 index 0000000..0edb525 Binary files /dev/null and b/._.gitignore differ diff --git a/._README.md b/._README.md new file mode 100644 index 0000000..0edb525 Binary files /dev/null and b/._README.md differ diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2b7004b --- /dev/null +++ b/.gitignore @@ -0,0 +1,13 @@ +# Binaries +excalidraw-library +*.exe + +# IDE +.idea/ +.vscode/ +*.swp +*.swo + +# OS +.DS_Store +Thumbs.db diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..1e52718 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,22 @@ +FROM golang:1.21-alpine AS builder + +WORKDIR /app +COPY go.mod ./ +COPY main.go ./ +COPY static/ ./static/ + +RUN go build -o excalidraw-library . + +FROM alpine:latest + +RUN apk --no-cache add ca-certificates + +WORKDIR /app +COPY --from=builder /app/excalidraw-library . + +ENV DATA_DIR=/data +ENV PORT=8080 + +EXPOSE 8080 + +CMD ["./excalidraw-library"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..7eda126 --- /dev/null +++ b/README.md @@ -0,0 +1,131 @@ +# Excalidraw Rooms + +A self-hosted Excalidraw library with per-user drawing storage and management. + +## Features + +- Dashboard to manage all your drawings +- Per-user storage (via Authentik SSO headers) +- Create, edit, and delete drawings +- Persistent storage via NFS + +## Docker Image + +``` +viktorbarzin/excalidraw-library:v4 +``` + +Available on Docker Hub: https://hub.docker.com/r/viktorbarzin/excalidraw-library + +## Configuration + +### Environment Variables + +| Variable | Default | Description | +|----------|---------|-------------| +| `DATA_DIR` | `/data` | Directory where drawings are stored | +| `PORT` | `8080` | HTTP server port | + +### Storage + +Mount a persistent volume to the `DATA_DIR` path. Drawings are stored as `.excalidraw` files, organized by username: + +``` +/data/ +├── user1/ +│ ├── drawing1.excalidraw +│ └── drawing2.excalidraw +└── user2/ + └── my-diagram.excalidraw +``` + +## 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: 10.0.10.15 + path: /mnt/main/excalidraw +``` + +### With Authentik SSO + +The application reads user identity from Authentik headers: + +- `X-Authentik-Username` - Used to create per-user storage directories +- `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 +``` + +## API Endpoints + +| Method | Path | Description | +|--------|------|-------------| +| GET | `/` | Dashboard UI | +| GET | `/api/drawings` | List all drawings for current user | +| GET | `/api/drawings/:id` | Get drawing data | +| PUT | `/api/drawings/:id` | Save drawing | +| DELETE | `/api/drawings/:id` | Delete drawing | +| GET | `/api/user` | Get current user info | +| GET | `/draw/:id` | Open drawing in editor | + +## License + +MIT diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..62b5152 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module excalidraw-library + +go 1.21 diff --git a/main.go b/main.go new file mode 100644 index 0000000..e6dfbd8 --- /dev/null +++ b/main.go @@ -0,0 +1,461 @@ +package main + +import ( + "embed" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "os" + "path/filepath" + "sort" + "strings" + "time" +) + +//go:embed static/* +var staticFiles embed.FS + +type Drawing struct { + ID string `json:"id"` + Name string `json:"name"` + Modified time.Time `json:"modified"` + Size int64 `json:"size"` +} + +var dataDir string + +func main() { + dataDir = os.Getenv("DATA_DIR") + if dataDir == "" { + dataDir = "/data" + } + + // Ensure data directory exists + if err := os.MkdirAll(dataDir, 0755); err != nil { + log.Fatalf("Failed to create data directory: %v", err) + } + + http.HandleFunc("/", handleDashboard) + http.HandleFunc("/api/drawings", handleListDrawings) + http.HandleFunc("/api/drawings/", handleDrawing) + http.HandleFunc("/api/user", handleUser) + http.HandleFunc("/draw/", handleDraw) + + port := os.Getenv("PORT") + if port == "" { + port = "8080" + } + + log.Printf("Starting server on :%s with data dir: %s", port, dataDir) + log.Fatal(http.ListenAndServe(":"+port, nil)) +} + +// getUsername extracts username from Authentik header, returns "anonymous" if not set +func getUsername(r *http.Request) string { + username := r.Header.Get("X-Authentik-Username") + if username == "" { + username = "anonymous" + } + // Sanitize to prevent directory traversal + username = filepath.Base(username) + return username +} + +// getUserDataDir returns the data directory for a specific user and ensures it exists +func getUserDataDir(username string) string { + userDir := filepath.Join(dataDir, username) + if err := os.MkdirAll(userDir, 0755); err != nil { + log.Printf("Warning: Failed to create user directory %s: %v", userDir, err) + } + return userDir +} + +func handleDashboard(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/" { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + fmt.Fprint(w, dashboardHTML) +} + +func handleListDrawings(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + username := getUsername(r) + userDataDir := getUserDataDir(username) + + files, err := os.ReadDir(userDataDir) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + var drawings []Drawing + for _, f := range files { + if f.IsDir() || !strings.HasSuffix(f.Name(), ".excalidraw") { + continue + } + + info, err := f.Info() + if err != nil { + continue + } + + id := strings.TrimSuffix(f.Name(), ".excalidraw") + drawings = append(drawings, Drawing{ + ID: id, + Name: id, + Modified: info.ModTime(), + Size: info.Size(), + }) + } + + // Sort by modified time, newest first + sort.Slice(drawings, func(i, j int) bool { + return drawings[i].Modified.After(drawings[j].Modified) + }) + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(drawings) +} + +func handleDrawing(w http.ResponseWriter, r *http.Request) { + id := strings.TrimPrefix(r.URL.Path, "/api/drawings/") + if id == "" { + http.Error(w, "Missing drawing ID", http.StatusBadRequest) + return + } + + username := getUsername(r) + userDataDir := getUserDataDir(username) + + // Sanitize ID to prevent directory traversal + id = filepath.Base(id) + filePath := filepath.Join(userDataDir, id+".excalidraw") + + switch r.Method { + case http.MethodGet: + data, err := os.ReadFile(filePath) + if err != nil { + if os.IsNotExist(err) { + http.Error(w, "Drawing not found", http.StatusNotFound) + } else { + http.Error(w, err.Error(), http.StatusInternalServerError) + } + return + } + w.Header().Set("Content-Type", "application/json") + w.Write(data) + + case http.MethodPut, http.MethodPost: + data, err := io.ReadAll(r.Body) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + if err := os.WriteFile(filePath, data, 0644); 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": "saved", "id": id}) + + case http.MethodDelete: + if err := os.Remove(filePath); err != nil { + if os.IsNotExist(err) { + http.Error(w, "Drawing not found", http.StatusNotFound) + } else { + http.Error(w, err.Error(), http.StatusInternalServerError) + } + return + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{"status": "deleted", "id": id}) + + default: + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + } +} + +// handleUser returns the current authenticated user info +func handleUser(w http.ResponseWriter, r *http.Request) { + username := getUsername(r) + email := r.Header.Get("X-Authentik-Email") + name := r.Header.Get("X-Authentik-Name") + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{ + "username": username, + "email": email, + "name": name, + }) +} + +func handleDraw(w http.ResponseWriter, r *http.Request) { + id := strings.TrimPrefix(r.URL.Path, "/draw/") + if id == "" { + http.Error(w, "Missing drawing ID", http.StatusBadRequest) + return + } + + // Serve the static editor.html - the JS will parse the ID from the URL + data, err := staticFiles.ReadFile("static/editor.html") + if err != nil { + http.Error(w, "Editor not found", http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Write(data) +} + +const dashboardHTML = ` + + + + + Excalidraw Library + + + +
+
+
+

Excalidraw Library

+ +
+ +
+
+
Loading...
+
+
+ + + + + +` + diff --git a/static/editor.html b/static/editor.html new file mode 100644 index 0000000..aba6390 --- /dev/null +++ b/static/editor.html @@ -0,0 +1,241 @@ + + + + + + Excalidraw Editor + + + +
+ Back to Library + Loading... + +
+
+
+
Loading Excalidraw...
+
+
+
+
Saved
+ + + +