From 7260c8d092234667bb22a0342f0d6d46c12b2c36 Mon Sep 17 00:00:00 2001 From: David Montero Crespo Date: Fri, 6 Mar 2026 20:38:06 -0300 Subject: [PATCH] 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 --- backend/app/api/routes/projects.py | 71 +++++++++++++++- backend/app/schemas/project.py | 15 +++- backend/app/services/project_files.py | 50 ++++++++++++ docker-compose.prod.yml | 1 + docker-compose.yml | 1 + frontend/src/App.tsx | 5 +- .../components/layout/SaveProjectModal.tsx | 3 +- frontend/src/pages/ProjectByIdPage.tsx | 80 +++++++++++++++++++ frontend/src/pages/ProjectPage.tsx | 25 ++++-- frontend/src/services/projectService.ts | 16 +++- 10 files changed, 250 insertions(+), 17 deletions(-) create mode 100644 backend/app/services/project_files.py create mode 100644 frontend/src/pages/ProjectByIdPage.tsx diff --git a/backend/app/api/routes/projects.py b/backend/app/api/routes/projects.py index 4582805..4714528 100644 --- a/backend/app/api/routes/projects.py +++ b/backend/app/api/routes/projects.py @@ -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) diff --git a/backend/app/schemas/project.py b/backend/app/schemas/project.py index 1ba3510..acae867 100644 --- a/backend/app/schemas/project.py +++ b/backend/app/schemas/project.py @@ -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 diff --git a/backend/app/services/project_files.py b/backend/app/services/project_files.py new file mode 100644 index 0000000..0e03f23 --- /dev/null +++ b/backend/app/services/project_files.py @@ -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) diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index f43f71f..76497ee 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -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: diff --git a/docker-compose.yml b/docker-compose.yml index 643069e..be38022 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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: diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index b0a58fb..084a6c7 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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() { } /> } /> } /> - {/* Specific literal routes must come before wildcard /:username */} + {/* Canonical project URL by ID */} + } /> + {/* Legacy slug route — redirects to /project/:id */} } /> } /> diff --git a/frontend/src/components/layout/SaveProjectModal.tsx b/frontend/src/components/layout/SaveProjectModal.tsx index a22715a..443b6ee 100644 --- a/frontend/src/components/layout/SaveProjectModal.tsx +++ b/frontend/src/components/layout/SaveProjectModal.tsx @@ -44,6 +44,7 @@ export const SaveProjectModal: React.FC = ({ 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 = ({ 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.'); diff --git a/frontend/src/pages/ProjectByIdPage.tsx b/frontend/src/pages/ProjectByIdPage.tsx new file mode 100644 index 0000000..c5a7f15 --- /dev/null +++ b/frontend/src/pages/ProjectByIdPage.tsx @@ -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 ( +
+
+

{error}

+ +
+
+ ); + } + + if (!ready) { + return ( +
+

Loading project…

+
+ ); + } + + return ; +}; diff --git a/frontend/src/pages/ProjectPage.tsx b/frontend/src/pages/ProjectPage.tsx index 59d48ea..526fc8e 100644 --- a/frontend/src/pages/ProjectPage.tsx +++ b/frontend/src/pages/ProjectPage.tsx @@ -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 = () => {

{error}

diff --git a/frontend/src/services/projectService.ts b/frontend/src/services/projectService.ts index 6043676..58a0f4b 100644 --- a/frontend/src/services/projectService.ts +++ b/frontend/src/services/projectService.ts @@ -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 { + const { data } = await api.get(`/projects/${id}`); + return data; +} + export async function getProject(username: string, slug: string): Promise { const { data } = await api.get(`/user/${username}/${slug}`); return data;