feat: /project/:id URL, per-project file volumes, and public/private access control

Backend:
- project_files.py: read/write sketch files to /app/data/projects/{id}/
- GET /api/projects/{id}: load project by ID (public = anyone, private = owner only)
- create/update write files to disk volume; delete removes them
- ProjectResponse includes files[] list loaded from disk

Frontend:
- /project/:id canonical route -> ProjectByIdPage
- ProjectPage (legacy /:username/:slug) redirects to /project/:id after load
- SaveProjectModal sends files[] and navigates to /project/{id} after save
- DATA_DIR env var in both compose files

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
pull/10/head
David Montero Crespo 2026-03-06 20:38:06 -03:00
parent 03f2d7f22e
commit 7260c8d092
10 changed files with 250 additions and 17 deletions

View File

@ -8,12 +8,24 @@ from app.core.dependencies import get_current_user, require_auth
from app.database.session import get_db
from app.models.project import Project
from app.models.user import User
from app.schemas.project import ProjectCreateRequest, ProjectResponse, ProjectUpdateRequest
from app.schemas.project import ProjectCreateRequest, ProjectResponse, ProjectUpdateRequest, SketchFile
from app.services.project_files import delete_files, read_files, write_files
from app.utils.slug import slugify
router = APIRouter()
def _files_for_project(project: Project) -> list[SketchFile]:
"""Load files from disk; fall back to legacy code field if disk is empty."""
disk = read_files(project.id)
if disk:
return [SketchFile(name=f["name"], content=f["content"]) for f in disk]
# Legacy: single sketch.ino from DB code field
if project.code:
return [SketchFile(name="sketch.ino", content=project.code)]
return []
def _to_response(project: Project, owner_username: str) -> ProjectResponse:
return ProjectResponse(
id=project.id,
@ -22,6 +34,7 @@ def _to_response(project: Project, owner_username: str) -> ProjectResponse:
description=project.description,
is_public=project.is_public,
board_type=project.board_type,
files=_files_for_project(project),
code=project.code,
components_json=project.components_json,
wires_json=project.wires_json,
@ -44,6 +57,8 @@ async def _unique_slug(db: AsyncSession, user_id: str, base_slug: str) -> str:
counter += 1
# ── My projects (literal route — must be before /{project_id}) ───────────────
@router.get("/projects/me", response_model=list[ProjectResponse])
async def my_projects(
db: AsyncSession = Depends(get_db),
@ -56,6 +71,30 @@ async def my_projects(
return [_to_response(p, user.username) for p in projects]
# ── GET by ID ────────────────────────────────────────────────────────────────
@router.get("/projects/{project_id}", response_model=ProjectResponse)
async def get_project_by_id(
project_id: str,
db: AsyncSession = Depends(get_db),
current_user: User | None = Depends(get_current_user),
):
result = await db.execute(select(Project).where(Project.id == project_id))
project = result.scalar_one_or_none()
if not project:
raise HTTPException(status_code=404, detail="Project not found.")
is_own = current_user and current_user.id == project.user_id
if not project.is_public and not is_own:
raise HTTPException(status_code=403, detail="This project is private.")
owner_result = await db.execute(select(User).where(User.id == project.user_id))
owner = owner_result.scalar_one_or_none()
return _to_response(project, owner.username if owner else "")
# ── Create ───────────────────────────────────────────────────────────────────
@router.post("/projects/", response_model=ProjectResponse, status_code=status.HTTP_201_CREATED)
async def create_project(
body: ProjectCreateRequest,
@ -79,9 +118,17 @@ async def create_project(
db.add(project)
await db.commit()
await db.refresh(project)
# Write sketch files to volume
files = body.files or ([SketchFile(name="sketch.ino", content=body.code)] if body.code else [])
if files:
write_files(project.id, [f.model_dump() for f in files])
return _to_response(project, user.username)
# ── Update ───────────────────────────────────────────────────────────────────
@router.put("/projects/{project_id}", response_model=ProjectResponse)
async def update_project(
project_id: str,
@ -98,7 +145,6 @@ async def update_project(
if body.name is not None:
project.name = body.name
# Re-slug only if the name actually changed
new_base = slugify(body.name)
if new_base != project.slug:
project.slug = await _unique_slug(db, user.id, new_base)
@ -118,9 +164,21 @@ async def update_project(
project.updated_at = datetime.now(timezone.utc)
await db.commit()
await db.refresh(project)
# Write updated files to volume
if body.files is not None:
write_files(project.id, [f.model_dump() for f in body.files])
elif body.code is not None:
# Legacy: update sketch.ino from code field only if no files were sent
existing = read_files(project.id)
if not existing:
write_files(project.id, [{"name": "sketch.ino", "content": body.code}])
return _to_response(project, user.username)
# ── Delete ───────────────────────────────────────────────────────────────────
@router.delete("/projects/{project_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_project(
project_id: str,
@ -135,8 +193,11 @@ async def delete_project(
raise HTTPException(status_code=403, detail="Forbidden.")
await db.delete(project)
await db.commit()
delete_files(project_id)
# ── User public projects ─────────────────────────────────────────────────────
@router.get("/user/{username}", response_model=list[ProjectResponse])
async def user_projects(
username: str,
@ -158,8 +219,10 @@ async def user_projects(
return [_to_response(p, owner.username) for p in projects]
# ── Get by username/slug ─────────────────────────────────────────────────────
@router.get("/user/{username}/{slug}", response_model=ProjectResponse)
async def get_project(
async def get_project_by_slug(
username: str,
slug: str,
db: AsyncSession = Depends(get_db),
@ -179,6 +242,6 @@ async def get_project(
is_own = current_user and current_user.id == owner.id
if not project.is_public and not is_own:
raise HTTPException(status_code=403, detail="Forbidden.")
raise HTTPException(status_code=403, detail="This project is private.")
return _to_response(project, owner.username)

View File

@ -3,12 +3,19 @@ from datetime import datetime
from pydantic import BaseModel
class SketchFile(BaseModel):
name: str
content: str
class ProjectCreateRequest(BaseModel):
name: str
description: str | None = None
is_public: bool = True
board_type: str = "arduino-uno"
code: str = ""
# Multi-file workspace. Falls back to legacy `code` field if omitted.
files: list[SketchFile] | None = None
code: str = "" # legacy single-file fallback
components_json: str = "[]"
wires_json: str = "[]"
@ -18,7 +25,8 @@ class ProjectUpdateRequest(BaseModel):
description: str | None = None
is_public: bool | None = None
board_type: str | None = None
code: str | None = None
files: list[SketchFile] | None = None
code: str | None = None # legacy
components_json: str | None = None
wires_json: str | None = None
@ -30,6 +38,9 @@ class ProjectResponse(BaseModel):
description: str | None
is_public: bool
board_type: str
# Files loaded from disk volume
files: list[SketchFile] = []
# Legacy single-file code (kept for backwards compat)
code: str
components_json: str
wires_json: str

View File

@ -0,0 +1,50 @@
"""
Reads and writes per-project sketch files to the data volume.
Files are stored at:
{DATA_DIR}/projects/{project_id}/{filename}
DATA_DIR defaults to /app/data (the bind-mounted volume).
"""
import os
from pathlib import Path
DATA_DIR = Path(os.environ.get("DATA_DIR", "/app/data"))
def _project_dir(project_id: str) -> Path:
return DATA_DIR / "projects" / project_id
def write_files(project_id: str, files: list[dict]) -> None:
"""Persist a list of {name, content} dicts to disk."""
d = _project_dir(project_id)
d.mkdir(parents=True, exist_ok=True)
# Remove files that are no longer in the list
names = {f["name"] for f in files}
for existing in d.iterdir():
if existing.is_file() and existing.name not in names:
existing.unlink()
for f in files:
(d / f["name"]).write_text(f["content"], encoding="utf-8")
def read_files(project_id: str) -> list[dict]:
"""Return [{name, content}] sorted by name. Empty list if directory absent."""
d = _project_dir(project_id)
if not d.exists():
return []
return [
{"name": p.name, "content": p.read_text(encoding="utf-8")}
for p in sorted(d.iterdir())
if p.is_file()
]
def delete_files(project_id: str) -> None:
"""Remove all files for a project from disk."""
import shutil
d = _project_dir(project_id)
if d.exists():
shutil.rmtree(d)

View File

@ -9,6 +9,7 @@ services:
- "3080:80"
environment:
- DATABASE_URL=sqlite+aiosqlite:////app/data/velxio.db
- DATA_DIR=/app/data
volumes:
- ./data:/app/data
healthcheck:

View File

@ -8,6 +8,7 @@ services:
restart: unless-stopped
environment:
- DATABASE_URL=sqlite+aiosqlite:////app/data/velxio.db
- DATA_DIR=/app/data
volumes:
- ./data:/app/data
healthcheck:

View File

@ -7,6 +7,7 @@ import { LoginPage } from './pages/LoginPage';
import { RegisterPage } from './pages/RegisterPage';
import { UserProfilePage } from './pages/UserProfilePage';
import { ProjectPage } from './pages/ProjectPage';
import { ProjectByIdPage } from './pages/ProjectByIdPage';
import { useAuthStore } from './store/useAuthStore';
import './App.css';
@ -25,7 +26,9 @@ function App() {
<Route path="/examples" element={<ExamplesPage />} />
<Route path="/login" element={<LoginPage />} />
<Route path="/register" element={<RegisterPage />} />
{/* Specific literal routes must come before wildcard /:username */}
{/* Canonical project URL by ID */}
<Route path="/project/:id" element={<ProjectByIdPage />} />
{/* Legacy slug route — redirects to /project/:id */}
<Route path="/:username/:projectName" element={<ProjectPage />} />
<Route path="/:username" element={<UserProfilePage />} />
</Routes>

View File

@ -44,6 +44,7 @@ export const SaveProjectModal: React.FC<SaveProjectModalProps> = ({ onClose }) =
description: description.trim() || undefined,
is_public: isPublic,
board_type: boardType,
files: files.map((f) => ({ name: f.name, content: f.content })),
code,
components_json: JSON.stringify(components),
wires_json: JSON.stringify(wires),
@ -62,7 +63,7 @@ export const SaveProjectModal: React.FC<SaveProjectModalProps> = ({ onClose }) =
ownerUsername: saved.owner_username,
isPublic: saved.is_public,
});
navigate(`/${saved.owner_username}/${saved.slug}`, { replace: true });
navigate(`/project/${saved.id}`, { replace: true });
onClose();
} catch (err: any) {
setError(err?.response?.data?.detail || 'Save failed.');

View File

@ -0,0 +1,80 @@
import { useEffect, useState } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { getProjectById } from '../services/projectService';
import { useEditorStore } from '../store/useEditorStore';
import { useSimulatorStore } from '../store/useSimulatorStore';
import { useProjectStore } from '../store/useProjectStore';
import { EditorPage } from './EditorPage';
export const ProjectByIdPage: React.FC = () => {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const loadFiles = useEditorStore((s) => s.loadFiles);
const { setComponents, setWires, setBoardType } = useSimulatorStore();
const setCurrentProject = useProjectStore((s) => s.setCurrentProject);
const currentProject = useProjectStore((s) => s.currentProject);
const [ready, setReady] = useState(false);
const [error, setError] = useState('');
useEffect(() => {
if (!id) return;
// If this project is already loaded in the store (e.g. navigated here
// right after saving) skip the fetch to avoid overwriting unsaved state.
if (currentProject?.id === id && ready) return;
getProjectById(id)
.then((project) => {
const files =
project.files.length > 0
? project.files
: [{ name: 'sketch.ino', content: project.code }];
loadFiles(files);
setBoardType(project.board_type as any);
try {
setComponents(JSON.parse(project.components_json));
setWires(JSON.parse(project.wires_json));
} catch {
// keep defaults if JSON is malformed
}
setCurrentProject({
id: project.id,
slug: project.slug,
ownerUsername: project.owner_username,
isPublic: project.is_public,
});
setReady(true);
})
.catch((err) => {
const s = err?.response?.status;
if (s === 404) setError('Project not found.');
else if (s === 403) setError('This project is private.');
else setError('Failed to load project.');
});
}, [id]);
if (error) {
return (
<div style={{ minHeight: '100vh', background: '#1e1e1e', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<div style={{ color: '#f44747', fontSize: 16, textAlign: 'center' }}>
<p>{error}</p>
<button
onClick={() => navigate('/')}
style={{ marginTop: 12, background: '#0e639c', border: 'none', color: '#fff', padding: '8px 16px', borderRadius: 4, cursor: 'pointer' }}
>
Go home
</button>
</div>
</div>
);
}
if (!ready) {
return (
<div style={{ minHeight: '100vh', background: '#1e1e1e', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<p style={{ color: '#9d9d9d' }}>Loading project</p>
</div>
);
}
return <EditorPage />;
};

View File

@ -6,10 +6,15 @@ import { useSimulatorStore } from '../store/useSimulatorStore';
import { useProjectStore } from '../store/useProjectStore';
import { EditorPage } from './EditorPage';
/**
* Legacy route: /:username/:projectName
* Loads the project by slug then redirects to /project/:id so the canonical
* URL is always the ID-based one.
*/
export const ProjectPage: React.FC = () => {
const { username, projectName } = useParams<{ username: string; projectName: string }>();
const navigate = useNavigate();
const setCode = useEditorStore((s) => s.setCode);
const loadFiles = useEditorStore((s) => s.loadFiles);
const { setComponents, setWires, setBoardType } = useSimulatorStore();
const setCurrentProject = useProjectStore((s) => s.setCurrentProject);
const [ready, setReady] = useState(false);
@ -19,13 +24,17 @@ export const ProjectPage: React.FC = () => {
if (!username || !projectName) return;
getProject(username, projectName)
.then((project) => {
setCode(project.code);
const files =
project.files.length > 0
? project.files
: [{ name: 'sketch.ino', content: project.code }];
loadFiles(files);
setBoardType(project.board_type as any);
try {
setComponents(JSON.parse(project.components_json));
setWires(JSON.parse(project.wires_json));
} catch {
// keep defaults if JSON is malformed
// keep defaults
}
setCurrentProject({
id: project.id,
@ -33,12 +42,14 @@ export const ProjectPage: React.FC = () => {
ownerUsername: project.owner_username,
isPublic: project.is_public,
});
// Redirect to canonical ID URL
navigate(`/project/${project.id}`, { replace: true });
setReady(true);
})
.catch((err) => {
const status = err?.response?.status;
if (status === 404) setError('Project not found.');
else if (status === 403) setError('This project is private.');
const s = err?.response?.status;
if (s === 404) setError('Project not found.');
else if (s === 403) setError('This project is private.');
else setError('Failed to load project.');
});
}, [username, projectName]);
@ -49,7 +60,7 @@ export const ProjectPage: React.FC = () => {
<div style={{ color: '#f44747', fontSize: 16, textAlign: 'center' }}>
<p>{error}</p>
<button onClick={() => navigate('/')} style={{ marginTop: 12, background: '#0e639c', border: 'none', color: '#fff', padding: '8px 16px', borderRadius: 4, cursor: 'pointer' }}>
Go to home
Go home
</button>
</div>
</div>

View File

@ -4,6 +4,11 @@ const API_BASE = import.meta.env.VITE_API_BASE || 'http://localhost:8001/api';
const api = axios.create({ baseURL: API_BASE, withCredentials: true });
export interface SketchFile {
name: string;
content: string;
}
export interface ProjectResponse {
id: string;
name: string;
@ -11,7 +16,8 @@ export interface ProjectResponse {
description: string | null;
is_public: boolean;
board_type: string;
code: string;
files: SketchFile[];
code: string; // legacy fallback
components_json: string;
wires_json: string;
owner_username: string;
@ -24,7 +30,8 @@ export interface ProjectSaveData {
description?: string;
is_public: boolean;
board_type: string;
code: string;
files: SketchFile[];
code?: string; // legacy fallback
components_json: string;
wires_json: string;
}
@ -39,6 +46,11 @@ export async function getUserProjects(username: string): Promise<ProjectResponse
return data;
}
export async function getProjectById(id: string): Promise<ProjectResponse> {
const { data } = await api.get<ProjectResponse>(`/projects/${id}`);
return data;
}
export async function getProject(username: string, slug: string): Promise<ProjectResponse> {
const { data } = await api.get<ProjectResponse>(`/user/${username}/${slug}`);
return data;