Initial commit: Excalidraw Rooms with per-user storage

This commit is contained in:
Viktor Barzin 2026-01-26 20:07:00 +00:00
commit 7272c9cd55
9 changed files with 871 additions and 0 deletions

BIN
._.git Normal file

Binary file not shown.

BIN
._.gitignore Normal file

Binary file not shown.

BIN
._README.md Normal file

Binary file not shown.

13
.gitignore vendored Normal file
View file

@ -0,0 +1,13 @@
# Binaries
excalidraw-library
*.exe
# IDE
.idea/
.vscode/
*.swp
*.swo
# OS
.DS_Store
Thumbs.db

22
Dockerfile Normal file
View file

@ -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"]

131
README.md Normal file
View file

@ -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

3
go.mod Normal file
View file

@ -0,0 +1,3 @@
module excalidraw-library
go 1.21

461
main.go Normal file
View file

@ -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 = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Excalidraw Library</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #1a1a2e;
color: #eee;
min-height: 100vh;
padding: 2rem;
}
.container { max-width: 900px; margin: 0 auto; }
header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 2rem;
padding-bottom: 1rem;
border-bottom: 1px solid #333;
}
.header-left { display: flex; align-items: center; gap: 1rem; }
h1 { font-size: 1.5rem; }
.user-info {
font-size: 0.9rem;
color: #a29bfe;
padding: 0.4rem 0.8rem;
background: #252542;
border-radius: 6px;
}
.btn {
background: #6c5ce7;
color: white;
border: none;
padding: 0.75rem 1.5rem;
border-radius: 8px;
cursor: pointer;
font-size: 1rem;
text-decoration: none;
display: inline-block;
}
.btn:hover { background: #5b4cdb; }
.btn-danger { background: #e74c3c; }
.btn-danger:hover { background: #c0392b; }
.btn-small { padding: 0.4rem 0.8rem; font-size: 0.85rem; }
.drawings { display: grid; gap: 1rem; }
.drawing {
background: #252542;
border-radius: 12px;
padding: 1.25rem;
display: flex;
justify-content: space-between;
align-items: center;
transition: transform 0.1s, box-shadow 0.1s;
}
.drawing:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
}
.drawing-info { flex: 1; }
.drawing-name {
font-size: 1.1rem;
font-weight: 500;
margin-bottom: 0.25rem;
color: #fff;
text-decoration: none;
}
.drawing-name:hover { color: #a29bfe; }
.drawing-meta { font-size: 0.85rem; color: #888; }
.drawing-actions { display: flex; gap: 0.5rem; }
.empty {
text-align: center;
padding: 4rem 2rem;
color: #666;
}
.modal {
display: none;
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0,0,0,0.7);
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal.active { display: flex; }
.modal-content {
background: #252542;
padding: 2rem;
border-radius: 12px;
width: 90%;
max-width: 400px;
}
.modal h2 { margin-bottom: 1rem; }
.modal input {
width: 100%;
padding: 0.75rem;
border: 1px solid #444;
border-radius: 8px;
background: #1a1a2e;
color: #fff;
font-size: 1rem;
margin-bottom: 1rem;
}
.modal-actions { display: flex; gap: 0.5rem; justify-content: flex-end; }
</style>
</head>
<body>
<div class="container">
<header>
<div class="header-left">
<h1>Excalidraw Library</h1>
<span id="user-info" class="user-info">Loading...</span>
</div>
<button class="btn" onclick="showNewModal()">+ New Drawing</button>
</header>
<div id="drawings" class="drawings">
<div class="empty">Loading...</div>
</div>
</div>
<div id="modal" class="modal">
<div class="modal-content">
<h2>New Drawing</h2>
<input type="text" id="drawingName" placeholder="Drawing name..." autofocus>
<div class="modal-actions">
<button class="btn" style="background:#444" onclick="hideModal()">Cancel</button>
<button class="btn" onclick="createDrawing()">Create</button>
</div>
</div>
</div>
<script>
async function loadUser() {
try {
const resp = await fetch('/api/user');
const user = await resp.json();
const el = document.getElementById('user-info');
if (user.name) {
el.textContent = user.name;
} else if (user.username) {
el.textContent = user.username;
} else {
el.textContent = 'Guest';
}
} catch (e) {
document.getElementById('user-info').textContent = 'Guest';
}
}
async function loadDrawings() {
const resp = await fetch('/api/drawings');
const drawings = await resp.json();
const container = document.getElementById('drawings');
if (!drawings || drawings.length === 0) {
container.innerHTML = '<div class="empty">No drawings yet. Create your first one!</div>';
return;
}
container.innerHTML = drawings.map(function(d) {
return '<div class="drawing">' +
'<div class="drawing-info">' +
'<a href="/draw/' + d.id + '" class="drawing-name">' + d.name + '</a>' +
'<div class="drawing-meta">' +
'Modified: ' + new Date(d.modified).toLocaleDateString() + ' ' + new Date(d.modified).toLocaleTimeString() +
' - ' + formatSize(d.size) +
'</div>' +
'</div>' +
'<div class="drawing-actions">' +
'<a href="/draw/' + d.id + '" class="btn btn-small">Open</a>' +
'<button class="btn btn-small btn-danger" onclick="deleteDrawing(\'' + d.id + '\')">Delete</button>' +
'</div>' +
'</div>';
}).join('');
}
function formatSize(bytes) {
if (bytes < 1024) return bytes + ' B';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
}
function showNewModal() {
document.getElementById('modal').classList.add('active');
document.getElementById('drawingName').focus();
}
function hideModal() {
document.getElementById('modal').classList.remove('active');
document.getElementById('drawingName').value = '';
}
async function createDrawing() {
var name = document.getElementById('drawingName').value.trim();
if (!name) {
name = 'drawing-' + Date.now();
}
// Sanitize name
name = name.replace(/[^a-zA-Z0-9-_]/g, '-');
// Create empty drawing
var emptyDrawing = {
type: "excalidraw",
version: 2,
source: "excalidraw-library",
elements: [],
appState: { viewBackgroundColor: "#ffffff" }
};
await fetch('/api/drawings/' + name, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(emptyDrawing)
});
hideModal();
window.location.href = '/draw/' + name;
}
async function deleteDrawing(id) {
if (!confirm('Delete "' + id + '"?')) return;
await fetch('/api/drawings/' + id, { method: 'DELETE' });
loadDrawings();
}
document.getElementById('drawingName').addEventListener('keypress', function(e) {
if (e.key === 'Enter') createDrawing();
});
document.getElementById('modal').addEventListener('click', function(e) {
if (e.target.id === 'modal') hideModal();
});
loadUser();
loadDrawings();
</script>
</body>
</html>`

241
static/editor.html Normal file
View file

@ -0,0 +1,241 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Excalidraw Editor</title>
<style>
* { 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;
display: flex;
gap: 8px;
background: rgba(255,255,255,0.95);
padding: 8px 12px;
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;
text-decoration: none;
display: inline-block;
}
.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;
}
.status {
position: fixed;
bottom: 10px;
right: 10px;
padding: 6px 12px;
background: rgba(0,0,0,0.7);
color: white;
border-radius: 4px;
font-size: 12px;
z-index: 1000;
opacity: 0;
transition: opacity 0.3s;
}
.status.show { opacity: 1; }
.loading {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
font-size: 1.2rem;
color: #666;
flex-direction: column;
gap: 1rem;
}
.error { color: #e74c3c; }
</style>
</head>
<body>
<div class="toolbar">
<a href="/" class="secondary">Back to Library</a>
<span class="title" id="title">Loading...</span>
<button onclick="saveDrawing()">Save</button>
</div>
<div id="root">
<div class="loading">
<div>Loading Excalidraw...</div>
<div id="load-status" style="font-size: 0.9rem; color: #888;"></div>
</div>
</div>
<div id="status" class="status">Saved</div>
<script>
// Get drawing ID from URL path: /draw/{id}
var pathParts = window.location.pathname.split('/');
var drawingId = pathParts[pathParts.length - 1] || pathParts[pathParts.length - 2];
if (!drawingId) {
document.getElementById('root').innerHTML = '<div class="loading error">No drawing ID specified</div>';
throw new Error('No drawing ID');
}
document.getElementById('title').textContent = drawingId;
document.title = drawingId + ' - Excalidraw';
var excalidrawAPI = null;
var autoSaveTimeout = null;
function updateLoadStatus(msg) {
var el = document.getElementById('load-status');
if (el) el.textContent = msg;
console.log('[Excalidraw]', msg);
}
function showStatus(msg) {
var el = document.getElementById('status');
el.textContent = msg;
el.classList.add('show');
setTimeout(function() { el.classList.remove('show'); }, 2000);
}
async function loadDrawing() {
try {
var resp = await fetch('/api/drawings/' + drawingId);
if (resp.ok) {
return await resp.json();
}
} catch (e) {
console.log('No existing drawing, starting fresh');
}
return null;
}
async function saveDrawing() {
if (!excalidrawAPI) {
console.log('Cannot save: excalidrawAPI not ready');
return;
}
var elements = excalidrawAPI.getSceneElements();
var appState = excalidrawAPI.getAppState();
var data = {
type: "excalidraw",
version: 2,
source: "excalidraw-library",
elements: elements,
appState: {
viewBackgroundColor: appState.viewBackgroundColor,
gridSize: appState.gridSize
}
};
try {
await fetch('/api/drawings/' + drawingId, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
showStatus('Saved');
} catch (e) {
showStatus('Save failed!');
console.error('Save error:', e);
}
}
function onChange() {
clearTimeout(autoSaveTimeout);
autoSaveTimeout = setTimeout(saveDrawing, 2000);
}
// Load scripts dynamically
function loadScript(src) {
return new Promise(function(resolve, reject) {
var script = document.createElement('script');
script.src = src;
script.crossOrigin = 'anonymous';
script.onload = resolve;
script.onerror = function() { reject(new Error('Failed to load: ' + src)); };
document.head.appendChild(script);
});
}
async function init() {
try {
updateLoadStatus('Loading React...');
await loadScript('https://unpkg.com/react@18.2.0/umd/react.production.min.js');
updateLoadStatus('Loading ReactDOM...');
await loadScript('https://unpkg.com/react-dom@18.2.0/umd/react-dom.production.min.js');
updateLoadStatus('Loading Excalidraw...');
await loadScript('https://unpkg.com/@excalidraw/excalidraw@0.17.6/dist/excalidraw.production.min.js');
updateLoadStatus('Initializing...');
// Verify libraries loaded
if (!window.React) throw new Error('React not loaded');
if (!window.ReactDOM) throw new Error('ReactDOM not loaded');
if (!window.ExcalidrawLib) throw new Error('ExcalidrawLib not loaded');
console.log('React version:', React.version);
console.log('ExcalidrawLib:', Object.keys(ExcalidrawLib));
updateLoadStatus('Loading drawing data...');
var initialData = await loadDrawing();
updateLoadStatus('Rendering Excalidraw...');
// Create Excalidraw component
function App() {
return React.createElement(ExcalidrawLib.Excalidraw, {
initialData: initialData ? {
elements: initialData.elements || [],
appState: initialData.appState || {}
} : undefined,
excalidrawAPI: function(api) {
excalidrawAPI = api;
console.log('Excalidraw API ready');
},
onChange: onChange
});
}
var root = ReactDOM.createRoot(document.getElementById('root'));
root.render(React.createElement(App));
console.log('Excalidraw rendered successfully');
} catch (e) {
console.error('Init error:', e);
document.getElementById('root').innerHTML =
'<div class="loading error">' +
'<div>Failed to load Excalidraw</div>' +
'<div style="font-size:0.9rem">' + e.message + '</div>' +
'</div>';
}
}
// Keyboard shortcut: Ctrl+S to save
document.addEventListener('keydown', function(e) {
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
e.preventDefault();
saveDrawing();
}
});
init();
</script>
</body>
</html>