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 = `