diff --git a/backend/app/api/routes/auth.py b/backend/app/api/routes/auth.py new file mode 100644 index 00000000..cad7b104 --- /dev/null +++ b/backend/app/api/routes/auth.py @@ -0,0 +1,174 @@ +from datetime import timedelta + +import httpx +from fastapi import APIRouter, Depends, HTTPException, Response, status +from fastapi.responses import RedirectResponse +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.config import settings +from app.core.dependencies import get_current_user, require_auth +from app.core.security import create_access_token, hash_password, verify_password +from app.database.session import get_db +from app.models.user import User +from app.schemas.auth import LoginRequest, RegisterRequest, UserResponse + +router = APIRouter() + + +def _set_auth_cookie(response: Response, token: str) -> None: + response.set_cookie( + key="access_token", + value=token, + httponly=True, + samesite="lax", + max_age=settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60, + secure=settings.COOKIE_SECURE, + ) + + +@router.post("/register", response_model=UserResponse, status_code=status.HTTP_201_CREATED) +async def register(body: RegisterRequest, response: Response, db: AsyncSession = Depends(get_db)): + # Check uniqueness + existing = await db.execute( + select(User).where((User.email == body.email) | (User.username == body.username)) + ) + if existing.scalar_one_or_none(): + raise HTTPException(status_code=400, detail="Email or username already taken.") + + user = User( + username=body.username, + email=body.email, + hashed_password=hash_password(body.password), + ) + db.add(user) + await db.commit() + await db.refresh(user) + + token = create_access_token({"sub": user.id}) + _set_auth_cookie(response, token) + return user + + +@router.post("/login", response_model=UserResponse) +async def login(body: LoginRequest, response: Response, db: AsyncSession = Depends(get_db)): + result = await db.execute(select(User).where(User.email == body.email)) + user = result.scalar_one_or_none() + if not user or not user.hashed_password or not verify_password(body.password, user.hashed_password): + raise HTTPException(status_code=401, detail="Invalid credentials.") + if not user.is_active: + raise HTTPException(status_code=403, detail="Account is disabled.") + + token = create_access_token({"sub": user.id}) + _set_auth_cookie(response, token) + return user + + +@router.get("/me", response_model=UserResponse) +async def me(user: User = Depends(get_current_user)): + if user is None: + raise HTTPException(status_code=401, detail="Not authenticated.") + return user + + +@router.post("/logout") +async def logout(response: Response, _user: User = Depends(require_auth)): + response.delete_cookie("access_token") + return {"message": "Logged out."} + + +# ── Google OAuth ────────────────────────────────────────────────────────────── + +GOOGLE_AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth" +GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token" +GOOGLE_USERINFO_URL = "https://www.googleapis.com/oauth2/v3/userinfo" + + +@router.get("/google") +async def google_login(): + if not settings.GOOGLE_CLIENT_ID: + raise HTTPException(status_code=501, detail="Google OAuth not configured.") + params = { + "client_id": settings.GOOGLE_CLIENT_ID, + "redirect_uri": settings.GOOGLE_REDIRECT_URI, + "response_type": "code", + "scope": "openid email profile", + "access_type": "offline", + } + from urllib.parse import urlencode + url = f"{GOOGLE_AUTH_URL}?{urlencode(params)}" + return RedirectResponse(url) + + +@router.get("/google/callback") +async def google_callback(code: str, response: Response, db: AsyncSession = Depends(get_db)): + if not settings.GOOGLE_CLIENT_ID: + raise HTTPException(status_code=501, detail="Google OAuth not configured.") + + async with httpx.AsyncClient() as client: + token_resp = await client.post( + GOOGLE_TOKEN_URL, + data={ + "code": code, + "client_id": settings.GOOGLE_CLIENT_ID, + "client_secret": settings.GOOGLE_CLIENT_SECRET, + "redirect_uri": settings.GOOGLE_REDIRECT_URI, + "grant_type": "authorization_code", + }, + ) + token_resp.raise_for_status() + access_token = token_resp.json()["access_token"] + + userinfo_resp = await client.get( + GOOGLE_USERINFO_URL, + headers={"Authorization": f"Bearer {access_token}"}, + ) + userinfo_resp.raise_for_status() + userinfo = userinfo_resp.json() + + google_id: str = userinfo["sub"] + email: str = userinfo.get("email", "") + avatar_url: str | None = userinfo.get("picture") + + # Upsert user by google_id + result = await db.execute(select(User).where(User.google_id == google_id)) + user = result.scalar_one_or_none() + + if not user: + # Try to find by email (link accounts) + result2 = await db.execute(select(User).where(User.email == email)) + user = result2.scalar_one_or_none() + if user: + user.google_id = google_id + if avatar_url and not user.avatar_url: + user.avatar_url = avatar_url + else: + # Generate username from email prefix + base_username = email.split("@")[0].lower() + import re + base_username = re.sub(r"[^a-z0-9_-]", "-", base_username)[:28] + username = base_username + counter = 1 + while True: + existing = await db.execute(select(User).where(User.username == username)) + if not existing.scalar_one_or_none(): + break + username = f"{base_username}{counter}" + counter += 1 + + user = User( + username=username, + email=email, + google_id=google_id, + avatar_url=avatar_url, + ) + db.add(user) + + await db.commit() + await db.refresh(user) + + jwt_token = create_access_token({"sub": user.id}, expires_delta=timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)) + # Send the user straight to the editor after OAuth login + redirect = RedirectResponse(url=f"{settings.FRONTEND_URL}/editor") + _set_auth_cookie(redirect, jwt_token) + return redirect diff --git a/backend/app/api/routes/projects.py b/backend/app/api/routes/projects.py new file mode 100644 index 00000000..47145284 --- /dev/null +++ b/backend/app/api/routes/projects.py @@ -0,0 +1,247 @@ +from datetime import datetime, timezone + +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +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, 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, + name=project.name, + slug=project.slug, + 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, + owner_username=owner_username, + created_at=project.created_at, + updated_at=project.updated_at, + ) + + +async def _unique_slug(db: AsyncSession, user_id: str, base_slug: str) -> str: + slug = base_slug or "project" + counter = 1 + while True: + result = await db.execute( + select(Project).where(Project.user_id == user_id, Project.slug == slug) + ) + if not result.scalar_one_or_none(): + return slug + slug = f"{base_slug}-{counter}" + 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), + user: User = Depends(require_auth), +): + result = await db.execute( + select(Project).where(Project.user_id == user.id).order_by(Project.updated_at.desc()) + ) + projects = result.scalars().all() + 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, + db: AsyncSession = Depends(get_db), + user: User = Depends(require_auth), +): + base_slug = slugify(body.name) or "project" + slug = await _unique_slug(db, user.id, base_slug) + + project = Project( + user_id=user.id, + name=body.name, + slug=slug, + description=body.description, + is_public=body.is_public, + board_type=body.board_type, + code=body.code, + components_json=body.components_json, + wires_json=body.wires_json, + ) + 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, + body: ProjectUpdateRequest, + db: AsyncSession = Depends(get_db), + user: User = Depends(require_auth), +): + 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.") + if project.user_id != user.id: + raise HTTPException(status_code=403, detail="Forbidden.") + + if body.name is not None: + project.name = body.name + new_base = slugify(body.name) + if new_base != project.slug: + project.slug = await _unique_slug(db, user.id, new_base) + if body.description is not None: + project.description = body.description + if body.is_public is not None: + project.is_public = body.is_public + if body.board_type is not None: + project.board_type = body.board_type + if body.code is not None: + project.code = body.code + if body.components_json is not None: + project.components_json = body.components_json + if body.wires_json is not None: + project.wires_json = body.wires_json + + 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, + db: AsyncSession = Depends(get_db), + user: User = Depends(require_auth), +): + 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.") + if project.user_id != user.id: + 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, + db: AsyncSession = Depends(get_db), + current_user: User | None = Depends(get_current_user), +): + result = await db.execute(select(User).where(User.username == username)) + owner = result.scalar_one_or_none() + if not owner: + raise HTTPException(status_code=404, detail="User not found.") + + is_own = current_user and current_user.id == owner.id + query = select(Project).where(Project.user_id == owner.id) + if not is_own: + query = query.where(Project.is_public == True) # noqa: E712 + query = query.order_by(Project.updated_at.desc()) + + projects = (await db.execute(query)).scalars().all() + 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_by_slug( + username: str, + slug: str, + db: AsyncSession = Depends(get_db), + current_user: User | None = Depends(get_current_user), +): + result = await db.execute(select(User).where(User.username == username)) + owner = result.scalar_one_or_none() + if not owner: + raise HTTPException(status_code=404, detail="User not found.") + + result2 = await db.execute( + select(Project).where(Project.user_id == owner.id, Project.slug == slug) + ) + project = result2.scalar_one_or_none() + if not project: + raise HTTPException(status_code=404, detail="Project not found.") + + 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="This project is private.") + + return _to_response(project, owner.username) diff --git a/backend/app/core/dependencies.py b/backend/app/core/dependencies.py new file mode 100644 index 00000000..d3db29b5 --- /dev/null +++ b/backend/app/core/dependencies.py @@ -0,0 +1,43 @@ +from fastapi import Depends, HTTPException, Request, status +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.security import decode_access_token +from app.database.session import get_db +from app.models.user import User + + +async def get_current_user( + request: Request, + db: AsyncSession = Depends(get_db), +) -> User | None: + token = request.cookies.get("access_token") + if not token: + return None + payload = decode_access_token(token) + if not payload: + return None + user_id: str | None = payload.get("sub") + if not user_id: + return None + result = await db.execute(select(User).where(User.id == user_id)) + user = result.scalar_one_or_none() + return user if (user and user.is_active) else None + + +async def require_auth( + user: User | None = Depends(get_current_user), +) -> User: + if user is None: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated") + return user + + +async def require_admin( + user: User | None = Depends(get_current_user), +) -> User: + if user is None: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated") + if not user.is_admin: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin access required") + return user diff --git a/backend/app/core/security.py b/backend/app/core/security.py new file mode 100644 index 00000000..daa7a4d6 --- /dev/null +++ b/backend/app/core/security.py @@ -0,0 +1,33 @@ +from datetime import datetime, timedelta, timezone +from typing import Any + +from jose import JWTError, jwt +from passlib.context import CryptContext + +from app.core.config import settings + +pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") + + +def hash_password(plain: str) -> str: + return pwd_context.hash(plain) + + +def verify_password(plain: str, hashed: str) -> bool: + return pwd_context.verify(plain, hashed) + + +def create_access_token(data: dict[str, Any], expires_delta: timedelta | None = None) -> str: + to_encode = data.copy() + expire = datetime.now(timezone.utc) + ( + expires_delta or timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES) + ) + to_encode["exp"] = expire + return jwt.encode(to_encode, settings.SECRET_KEY, algorithm="HS256") + + +def decode_access_token(token: str) -> dict[str, Any] | None: + try: + return jwt.decode(token, settings.SECRET_KEY, algorithms=["HS256"]) + except JWTError: + return None diff --git a/backend/app/database/session.py b/backend/app/database/session.py new file mode 100644 index 00000000..da81aa1d --- /dev/null +++ b/backend/app/database/session.py @@ -0,0 +1,18 @@ +from collections.abc import AsyncGenerator + +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine +from sqlalchemy.orm import DeclarativeBase + +from app.core.config import settings + +async_engine = create_async_engine(settings.DATABASE_URL, echo=False) +AsyncSessionLocal = async_sessionmaker(async_engine, expire_on_commit=False) + + +class Base(DeclarativeBase): + pass + + +async def get_db() -> AsyncGenerator[AsyncSession, None]: + async with AsyncSessionLocal() as session: + yield session diff --git a/backend/app/models/project.py b/backend/app/models/project.py new file mode 100644 index 00000000..8b83f587 --- /dev/null +++ b/backend/app/models/project.py @@ -0,0 +1,33 @@ +import uuid +from datetime import datetime, timezone + +from sqlalchemy import Boolean, DateTime, ForeignKey, String, Text, UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.database.session import Base + + +class Project(Base): + __tablename__ = "projects" + __table_args__ = (UniqueConstraint("user_id", "slug", name="uq_user_slug"),) + + id: Mapped[str] = mapped_column(String, primary_key=True, default=lambda: str(uuid.uuid4())) + user_id: Mapped[str] = mapped_column(String, ForeignKey("users.id"), nullable=False, index=True) + name: Mapped[str] = mapped_column(String(120), nullable=False) + slug: Mapped[str] = mapped_column(String(120), nullable=False) + description: Mapped[str | None] = mapped_column(String(500), nullable=True) + is_public: Mapped[bool] = mapped_column(Boolean, default=True) + board_type: Mapped[str] = mapped_column(String(50), default="arduino-uno") + code: Mapped[str] = mapped_column(Text, default="") + components_json: Mapped[str] = mapped_column(Text, default="[]") + wires_json: Mapped[str] = mapped_column(Text, default="[]") + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), default=lambda: datetime.now(timezone.utc) + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + default=lambda: datetime.now(timezone.utc), + onupdate=lambda: datetime.now(timezone.utc), + ) + + owner: Mapped["User"] = relationship("User", back_populates="projects") # noqa: F821 diff --git a/backend/app/models/user.py b/backend/app/models/user.py new file mode 100644 index 00000000..cc800e33 --- /dev/null +++ b/backend/app/models/user.py @@ -0,0 +1,25 @@ +import uuid +from datetime import datetime, timezone + +from sqlalchemy import Boolean, DateTime, String +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.database.session import Base + + +class User(Base): + __tablename__ = "users" + + id: Mapped[str] = mapped_column(String, primary_key=True, default=lambda: str(uuid.uuid4())) + username: Mapped[str] = mapped_column(String(30), unique=True, index=True, nullable=False) + email: Mapped[str] = mapped_column(String(255), unique=True, index=True, nullable=False) + hashed_password: Mapped[str | None] = mapped_column(String, nullable=True) + google_id: Mapped[str | None] = mapped_column(String, unique=True, nullable=True) + avatar_url: Mapped[str | None] = mapped_column(String, nullable=True) + is_active: Mapped[bool] = mapped_column(Boolean, default=True) + is_admin: Mapped[bool] = mapped_column(Boolean, default=False) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), default=lambda: datetime.now(timezone.utc) + ) + + projects: Mapped[list["Project"]] = relationship("Project", back_populates="owner", lazy="select") # noqa: F821 diff --git a/backend/app/schemas/auth.py b/backend/app/schemas/auth.py new file mode 100644 index 00000000..7f121350 --- /dev/null +++ b/backend/app/schemas/auth.py @@ -0,0 +1,44 @@ +from datetime import datetime + +from pydantic import BaseModel, EmailStr, field_validator + +from app.utils.slug import is_valid_username + + +class RegisterRequest(BaseModel): + username: str + email: EmailStr + password: str + + @field_validator("username") + @classmethod + def validate_username(cls, v: str) -> str: + if not is_valid_username(v.lower()): + raise ValueError( + "Username must be 3-30 chars, only lowercase letters/numbers/underscores/hyphens, " + "and not a reserved word." + ) + return v.lower() + + @field_validator("password") + @classmethod + def validate_password(cls, v: str) -> str: + if len(v) < 8: + raise ValueError("Password must be at least 8 characters.") + return v + + +class LoginRequest(BaseModel): + email: EmailStr + password: str + + +class UserResponse(BaseModel): + id: str + username: str + email: str + avatar_url: str | None + is_admin: bool = False + created_at: datetime + + model_config = {"from_attributes": True} diff --git a/backend/app/schemas/project.py b/backend/app/schemas/project.py new file mode 100644 index 00000000..acae8678 --- /dev/null +++ b/backend/app/schemas/project.py @@ -0,0 +1,51 @@ +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" + # 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 = "[]" + + +class ProjectUpdateRequest(BaseModel): + name: str | None = None + description: str | None = None + is_public: bool | None = None + board_type: str | None = None + files: list[SketchFile] | None = None + code: str | None = None # legacy + components_json: str | None = None + wires_json: str | None = None + + +class ProjectResponse(BaseModel): + id: str + name: str + slug: str + 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 + owner_username: str + created_at: datetime + updated_at: datetime + + model_config = {"from_attributes": True} diff --git a/backend/app/services/project_files.py b/backend/app/services/project_files.py new file mode 100644 index 00000000..0e03f235 --- /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/backend/app/utils/slug.py b/backend/app/utils/slug.py new file mode 100644 index 00000000..69fa697d --- /dev/null +++ b/backend/app/utils/slug.py @@ -0,0 +1,17 @@ +import re + +RESERVED_USERNAMES = {"login", "register", "api", "admin", "examples", "logout", "me", "google"} + + +def slugify(text: str) -> str: + text = text.lower().strip() + text = re.sub(r"[^a-z0-9\s-]", "", text) + text = re.sub(r"[\s_]+", "-", text) + text = re.sub(r"-+", "-", text) + return text.strip("-") + + +def is_valid_username(name: str) -> bool: + if name.lower() in RESERVED_USERNAMES: + return False + return bool(re.match(r"^[a-z0-9_-]{3,30}$", name)) diff --git a/deploy/nginx.conf b/deploy/nginx.conf new file mode 100644 index 00000000..568ee2dc --- /dev/null +++ b/deploy/nginx.conf @@ -0,0 +1,85 @@ +server { + listen 80; + server_name localhost velxio.dev www.velxio.dev; + root /usr/share/nginx/html; + index index.html; + + # Security headers + add_header X-Frame-Options "SAMEORIGIN" always; + add_header X-Content-Type-Options "nosniff" always; + add_header X-XSS-Protection "1; mode=block" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + + # SEO crawler files — never cache so Googlebot always gets the latest version + location = /sitemap.xml { + try_files $uri =404; + add_header Cache-Control "no-cache, must-revalidate"; + add_header X-Content-Type-Options "nosniff" always; + } + + location = /robots.txt { + try_files $uri =404; + add_header Cache-Control "no-cache, must-revalidate"; + } + + # Health check endpoint + location = /health { + proxy_pass http://127.0.0.1:8001/health; + proxy_set_header Host $host; + } + + # WebSocket endpoints (ESP32 simulation, etc.) + # Must come BEFORE the generic /api/ block so nginx matches it first. + location /api/simulation/ws/ { + proxy_pass http://127.0.0.1:8001/api/simulation/ws/; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + 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; + proxy_read_timeout 86400s; + proxy_send_timeout 86400s; + } + + # Proxy /api/* requests to the FastAPI backend. + # FastAPI Swagger UI is at /api/docs (moved from /docs to avoid + # conflicting with the frontend /docs/* documentation routes). + location /api/ { + proxy_pass http://127.0.0.1:8001/api/; + proxy_set_header Host $host; + 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; + proxy_read_timeout 300s; + proxy_connect_timeout 75s; + } + + # Cache static assets with content-hash filenames (js/css/fonts/images) + location ~* \.(js|css|woff|woff2|ttf|eot)$ { + expires 1y; + add_header Cache-Control "public, immutable"; + } + + location ~* \.(png|jpg|jpeg|gif|ico|svg|webp)$ { + expires 30d; + add_header Cache-Control "public"; + } + + # Gzip compression + gzip on; + gzip_vary on; + gzip_min_length 1024; + gzip_proxied any; + gzip_types text/plain text/css text/xml text/javascript application/javascript application/json application/xml application/rss+xml; + + # Frontend SPA routing — must be last so specific locations above take precedence + location = / { + return 301 /velxio/editor; + } + + location / { + try_files $uri $uri/ /index.html; + } +} diff --git a/frontend/src/components/editor/DeployProgress.css b/frontend/src/components/editor/DeployProgress.css new file mode 100644 index 00000000..50e40615 --- /dev/null +++ b/frontend/src/components/editor/DeployProgress.css @@ -0,0 +1,174 @@ +.deploy-progress { + position: fixed; + bottom: 1rem; + left: 50%; + transform: translateX(-50%); + z-index: 999; + min-width: 280px; + max-width: 90%; +} + +.deploy-progress-container { + background: linear-gradient(135deg, #1e293b 0%, #334155 100%); + border-radius: 0.75rem; + border: 1px solid #475569; + box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.5), 0 4px 10px -2px rgba(0, 0, 0, 0.3); + backdrop-filter: blur(10px); + -webkit-backdrop-filter: blur(10px); + overflow: hidden; +} + +.deploy-progress-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.75rem 1rem; + border-bottom: 1px solid #475569; +} + +.deploy-progress-indicator { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.deploy-state-indicator { + width: 8px; + height: 8px; + border-radius: 50%; + animation: pulse 1.5s infinite; +} + +.deploy-state-compiling { + background: #60a5fa; + box-shadow: 0 0 0 0 rgba(96, 165, 250, 0.7); +} + +.deploy-state-transferring { + background: #fbbf24; + box-shadow: 0 0 0 0 rgba(251, 191, 36, 0.7); +} + +.deploy-state-flashing { + background: #4ade80; + box-shadow: 0 0 0 0 rgba(74, 178, 128, 0.7); +} + +.deploy-state-success { + background: #4ade80; + animation: none; +} + +.deploy-state-error { + background: #f87171; +} + +.deploy-state-idle { + background: #94a3b8; +} + +@keyframes pulse { + 0% { + opacity: 1; + transform: scale(1); + } + 70% { + opacity: 0.5; + transform: scale(1.1); + } + 100% { + opacity: 1; + transform: scale(1); + } +} + +.deploy-progress-bar { + height: 6px; + background-color: #334155; + border-radius: 3px; + overflow: hidden; + margin: 0 0.75rem 0.75rem 0.75rem; +} + +.deploy-progress-fill { + height: 100%; + transition: width 0.3s ease; + border-radius: 3px; +} + +.deploy-progress-compiling { + background: linear-gradient(90deg, #3b82f6, #06b6d4); +} + +.deploy-progress-transferring { + background: linear-gradient(90deg, #f59e0b, #ef4444); +} + +.deploy-progress-flashing { + background: linear-gradient(90deg, #10b981, #059669); +} + +.deploy-progress-success { + background: linear-gradient(90deg, #10b981, #059669); +} + +.deploy-progress-error { + background: linear-gradient(90deg, #ef4444, #f97316); +} + +.deploy-progress-footer { + padding: 0.75rem 1rem; +} + +.deploy-progress-stats { + display: flex; + justify-content: space-between; + align-items: center; +} + +.deploy-progress-chunk { + font-size: 0.75rem; + color: #94a3b8; +} + +.deploy-progress-percent { + font-size: 0.75rem; + font-weight: 500; + color: #cbd5e1; +} + +.deploy-progress-error { + padding: 0.75rem 1rem; + background-color: rgba(239, 68, 68, 0.1); + border-top: 1px solid #fef2f2; + margin: 0 1rem 1rem 1rem; + border-radius: 0.5rem; +} + +.deploy-progress-error-message { + font-size: 0.875rem; + color: #fecaca; + line-height: 1.4; +} + +.deploy-close-btn { + background: none; + border: none; + color: #94a3b8; + cursor: pointer; + font-size: 1.25rem; + line-height: 1; + padding: 0; + width: 24px; + height: 24px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 9999px; + transition: all 0.2s ease; +} + +.deploy-close-btn:hover { + color: #e2e8f0; + background-color: #475569; +} \ No newline at end of file diff --git a/frontend/src/components/editor/DeployProgress.tsx b/frontend/src/components/editor/DeployProgress.tsx new file mode 100644 index 00000000..547b581e --- /dev/null +++ b/frontend/src/components/editor/DeployProgress.tsx @@ -0,0 +1,197 @@ +import { useEffect, useState } from 'react'; +import { useSimulatorStore } from '../store/useSimulatorStore'; + +type DeployState = 'idle' | 'compiling' | 'transferring' | 'flashing' | 'success' | 'error'; + +interface DeployProgressProps { + visible: boolean; + state: DeployState; + progress?: number; // 0-100 + chunkCount?: number; + totalChunks?: number; + error?: string; + onClose?: () => void; +} + +export function DeployProgress({ + visible, + state, + progress = 0, + chunkCount = 0, + totalChunks = 0, + error, + onClose, +}: DeployProgressProps) { + const [opacity, setOpacity] = useState(0); + + useEffect(() => { + if (visible) { + setOpacity(1); + } else { + setOpacity(0); + } + }, [visible]); + + if (!visible && opacity === 0) return null; + + const getProgressColor = () => { + switch (state) { + case 'compiling': + return 'from-blue-500 to-cyan-400'; + case 'transferring': + return 'from-amber-400 to-orange-500'; + case 'flashing': + return 'from-emerald-500 to-green-400'; + case 'success': + return 'from-green-500 to-emerald-400'; + case 'error': + return 'from-red-500 to-rose-400'; + default: + return 'from-blue-500 to-cyan-400'; + } + }; + + return ( +
+
+ {/* Header with state indicator */} +
+
+ {state === 'compiling' && ( +
+ )} + {state === 'transferring' && ( +
+ )} + {state === 'flashing' && ( +
+ )} + {state === 'success' && ( +
+ )} + {state === 'error' && ( +
+ )} + {state === 'idle' && ( +
+ )} + + {state === 'compiling' && 'Compiling...'} + {state === 'transferring' && 'Transferring...'} + {state === 'flashing' && 'Flashing...'} + {state === 'success' && 'Deploy Complete!'} + {state === 'error' && 'Deploy Failed'} + {state === 'idle' && 'Ready'} + +
+ {state !== 'idle' && onClose && ( + + )} +
+ + {/* Progress bar */} + {state !== 'idle' && state !== 'success' && ( +
+
+
0 ? 'w-[${progress}%]' : 'w-0' + }`} + style={{ width: `${Math.min(progress, 100)}%` }} + /> +
+ {chunkCount > 0 && totalChunks > 0 && ( +
+ Chunk: {chunkCount}/{totalChunks} + {Math.round(progress)}% +
+ )} +
+ )} + + {/* Error message */} + {state === 'error' && error && ( +
+
+ {error} +
+
+ )} +
+
+ ); +} + +// Hook to manage deploy state +export function useDeployProgress() { + const [visible, setVisible] = useState(false); + const [state, setState] = useState('idle'); + const [progress, setProgress] = useState(0); + const [chunkCount, setChunkCount] = useState(0); + const [totalChunks, setTotalChunks] = useState(0); + const [error, setError] = useState(undefined); + + const startDeploy = () => { + setState('compiling'); + setProgress(0); + setError(undefined); + setVisible(true); + }; + + const updateCompileProgress = (p: number) => { + setProgress(p); + }; + + const onTransferStart = (total: number) => { + setState('transferring'); + setTotalChunks(total); + setChunkCount(0); + setProgress(100); + }; + + const onTransferProgress = (sent: number) => { + setChunkCount(sent); + }; + + const onFlashStart = () => { + setState('flashing'); + setProgress(100); + }; + + const onComplete = () => { + setState('success'); + setTimeout(() => { + setVisible(false); + setState('idle'); + }, 2000); + }; + + const onError = (err: string) => { + setState('error'); + setError(err); + }; + + return { + visible, + state, + progress, + chunkCount, + totalChunks, + error, + startDeploy, + updateCompileProgress, + onTransferStart, + onTransferProgress, + onFlashStart, + onComplete, + onError, + }; +} \ No newline at end of file diff --git a/frontend/src/services/EmbedBridge.ts b/frontend/src/services/EmbedBridge.ts new file mode 100644 index 00000000..13ed86a5 --- /dev/null +++ b/frontend/src/services/EmbedBridge.ts @@ -0,0 +1,170 @@ +/** + * EmbedBridge — PostMessage bridge for Elemes LMS integration. + * + * When Velxio is loaded in an iframe with ?embed=true, this bridge + * listens for commands from the parent (Elemes) and responds with + * simulator state (source code, serial log, wires). + */ + +import { useEditorStore } from '../store/useEditorStore'; +import { useSimulatorStore } from '../store/useSimulatorStore'; + +class EmbedBridge { + private isEmbedded: boolean; + + constructor() { + this.isEmbedded = window.parent !== window; + if (this.isEmbedded) { + window.addEventListener('message', this.onMessage.bind(this)); + } + } + + private _readyInterval: ReturnType | null = null; + + /** Call after Velxio is fully loaded. Repeats until parent acknowledges. */ + notifyReady() { + this.send('velxio:ready', { version: '1.0' }); + // Keep sending until parent acknowledges (handles race with listener setup) + this._readyInterval = setInterval(() => { + this.send('velxio:ready', { version: '1.0' }); + }, 300); + } + + /** Notify parent that compilation finished. */ + notifyCompileResult(success: boolean) { + this.send('velxio:compile_result', { success }); + } + + /** Parent received ready — stop broadcasting. */ + private stopReadyBroadcast() { + if (this._readyInterval) { + clearInterval(this._readyInterval); + this._readyInterval = null; + } + } + + private send(type: string, payload: Record = {}) { + if (!this.isEmbedded) return; + window.parent.postMessage({ type, ...payload }, '*'); + } + + private onMessage(event: MessageEvent) { + const { type } = event.data || {}; + if (!type?.startsWith('elemes:')) return; + + // Any message from parent means it's listening — stop ready broadcast + this.stopReadyBroadcast(); + + switch (type) { + case 'elemes:load_code': { + const files = (event.data.files as { name: string; content: string }[]) || []; + useEditorStore.getState().loadFiles(files); + break; + } + + case 'elemes:load_circuit': { + const data = event.data as { + board?: string; + components?: Array<{ + type: string; + id: string; + x: number; + y: number; + rotation?: number; + props?: Record; + }>; + wires?: Array; + }; + + const store = useSimulatorStore.getState(); + + // Set components if provided + if (data.components) { + const mapped = data.components.map((c) => ({ + id: c.id, + metadataId: c.type, + x: c.x, + y: c.y, + properties: { ...(c.props || {}), rotation: c.rotation || 0 }, + })); + store.setComponents(mapped); + } + + // Set wires if provided + if (data.wires && Array.isArray(data.wires)) { + // Filter out null/undefined wires to prevent runtime errors + const validWires = data.wires.filter((w) => w && w.start && w.end && w.id) as never[]; + store.setWires(validWires); + } + break; + } + + case 'elemes:get_source_code': { + const files = useEditorStore.getState().files; + const payload = { + files: files.map((f) => ({ name: f.name, content: f.content })), + }; + console.log('[EmbedBridge] Responding to get_source_code:', payload.files.length, 'files'); + this.send('velxio:source_code', payload); + break; + } + + case 'elemes:get_serial_log': { + const state = useSimulatorStore.getState(); + // Read from active board's serialOutput + const activeBoard = state.boards.find((b) => b.id === state.activeBoardId); + const log = activeBoard?.serialOutput ?? state.serialOutput ?? ''; + console.log('[EmbedBridge] Responding to get_serial_log:', JSON.stringify(log).substring(0, 200)); + console.log('[EmbedBridge] activeBoardId:', state.activeBoardId, 'boards:', state.boards.length); + this.send('velxio:serial_log', { log }); + break; + } + + case 'elemes:get_wires': { + const wires = useSimulatorStore.getState().wires; + console.log('[EmbedBridge] Responding to get_wires:', wires.length, 'wires'); + wires.forEach((w, i) => { + console.log(`[EmbedBridge] wire[${i}]: ${w.start.componentId}:${w.start.pinName} → ${w.end.componentId}:${w.end.pinName}`); + }); + this.send('velxio:wires', { wires }); + break; + } + + case 'elemes:set_embed_mode': { + window.dispatchEvent( + new CustomEvent('velxio-embed-mode', { detail: event.data }) + ); + break; + } + + case 'elemes:ping': { + // Parent missed the initial ready signal — re-send it + this.notifyReady(); + break; + } + + case 'elemes:compile_and_run': { + // Could trigger compile+run programmatically — future enhancement + break; + } + + case 'elemes:stop': { + const store = useSimulatorStore.getState(); + if (store.activeBoardId) { + store.stopBoard(store.activeBoardId); + } else { + store.stopSimulation(); + } + break; + } + } + } +} + +/** Singleton — created once when module loads. */ +export const embedBridge = new EmbedBridge(); + +// Expose Zustand stores on window so parent iframe (same-origin) can access them directly. +// This is a fallback for when PostMessage bridge doesn't connect. +(window as any).__VELXIO_EDITOR_STORE__ = useEditorStore; +(window as any).__VELXIO_SIMULATOR_STORE__ = useSimulatorStore;