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>
This commit is contained in:
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.database.session import get_db
from app.models.project import Project from app.models.project import Project
from app.models.user import User 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 from app.utils.slug import slugify
router = APIRouter() 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: def _to_response(project: Project, owner_username: str) -> ProjectResponse:
return ProjectResponse( return ProjectResponse(
id=project.id, id=project.id,
@ -22,6 +34,7 @@ def _to_response(project: Project, owner_username: str) -> ProjectResponse:
description=project.description, description=project.description,
is_public=project.is_public, is_public=project.is_public,
board_type=project.board_type, board_type=project.board_type,
files=_files_for_project(project),
code=project.code, code=project.code,
components_json=project.components_json, components_json=project.components_json,
wires_json=project.wires_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 counter += 1
# ── My projects (literal route — must be before /{project_id}) ───────────────
@router.get("/projects/me", response_model=list[ProjectResponse]) @router.get("/projects/me", response_model=list[ProjectResponse])
async def my_projects( async def my_projects(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
@ -56,6 +71,30 @@ async def my_projects(
return [_to_response(p, user.username) for p in 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) @router.post("/projects/", response_model=ProjectResponse, status_code=status.HTTP_201_CREATED)
async def create_project( async def create_project(
body: ProjectCreateRequest, body: ProjectCreateRequest,
@ -79,9 +118,17 @@ async def create_project(
db.add(project) db.add(project)
await db.commit() await db.commit()
await db.refresh(project) 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) return _to_response(project, user.username)
# ── Update ───────────────────────────────────────────────────────────────────
@router.put("/projects/{project_id}", response_model=ProjectResponse) @router.put("/projects/{project_id}", response_model=ProjectResponse)
async def update_project( async def update_project(
project_id: str, project_id: str,
@ -98,7 +145,6 @@ async def update_project(
if body.name is not None: if body.name is not None:
project.name = body.name project.name = body.name
# Re-slug only if the name actually changed
new_base = slugify(body.name) new_base = slugify(body.name)
if new_base != project.slug: if new_base != project.slug:
project.slug = await _unique_slug(db, user.id, new_base) 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) project.updated_at = datetime.now(timezone.utc)
await db.commit() await db.commit()
await db.refresh(project) 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) return _to_response(project, user.username)
# ── Delete ───────────────────────────────────────────────────────────────────
@router.delete("/projects/{project_id}", status_code=status.HTTP_204_NO_CONTENT) @router.delete("/projects/{project_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_project( async def delete_project(
project_id: str, project_id: str,
@ -135,8 +193,11 @@ async def delete_project(
raise HTTPException(status_code=403, detail="Forbidden.") raise HTTPException(status_code=403, detail="Forbidden.")
await db.delete(project) await db.delete(project)
await db.commit() await db.commit()
delete_files(project_id)
# ── User public projects ─────────────────────────────────────────────────────
@router.get("/user/{username}", response_model=list[ProjectResponse]) @router.get("/user/{username}", response_model=list[ProjectResponse])
async def user_projects( async def user_projects(
username: str, username: str,
@ -158,8 +219,10 @@ async def user_projects(
return [_to_response(p, owner.username) for p in projects] return [_to_response(p, owner.username) for p in projects]
# ── Get by username/slug ─────────────────────────────────────────────────────
@router.get("/user/{username}/{slug}", response_model=ProjectResponse) @router.get("/user/{username}/{slug}", response_model=ProjectResponse)
async def get_project( async def get_project_by_slug(
username: str, username: str,
slug: str, slug: str,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
@ -179,6 +242,6 @@ async def get_project(
is_own = current_user and current_user.id == owner.id is_own = current_user and current_user.id == owner.id
if not project.is_public and not is_own: 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) return _to_response(project, owner.username)

View File

@ -3,12 +3,19 @@ from datetime import datetime
from pydantic import BaseModel from pydantic import BaseModel
class SketchFile(BaseModel):
name: str
content: str
class ProjectCreateRequest(BaseModel): class ProjectCreateRequest(BaseModel):
name: str name: str
description: str | None = None description: str | None = None
is_public: bool = True is_public: bool = True
board_type: str = "arduino-uno" 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 = "[]" components_json: str = "[]"
wires_json: str = "[]" wires_json: str = "[]"
@ -18,7 +25,8 @@ class ProjectUpdateRequest(BaseModel):
description: str | None = None description: str | None = None
is_public: bool | None = None is_public: bool | None = None
board_type: str | 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 components_json: str | None = None
wires_json: str | None = None wires_json: str | None = None
@ -30,6 +38,9 @@ class ProjectResponse(BaseModel):
description: str | None description: str | None
is_public: bool is_public: bool
board_type: str board_type: str
# Files loaded from disk volume
files: list[SketchFile] = []
# Legacy single-file code (kept for backwards compat)
code: str code: str
components_json: str components_json: str
wires_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" - "3080:80"
environment: environment:
- DATABASE_URL=sqlite+aiosqlite:////app/data/velxio.db - DATABASE_URL=sqlite+aiosqlite:////app/data/velxio.db
- DATA_DIR=/app/data
volumes: volumes:
- ./data:/app/data - ./data:/app/data
healthcheck: healthcheck:

View File

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

View File

@ -7,6 +7,7 @@ import { LoginPage } from './pages/LoginPage';
import { RegisterPage } from './pages/RegisterPage'; import { RegisterPage } from './pages/RegisterPage';
import { UserProfilePage } from './pages/UserProfilePage'; import { UserProfilePage } from './pages/UserProfilePage';
import { ProjectPage } from './pages/ProjectPage'; import { ProjectPage } from './pages/ProjectPage';
import { ProjectByIdPage } from './pages/ProjectByIdPage';
import { useAuthStore } from './store/useAuthStore'; import { useAuthStore } from './store/useAuthStore';
import './App.css'; import './App.css';
@ -25,7 +26,9 @@ function App() {
<Route path="/examples" element={<ExamplesPage />} /> <Route path="/examples" element={<ExamplesPage />} />
<Route path="/login" element={<LoginPage />} /> <Route path="/login" element={<LoginPage />} />
<Route path="/register" element={<RegisterPage />} /> <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/:projectName" element={<ProjectPage />} />
<Route path="/:username" element={<UserProfilePage />} /> <Route path="/:username" element={<UserProfilePage />} />
</Routes> </Routes>

View File

@ -44,6 +44,7 @@ export const SaveProjectModal: React.FC<SaveProjectModalProps> = ({ onClose }) =
description: description.trim() || undefined, description: description.trim() || undefined,
is_public: isPublic, is_public: isPublic,
board_type: boardType, board_type: boardType,
files: files.map((f) => ({ name: f.name, content: f.content })),
code, code,
components_json: JSON.stringify(components), components_json: JSON.stringify(components),
wires_json: JSON.stringify(wires), wires_json: JSON.stringify(wires),
@ -62,7 +63,7 @@ export const SaveProjectModal: React.FC<SaveProjectModalProps> = ({ onClose }) =
ownerUsername: saved.owner_username, ownerUsername: saved.owner_username,
isPublic: saved.is_public, isPublic: saved.is_public,
}); });
navigate(`/${saved.owner_username}/${saved.slug}`, { replace: true }); navigate(`/project/${saved.id}`, { replace: true });
onClose(); onClose();
} catch (err: any) { } catch (err: any) {
setError(err?.response?.data?.detail || 'Save failed.'); 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 { useProjectStore } from '../store/useProjectStore';
import { EditorPage } from './EditorPage'; 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 = () => { export const ProjectPage: React.FC = () => {
const { username, projectName } = useParams<{ username: string; projectName: string }>(); const { username, projectName } = useParams<{ username: string; projectName: string }>();
const navigate = useNavigate(); const navigate = useNavigate();
const setCode = useEditorStore((s) => s.setCode); const loadFiles = useEditorStore((s) => s.loadFiles);
const { setComponents, setWires, setBoardType } = useSimulatorStore(); const { setComponents, setWires, setBoardType } = useSimulatorStore();
const setCurrentProject = useProjectStore((s) => s.setCurrentProject); const setCurrentProject = useProjectStore((s) => s.setCurrentProject);
const [ready, setReady] = useState(false); const [ready, setReady] = useState(false);
@ -19,13 +24,17 @@ export const ProjectPage: React.FC = () => {
if (!username || !projectName) return; if (!username || !projectName) return;
getProject(username, projectName) getProject(username, projectName)
.then((project) => { .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); setBoardType(project.board_type as any);
try { try {
setComponents(JSON.parse(project.components_json)); setComponents(JSON.parse(project.components_json));
setWires(JSON.parse(project.wires_json)); setWires(JSON.parse(project.wires_json));
} catch { } catch {
// keep defaults if JSON is malformed // keep defaults
} }
setCurrentProject({ setCurrentProject({
id: project.id, id: project.id,
@ -33,12 +42,14 @@ export const ProjectPage: React.FC = () => {
ownerUsername: project.owner_username, ownerUsername: project.owner_username,
isPublic: project.is_public, isPublic: project.is_public,
}); });
// Redirect to canonical ID URL
navigate(`/project/${project.id}`, { replace: true });
setReady(true); setReady(true);
}) })
.catch((err) => { .catch((err) => {
const status = err?.response?.status; const s = err?.response?.status;
if (status === 404) setError('Project not found.'); if (s === 404) setError('Project not found.');
else if (status === 403) setError('This project is private.'); else if (s === 403) setError('This project is private.');
else setError('Failed to load project.'); else setError('Failed to load project.');
}); });
}, [username, projectName]); }, [username, projectName]);
@ -49,7 +60,7 @@ export const ProjectPage: React.FC = () => {
<div style={{ color: '#f44747', fontSize: 16, textAlign: 'center' }}> <div style={{ color: '#f44747', fontSize: 16, textAlign: 'center' }}>
<p>{error}</p> <p>{error}</p>
<button onClick={() => navigate('/')} style={{ marginTop: 12, background: '#0e639c', border: 'none', color: '#fff', padding: '8px 16px', borderRadius: 4, cursor: 'pointer' }}> <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> </button>
</div> </div>
</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 }); const api = axios.create({ baseURL: API_BASE, withCredentials: true });
export interface SketchFile {
name: string;
content: string;
}
export interface ProjectResponse { export interface ProjectResponse {
id: string; id: string;
name: string; name: string;
@ -11,7 +16,8 @@ export interface ProjectResponse {
description: string | null; description: string | null;
is_public: boolean; is_public: boolean;
board_type: string; board_type: string;
code: string; files: SketchFile[];
code: string; // legacy fallback
components_json: string; components_json: string;
wires_json: string; wires_json: string;
owner_username: string; owner_username: string;
@ -24,7 +30,8 @@ export interface ProjectSaveData {
description?: string; description?: string;
is_public: boolean; is_public: boolean;
board_type: string; board_type: string;
code: string; files: SketchFile[];
code?: string; // legacy fallback
components_json: string; components_json: string;
wires_json: string; wires_json: string;
} }
@ -39,6 +46,11 @@ export async function getUserProjects(username: string): Promise<ProjectResponse
return data; 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> { export async function getProject(username: string, slug: string): Promise<ProjectResponse> {
const { data } = await api.get<ProjectResponse>(`/user/${username}/${slug}`); const { data } = await api.get<ProjectResponse>(`/user/${username}/${slug}`);
return data; return data;