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
parent
03f2d7f22e
commit
7260c8d092
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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.');
|
||||
|
|
|
|||
|
|
@ -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 />;
|
||||
};
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in New Issue