feat: Add server-side storage for persistent diagram management

- Express.js backend for diagram CRUD operations
- Filesystem persistence via Docker volume mount
- Automatic storage detection in UI
- Overwrite protection with confirmation dialogs
- Server storage enabled by default in Docker

Closes #48
This commit is contained in:
Stan
2025-08-14 12:58:36 +01:00
parent eaca733271
commit bf3a30fa12
15 changed files with 1712 additions and 23 deletions

15
.env.example Normal file
View File

@@ -0,0 +1,15 @@
# Server Storage Configuration
# Set to true to enable server-side storage for multi-device access
# Default is true - diagrams are saved to ./diagrams directory on the host
ENABLE_SERVER_STORAGE=true
# Git backup (optional)
# Set to true to automatically commit changes to git
ENABLE_GIT_BACKUP=false
# Storage path (inside container)
# This is mapped to ./diagrams on the host via Docker volume
STORAGE_PATH=/data/diagrams
# Backend port (usually doesn't need changing)
BACKEND_PORT=3001

View File

@@ -21,14 +21,35 @@ COPY . .
# Build the library first, then the app
RUN npm run build:lib && npm run build:app
# Use Nginx as the production server
FROM nginx:alpine
# Use Node with nginx for production
FROM node:22-alpine
# Install nginx
RUN apk add --no-cache nginx
# Copy backend code
COPY --from=build /app/packages/fossflow-backend /app/packages/fossflow-backend
# Copy the built React app to Nginx's web server directory
COPY --from=build /app/packages/fossflow-app/build /usr/share/nginx/html
# Expose port 80 for the Nginx server
EXPOSE 80
# Copy nginx configuration
COPY nginx.conf /etc/nginx/http.d/default.conf
# Start Nginx when the container runs
CMD ["nginx", "-g", "daemon off;"]
# Copy and set up entrypoint script
COPY docker-entrypoint.sh /docker-entrypoint.sh
RUN chmod +x /docker-entrypoint.sh
# Create data directory for persistent storage
RUN mkdir -p /data/diagrams
# Expose ports
EXPOSE 80 3001
# Environment variables with defaults
ENV ENABLE_SERVER_STORAGE=true
ENV STORAGE_PATH=/data/diagrams
ENV BACKEND_PORT=3001
# Start services
ENTRYPOINT ["/docker-entrypoint.sh"]

View File

@@ -9,6 +9,13 @@ FossFLOW is a powerful, open-source Progressive Web App (PWA) for creating beaut
## Recent Updates (August 2025)
### Server Storage Support
- **Persistent Storage** - Diagrams saved to server filesystem, persist across browser sessions
- **Multi-device Access** - Access your diagrams from any device when using Docker deployment
- **Automatic Detection** - UI automatically shows server storage when available
- **Overwrite Protection** - Confirmation dialog when saving with duplicate names
- **Docker Integration** - Server storage enabled by default in Docker deployments
### Enhanced Interaction Features
- **Configurable Hotkeys** - Three profiles (QWERTY, SMNRCT, None) for tool selection with visual indicators
- **Advanced Pan Controls** - Multiple pan methods including empty area drag, middle/right click, modifier keys (Ctrl/Alt), and keyboard navigation (Arrow/WASD/IJKL)
@@ -40,6 +47,7 @@ FossFLOW is a powerful, open-source Progressive Web App (PWA) for creating beaut
- 📤 **Import/Export** - Share diagrams as JSON files
- 🎯 **Session Storage** - Quick save without dialogs
- 🌐 **Offline Support** - Work without internet connection
- 🗄️ **Server Storage** - Optional persistent storage when using Docker (enabled by default)
## Try it online
@@ -48,11 +56,18 @@ Go to https://stan-smith.github.io/FossFLOW/
## 🐳 Quick Deploy with Docker
```bash
# Using Docker Compose (recommended)
# Using Docker Compose (recommended - includes persistent storage)
docker compose up
# Or run directly from Docker Hub
docker run -p 80:80 stnsmith/fossflow:latest
# Or run directly from Docker Hub with persistent storage
docker run -p 80:80 -v ./diagrams:/data/diagrams stnsmith/fossflow:latest
```
Server storage is enabled by default in Docker. Your diagrams will be saved to `./diagrams` on the host.
To disable server storage, set `ENABLE_SERVER_STORAGE=false`:
```bash
docker run -p 80:80 -e ENABLE_SERVER_STORAGE=false stnsmith/fossflow:latest
```
## Quick Start (Local Development)

13
compose.dev.yml Normal file
View File

@@ -0,0 +1,13 @@
services:
fossflow:
build: .
ports:
- "3000:80"
- "3001:3001"
environment:
- NODE_ENV=development
- ENABLE_SERVER_STORAGE=true
- STORAGE_PATH=/data/diagrams
- ENABLE_GIT_BACKUP=false
volumes:
- ./diagrams:/data/diagrams

View File

@@ -5,4 +5,9 @@ services:
ports:
- 80:80
environment:
- NODE_ENV=production
- NODE_ENV=production
- ENABLE_SERVER_STORAGE=${ENABLE_SERVER_STORAGE:-true}
- STORAGE_PATH=/data/diagrams
- ENABLE_GIT_BACKUP=${ENABLE_GIT_BACKUP:-false}
volumes:
- ./diagrams:/data/diagrams

16
docker-entrypoint.sh Normal file
View File

@@ -0,0 +1,16 @@
#!/bin/sh
# Start Node.js backend if server storage is enabled
if [ "$ENABLE_SERVER_STORAGE" = "true" ]; then
echo "Starting FossFLOW backend server..."
cd /app/packages/fossflow-backend
npm install --production
node server.js &
echo "Backend server started"
else
echo "Server storage disabled, backend not started"
fi
# Start nginx
echo "Starting nginx..."
nginx -g "daemon off;"

23
nginx.conf Normal file
View File

@@ -0,0 +1,23 @@
server {
listen 80;
server_name localhost;
# Serve static files
location / {
root /usr/share/nginx/html;
try_files $uri /index.html;
}
# Proxy API requests to Node.js backend
location /api/ {
proxy_pass http://localhost:3001;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}

667
package-lock.json generated
View File

File diff suppressed because it is too large Load Diff

View File

@@ -9,13 +9,16 @@
"scripts": {
"dev": "npm run start --workspace=packages/fossflow-app",
"dev:lib": "npm run dev --workspace=packages/fossflow-lib",
"dev:backend": "npm run dev --workspace=packages/fossflow-backend",
"build": "npm run build:lib && npm run build:app",
"build:lib": "npm run build --workspace=packages/fossflow-lib",
"build:app": "npm run build --workspace=packages/fossflow-app",
"test": "npm run test --workspaces --if-present",
"lint": "npm run lint --workspaces --if-present",
"clean": "npm run clean --workspaces --if-present && rm -rf node_modules",
"publish:lib": "npm run build:lib && npm publish --workspace=packages/fossflow-lib"
"publish:lib": "npm run build:lib && npm publish --workspace=packages/fossflow-lib",
"docker:build": "docker build -t fossflow:local .",
"docker:run": "docker compose -f compose.dev.yml up"
},
"devDependencies": {
"@types/node": "^18.19.0",

View File

@@ -8,6 +8,8 @@ import azureIsopack from '@isoflow/isopacks/dist/azure';
import kubernetesIsopack from '@isoflow/isopacks/dist/kubernetes';
import { DiagramData, mergeDiagramData, extractSavableData } from './diagramUtils';
import { StorageManager } from './StorageManager';
import { DiagramManager } from './components/DiagramManager';
import { storageManager } from './services/storageService';
import './App.css';
const icons = flattenCollections([
@@ -42,6 +44,8 @@ function App() {
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
const [lastAutoSave, setLastAutoSave] = useState<Date | null>(null);
const [showStorageManager, setShowStorageManager] = useState(false);
const [showDiagramManager, setShowDiagramManager] = useState(false);
const [serverStorageAvailable, setServerStorageAvailable] = useState(false);
// Initialize with empty diagram data
// Create default colors for connectors
@@ -65,6 +69,13 @@ function App() {
fitToScreen: true
});
// Check for server storage availability
useEffect(() => {
storageManager.initialize().then(() => {
setServerStorageAvailable(storageManager.isServerStorage());
}).catch(console.error);
}, []);
// Load diagrams from localStorage on component mount
useEffect(() => {
const savedDiagrams = localStorage.getItem('fossflow-diagrams');
@@ -128,6 +139,20 @@ function App() {
return;
}
// Check if a diagram with this name already exists (excluding current)
const existingDiagram = diagrams.find(d =>
d.name === diagramName.trim() && d.id !== currentDiagram?.id
);
if (existingDiagram) {
const confirmOverwrite = window.confirm(
`A diagram named "${diagramName}" already exists in this session. This will overwrite it. Are you sure you want to continue?`
);
if (!confirmOverwrite) {
return;
}
}
// Construct save data WITHOUT icons (they're loaded separately)
const savedData = {
title: diagramName,
@@ -150,6 +175,11 @@ function App() {
if (currentDiagram) {
// Update existing diagram
setDiagrams(diagrams.map(d => d.id === currentDiagram.id ? newDiagram : d));
} else if (existingDiagram) {
// Replace existing diagram with same name
setDiagrams(diagrams.map(d => d.id === existingDiagram.id ? { ...newDiagram, id: existingDiagram.id, createdAt: existingDiagram.createdAt } : d));
newDiagram.id = existingDiagram.id;
newDiagram.createdAt = existingDiagram.createdAt;
} else {
// Add new diagram
setDiagrams([...diagrams, newDiagram]);
@@ -342,6 +372,30 @@ function App() {
// Trigger file input click
fileInputRef.current?.click();
};
const handleDiagramManagerLoad = (id: string, data: any) => {
// Load diagram from server storage
const mergedData: DiagramData = {
...data,
title: data.title || data.name || 'Loaded Diagram',
icons: icons, // Always use app icons
colors: data.colors?.length ? data.colors : defaultColors,
fitToScreen: data.fitToScreen !== false
};
setDiagramData(mergedData);
setDiagramName(data.name || 'Loaded Diagram');
setCurrentModel(mergedData);
setCurrentDiagram({
id,
name: data.name || 'Loaded Diagram',
data: mergedData,
createdAt: data.created || new Date().toISOString(),
updatedAt: data.lastModified || new Date().toISOString()
});
setFossflowKey(prev => prev + 1); // Force re-render
setHasUnsavedChanges(false);
};
// Auto-save functionality
useEffect(() => {
@@ -402,6 +456,14 @@ function App() {
<div className="App">
<div className="toolbar">
<button onClick={newDiagram}>New Diagram</button>
{serverStorageAvailable && (
<button
onClick={() => setShowDiagramManager(true)}
style={{ backgroundColor: '#2196F3', color: 'white' }}
>
🌐 Server Storage
</button>
)}
<button onClick={() => setShowSaveDialog(true)}>Save (Session Only)</button>
<button onClick={() => setShowLoadDialog(true)}>Load (Session Only)</button>
<button
@@ -601,6 +663,16 @@ function App() {
{showStorageManager && (
<StorageManager onClose={() => setShowStorageManager(false)} />
)}
{/* Diagram Manager */}
{showDiagramManager && (
<DiagramManager
onLoadDiagram={handleDiagramManagerLoad}
currentDiagramId={currentDiagram?.id}
currentDiagramData={currentModel || diagramData}
onClose={() => setShowDiagramManager(false)}
/>
)}
</div>
);
}

View File

@@ -0,0 +1,237 @@
.diagram-manager-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 10000;
}
.diagram-manager {
background: white;
border-radius: 8px;
width: 90%;
max-width: 800px;
max-height: 80vh;
display: flex;
flex-direction: column;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
}
.diagram-manager-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px;
border-bottom: 1px solid #e0e0e0;
}
.diagram-manager-header h2 {
margin: 0;
font-size: 24px;
}
.close-button {
background: none;
border: none;
font-size: 28px;
cursor: pointer;
color: #666;
padding: 0;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
}
.close-button:hover {
color: #000;
}
.storage-info {
padding: 15px 20px;
background: #f5f5f5;
border-bottom: 1px solid #e0e0e0;
display: flex;
align-items: center;
gap: 15px;
}
.storage-badge {
padding: 5px 12px;
border-radius: 20px;
font-size: 14px;
font-weight: 500;
}
.storage-badge.server {
background: #e3f2fd;
color: #1976d2;
}
.storage-badge.local {
background: #fff3e0;
color: #f57c00;
}
.storage-note {
font-size: 14px;
color: #666;
}
.error-message {
background: #ffebee;
color: #c62828;
padding: 10px 20px;
border-left: 4px solid #c62828;
}
.diagram-manager-actions {
padding: 20px;
border-bottom: 1px solid #e0e0e0;
}
.action-button {
padding: 10px 20px;
border: none;
border-radius: 4px;
font-size: 14px;
cursor: pointer;
background: #f5f5f5;
color: #333;
transition: background 0.2s;
}
.action-button:hover {
background: #e0e0e0;
}
.action-button.primary {
background: #4caf50;
color: white;
}
.action-button.primary:hover {
background: #45a049;
}
.action-button.danger {
background: #f44336;
color: white;
}
.action-button.danger:hover {
background: #da190b;
}
.loading {
padding: 40px;
text-align: center;
color: #666;
}
.diagram-list {
flex: 1;
overflow-y: auto;
padding: 20px;
}
.empty-state {
text-align: center;
padding: 40px;
color: #666;
}
.empty-state .hint {
font-size: 14px;
color: #999;
margin-top: 10px;
}
.diagram-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 15px;
border: 1px solid #e0e0e0;
border-radius: 4px;
margin-bottom: 10px;
transition: background 0.2s;
}
.diagram-item:hover {
background: #f5f5f5;
}
.diagram-info h3 {
margin: 0 0 5px 0;
font-size: 16px;
}
.diagram-meta {
font-size: 13px;
color: #666;
}
.diagram-actions {
display: flex;
gap: 10px;
}
.diagram-actions .action-button {
padding: 6px 12px;
font-size: 13px;
}
.save-dialog {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: white;
padding: 20px;
border-radius: 8px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2);
min-width: 300px;
}
.save-dialog h3 {
margin: 0 0 15px 0;
}
.save-dialog input {
width: 100%;
padding: 8px;
border: 1px solid #ddd;
border-radius: 4px;
margin-bottom: 15px;
font-size: 14px;
}
.dialog-buttons {
display: flex;
gap: 10px;
justify-content: flex-end;
}
.dialog-buttons button {
padding: 8px 16px;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
}
.dialog-buttons button:first-child {
background: #4caf50;
color: white;
}
.dialog-buttons button:last-child {
background: #f5f5f5;
}

View File

@@ -0,0 +1,218 @@
import React, { useState, useEffect } from 'react';
import { storageManager, DiagramInfo } from '../services/storageService';
import './DiagramManager.css';
interface Props {
onLoadDiagram: (id: string, data: any) => void;
currentDiagramId?: string;
currentDiagramData?: any;
onClose: () => void;
}
export const DiagramManager: React.FC<Props> = ({
onLoadDiagram,
currentDiagramId,
currentDiagramData,
onClose
}) => {
const [diagrams, setDiagrams] = useState<DiagramInfo[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [isServerStorage, setIsServerStorage] = useState(false);
const [saveName, setSaveName] = useState('');
const [showSaveDialog, setShowSaveDialog] = useState(false);
useEffect(() => {
loadDiagrams();
}, []);
const loadDiagrams = async () => {
try {
setLoading(true);
setError(null);
// Initialize storage if not already done
await storageManager.initialize();
setIsServerStorage(storageManager.isServerStorage());
// Load diagram list
const storage = storageManager.getStorage();
const list = await storage.listDiagrams();
setDiagrams(list);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load diagrams');
} finally {
setLoading(false);
}
};
const handleLoad = async (id: string) => {
try {
const storage = storageManager.getStorage();
const data = await storage.loadDiagram(id);
onLoadDiagram(id, data);
onClose();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load diagram');
}
};
const handleDelete = async (id: string) => {
if (!window.confirm('Are you sure you want to delete this diagram?')) {
return;
}
try {
const storage = storageManager.getStorage();
await storage.deleteDiagram(id);
await loadDiagrams(); // Refresh list
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to delete diagram');
}
};
const handleSave = async () => {
if (!saveName.trim()) {
setError('Please enter a diagram name');
return;
}
try {
const storage = storageManager.getStorage();
// Check if a diagram with this name already exists (excluding current diagram)
const existingDiagram = diagrams.find(d =>
d.name === saveName.trim() && d.id !== currentDiagramId
);
if (existingDiagram) {
const confirmOverwrite = window.confirm(
`A diagram named "${saveName}" already exists. This will overwrite it. Are you sure you want to continue?`
);
if (!confirmOverwrite) {
return;
}
// Delete the existing diagram first
await storage.deleteDiagram(existingDiagram.id);
}
const dataToSave = {
...currentDiagramData,
name: saveName
};
if (currentDiagramId) {
// Update existing
await storage.saveDiagram(currentDiagramId, dataToSave);
} else {
// Create new
await storage.createDiagram(dataToSave);
}
setShowSaveDialog(false);
setSaveName('');
await loadDiagrams(); // Refresh list
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to save diagram');
}
};
return (
<div className="diagram-manager-overlay">
<div className="diagram-manager">
<div className="diagram-manager-header">
<h2>Diagram Manager</h2>
<button className="close-button" onClick={onClose}>×</button>
</div>
<div className="storage-info">
<span className={`storage-badge ${isServerStorage ? 'server' : 'local'}`}>
{isServerStorage ? '🌐 Server Storage' : '💾 Local Storage'}
</span>
{isServerStorage && (
<span className="storage-note">
Diagrams are saved on the server and available across all devices
</span>
)}
</div>
{error && (
<div className="error-message">
{error}
</div>
)}
<div className="diagram-manager-actions">
<button
className="action-button primary"
onClick={() => {
setSaveName(currentDiagramData?.name || 'Untitled Diagram');
setShowSaveDialog(true);
}}
>
💾 Save Current Diagram
</button>
</div>
{loading ? (
<div className="loading">Loading diagrams...</div>
) : (
<div className="diagram-list">
{diagrams.length === 0 ? (
<div className="empty-state">
<p>No saved diagrams</p>
<p className="hint">Save your current diagram to get started</p>
</div>
) : (
diagrams.map(diagram => (
<div key={diagram.id} className="diagram-item">
<div className="diagram-info">
<h3>{diagram.name}</h3>
<span className="diagram-meta">
Last modified: {diagram.lastModified.toLocaleString()}
{diagram.size && `${(diagram.size / 1024).toFixed(1)} KB`}
</span>
</div>
<div className="diagram-actions">
<button
className="action-button"
onClick={() => handleLoad(diagram.id)}
>
Load
</button>
<button
className="action-button danger"
onClick={() => handleDelete(diagram.id)}
>
Delete
</button>
</div>
</div>
))
)}
</div>
)}
{/* Save Dialog */}
{showSaveDialog && (
<div className="save-dialog">
<h3>Save Diagram</h3>
<input
type="text"
placeholder="Diagram name"
value={saveName}
onChange={(e) => setSaveName(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleSave()}
autoFocus
/>
<div className="dialog-buttons">
<button onClick={handleSave}>Save</button>
<button onClick={() => setShowSaveDialog(false)}>Cancel</button>
</div>
</div>
)}
</div>
</div>
);
};

View File

@@ -0,0 +1,191 @@
import { Model } from 'fossflow/dist/types';
export interface DiagramInfo {
id: string;
name: string;
lastModified: Date;
size?: number;
}
export interface StorageService {
isAvailable(): Promise<boolean>;
listDiagrams(): Promise<DiagramInfo[]>;
loadDiagram(id: string): Promise<Model>;
saveDiagram(id: string, data: Model): Promise<void>;
deleteDiagram(id: string): Promise<void>;
createDiagram(data: Model): Promise<string>;
}
// Server Storage Implementation
class ServerStorage implements StorageService {
private baseUrl: string;
private available: boolean | null = null;
constructor(baseUrl: string = '') {
// In production (Docker), use relative paths (nginx proxy)
// In development, use localhost:3001
const isDevelopment = window.location.hostname === 'localhost' && window.location.port === '3000';
this.baseUrl = baseUrl || (isDevelopment ? 'http://localhost:3001' : '');
}
async isAvailable(): Promise<boolean> {
if (this.available !== null) return this.available;
try {
const response = await fetch(`${this.baseUrl}/api/storage/status`);
const data = await response.json();
this.available = data.enabled;
return this.available;
} catch (error) {
console.log('Server storage not available:', error);
this.available = false;
return false;
}
}
async listDiagrams(): Promise<DiagramInfo[]> {
const response = await fetch(`${this.baseUrl}/api/diagrams`);
if (!response.ok) throw new Error('Failed to list diagrams');
const diagrams = await response.json();
return diagrams.map((d: any) => ({
...d,
lastModified: new Date(d.lastModified)
}));
}
async loadDiagram(id: string): Promise<Model> {
const response = await fetch(`${this.baseUrl}/api/diagrams/${id}`);
if (!response.ok) throw new Error('Failed to load diagram');
return response.json();
}
async saveDiagram(id: string, data: Model): Promise<void> {
const response = await fetch(`${this.baseUrl}/api/diagrams/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
if (!response.ok) throw new Error('Failed to save diagram');
}
async deleteDiagram(id: string): Promise<void> {
const response = await fetch(`${this.baseUrl}/api/diagrams/${id}`, {
method: 'DELETE'
});
if (!response.ok) throw new Error('Failed to delete diagram');
}
async createDiagram(data: Model): Promise<string> {
const response = await fetch(`${this.baseUrl}/api/diagrams`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
if (!response.ok) throw new Error('Failed to create diagram');
const result = await response.json();
return result.id;
}
}
// Session Storage Implementation (existing functionality)
class SessionStorage implements StorageService {
private readonly KEY_PREFIX = 'fossflow_diagram_';
private readonly LIST_KEY = 'fossflow_diagrams';
async isAvailable(): Promise<boolean> {
return true; // Session storage is always available
}
async listDiagrams(): Promise<DiagramInfo[]> {
const listStr = sessionStorage.getItem(this.LIST_KEY);
if (!listStr) return [];
const list = JSON.parse(listStr);
return list.map((item: any) => ({
...item,
lastModified: new Date(item.lastModified)
}));
}
async loadDiagram(id: string): Promise<Model> {
const data = sessionStorage.getItem(`${this.KEY_PREFIX}${id}`);
if (!data) throw new Error('Diagram not found');
return JSON.parse(data);
}
async saveDiagram(id: string, data: Model): Promise<void> {
sessionStorage.setItem(`${this.KEY_PREFIX}${id}`, JSON.stringify(data));
// Update list
const list = await this.listDiagrams();
const existing = list.findIndex(d => d.id === id);
const info: DiagramInfo = {
id,
name: (data as any).name || 'Untitled Diagram',
lastModified: new Date(),
size: JSON.stringify(data).length
};
if (existing >= 0) {
list[existing] = info;
} else {
list.push(info);
}
sessionStorage.setItem(this.LIST_KEY, JSON.stringify(list));
}
async deleteDiagram(id: string): Promise<void> {
sessionStorage.removeItem(`${this.KEY_PREFIX}${id}`);
// Update list
const list = await this.listDiagrams();
const filtered = list.filter(d => d.id !== id);
sessionStorage.setItem(this.LIST_KEY, JSON.stringify(filtered));
}
async createDiagram(data: Model): Promise<string> {
const id = `diagram_${Date.now()}`;
await this.saveDiagram(id, data);
return id;
}
}
// Storage Manager - decides which storage to use
class StorageManager {
private serverStorage: ServerStorage;
private sessionStorage: SessionStorage;
private activeStorage: StorageService | null = null;
constructor() {
this.serverStorage = new ServerStorage();
this.sessionStorage = new SessionStorage();
}
async initialize(): Promise<StorageService> {
// Try server storage first
if (await this.serverStorage.isAvailable()) {
console.log('Using server storage');
this.activeStorage = this.serverStorage;
} else {
console.log('Using session storage');
this.activeStorage = this.sessionStorage;
}
return this.activeStorage;
}
getStorage(): StorageService {
if (!this.activeStorage) {
throw new Error('Storage not initialized. Call initialize() first.');
}
return this.activeStorage;
}
isServerStorage(): boolean {
return this.activeStorage === this.serverStorage;
}
}
// Export singleton instance
export const storageManager = new StorageManager();

View File

@@ -0,0 +1,20 @@
{
"name": "fossflow-backend",
"version": "1.0.0",
"description": "Optional backend server for FossFLOW persistent storage",
"main": "server.js",
"type": "module",
"scripts": {
"start": "node server.js",
"dev": "nodemon server.js"
},
"dependencies": {
"express": "^4.18.2",
"cors": "^2.8.5",
"dotenv": "^16.0.3",
"uuid": "^9.0.0"
},
"devDependencies": {
"nodemon": "^3.0.1"
}
}

View File

@@ -0,0 +1,197 @@
import express from 'express';
import cors from 'cors';
import fs from 'fs/promises';
import path from 'path';
import { fileURLToPath } from 'url';
import dotenv from 'dotenv';
// Load environment variables
dotenv.config();
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const app = express();
const PORT = process.env.BACKEND_PORT || 3001;
// Configuration from environment variables
const STORAGE_ENABLED = process.env.ENABLE_SERVER_STORAGE === 'true';
const STORAGE_PATH = process.env.STORAGE_PATH || '/data/diagrams';
const ENABLE_GIT_BACKUP = process.env.ENABLE_GIT_BACKUP === 'true';
// Middleware
app.use(cors());
app.use(express.json({ limit: '10mb' }));
// Health check / Storage status endpoint
app.get('/api/storage/status', (req, res) => {
res.json({
enabled: STORAGE_ENABLED,
gitBackup: ENABLE_GIT_BACKUP,
version: '1.0.0'
});
});
// Only enable storage endpoints if storage is enabled
if (STORAGE_ENABLED) {
// Ensure storage directory exists
async function ensureStorageDir() {
try {
await fs.access(STORAGE_PATH);
} catch {
await fs.mkdir(STORAGE_PATH, { recursive: true });
console.log(`Created storage directory: ${STORAGE_PATH}`);
}
}
// Initialize storage
ensureStorageDir().catch(console.error);
// List all diagrams
app.get('/api/diagrams', async (req, res) => {
try {
const files = await fs.readdir(STORAGE_PATH);
const diagrams = [];
for (const file of files) {
if (file.endsWith('.json') && file !== 'metadata.json') {
const filePath = path.join(STORAGE_PATH, file);
const stats = await fs.stat(filePath);
const content = await fs.readFile(filePath, 'utf-8');
const data = JSON.parse(content);
diagrams.push({
id: file.replace('.json', ''),
name: data.name || 'Untitled Diagram',
lastModified: stats.mtime,
size: stats.size
});
}
}
res.json(diagrams);
} catch (error) {
console.error('Error listing diagrams:', error);
res.status(500).json({ error: 'Failed to list diagrams' });
}
});
// Get specific diagram
app.get('/api/diagrams/:id', async (req, res) => {
try {
const filePath = path.join(STORAGE_PATH, `${req.params.id}.json`);
const content = await fs.readFile(filePath, 'utf-8');
res.json(JSON.parse(content));
} catch (error) {
if (error.code === 'ENOENT') {
res.status(404).json({ error: 'Diagram not found' });
} else {
console.error('Error reading diagram:', error);
res.status(500).json({ error: 'Failed to read diagram' });
}
}
});
// Save or update diagram
app.put('/api/diagrams/:id', async (req, res) => {
try {
const filePath = path.join(STORAGE_PATH, `${req.params.id}.json`);
const data = {
...req.body,
id: req.params.id,
lastModified: new Date().toISOString()
};
await fs.writeFile(filePath, JSON.stringify(data, null, 2));
// Git backup if enabled
if (ENABLE_GIT_BACKUP) {
// TODO: Implement git commit
console.log('Git backup not yet implemented');
}
res.json({ success: true, id: req.params.id });
} catch (error) {
console.error('Error saving diagram:', error);
res.status(500).json({ error: 'Failed to save diagram' });
}
});
// Delete diagram
app.delete('/api/diagrams/:id', async (req, res) => {
try {
const filePath = path.join(STORAGE_PATH, `${req.params.id}.json`);
await fs.unlink(filePath);
res.json({ success: true });
} catch (error) {
if (error.code === 'ENOENT') {
res.status(404).json({ error: 'Diagram not found' });
} else {
console.error('Error deleting diagram:', error);
res.status(500).json({ error: 'Failed to delete diagram' });
}
}
});
// Create a new diagram
app.post('/api/diagrams', async (req, res) => {
try {
const id = req.body.id || `diagram_${Date.now()}`;
const filePath = path.join(STORAGE_PATH, `${id}.json`);
// Check if already exists
try {
await fs.access(filePath);
return res.status(409).json({ error: 'Diagram already exists' });
} catch {
// File doesn't exist, proceed
}
const data = {
...req.body,
id,
created: new Date().toISOString(),
lastModified: new Date().toISOString()
};
await fs.writeFile(filePath, JSON.stringify(data, null, 2));
res.status(201).json({ success: true, id });
} catch (error) {
console.error('Error creating diagram:', error);
res.status(500).json({ error: 'Failed to create diagram' });
}
});
} else {
// Storage disabled - return appropriate responses
app.get('/api/diagrams', (req, res) => {
res.status(503).json({ error: 'Server storage is disabled' });
});
app.get('/api/diagrams/:id', (req, res) => {
res.status(503).json({ error: 'Server storage is disabled' });
});
app.put('/api/diagrams/:id', (req, res) => {
res.status(503).json({ error: 'Server storage is disabled' });
});
app.delete('/api/diagrams/:id', (req, res) => {
res.status(503).json({ error: 'Server storage is disabled' });
});
app.post('/api/diagrams', (req, res) => {
res.status(503).json({ error: 'Server storage is disabled' });
});
}
// Start server
app.listen(PORT, () => {
console.log(`FossFLOW Backend Server running on port ${PORT}`);
console.log(`Server storage: ${STORAGE_ENABLED ? 'ENABLED' : 'DISABLED'}`);
if (STORAGE_ENABLED) {
console.log(`Storage path: ${STORAGE_PATH}`);
console.log(`Git backup: ${ENABLE_GIT_BACKUP ? 'ENABLED' : 'DISABLED'}`);
}
});