diff --git a/modules/kubernetes/excalidraw/main.tf b/modules/kubernetes/excalidraw/main.tf index 2df6db3a..c07a06e7 100644 --- a/modules/kubernetes/excalidraw/main.tf +++ b/modules/kubernetes/excalidraw/main.tf @@ -39,14 +39,37 @@ resource "kubernetes_deployment" "excalidraw" { app = "excalidraw" } annotations = { - "diun.enable" = "false" + "diun.enable" = "true" "diun.include_tags" = "^latest$" } } spec { container { - image = "docker.io/excalidraw/excalidraw:latest" - name = "excalidraw" + image = "viktorbarzin/excalidraw-library:v4" + image_pull_policy = "IfNotPresent" + name = "excalidraw" + port { + container_port = 8080 + } + env { + name = "DATA_DIR" + value = "/data" + } + env { + name = "PORT" + value = "8080" + } + volume_mount { + name = "data" + mount_path = "/data" + } + } + volume { + name = "data" + nfs { + server = "10.0.10.15" + path = "/mnt/main/excalidraw" + } } } } @@ -67,8 +90,9 @@ resource "kubernetes_service" "draw" { app = "excalidraw" } port { - name = "http" - port = "80" + name = "http" + port = 80 + target_port = 8080 } } } @@ -78,5 +102,8 @@ module "ingress" { namespace = kubernetes_namespace.excalidraw.metadata[0].name name = "draw" tls_secret_name = var.tls_secret_name + protected = true + extra_annotations = { + "nginx.ingress.kubernetes.io/auth-response-headers" = "X-authentik-username,X-authentik-email,X-authentik-name" + } } - diff --git a/modules/kubernetes/excalidraw/project/Dockerfile b/modules/kubernetes/excalidraw/project/Dockerfile new file mode 100644 index 00000000..1e527189 --- /dev/null +++ b/modules/kubernetes/excalidraw/project/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/modules/kubernetes/excalidraw/project/go.mod b/modules/kubernetes/excalidraw/project/go.mod new file mode 100644 index 00000000..62b5152c --- /dev/null +++ b/modules/kubernetes/excalidraw/project/go.mod @@ -0,0 +1,3 @@ +module excalidraw-library + +go 1.21 diff --git a/modules/kubernetes/excalidraw/project/main.go b/modules/kubernetes/excalidraw/project/main.go new file mode 100644 index 00000000..e6dfbd83 --- /dev/null +++ b/modules/kubernetes/excalidraw/project/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 = ` + +
+ + +