From 7d682cce8dc564339519af12276e3d9cd99efabd Mon Sep 17 00:00:00 2001 From: jimmy Date: Fri, 1 May 2026 21:13:13 +1200 Subject: [PATCH] Add admin invites and user workspace management tools. Implement invite-token registration with optional email delivery, add admin UI actions for creating invites and opening user workspaces, and support superuser workspace override while preserving per-user code isolation with shared read-only lib. Made-with: Cursor --- .env.example | 10 ++ README.md | 31 ++++- src/editor_app/db/models.py | 17 +++ src/editor_app/deps.py | 35 +++++- src/editor_app/routers/auth_routes.py | 17 ++- src/editor_app/routers/files.py | 36 +++--- src/editor_app/routers/users_admin.py | 25 +++- src/editor_app/schemas/users.py | 25 ++++ src/editor_app/services/accounts.py | 83 ++++++++++++- src/editor_app/services/filesystem.py | 129 ++++++++++++++------- src/static/home.html | 160 ++++++++++++++++++++++++++ src/static/index.html | 3 +- src/static/register.html | 11 +- src/static/script.js | 34 +++++- tests/test_auth.py | 138 +++++++++++++++++++++- 15 files changed, 683 insertions(+), 71 deletions(-) diff --git a/.env.example b/.env.example index 39df1a0..ff5c938 100644 --- a/.env.example +++ b/.env.example @@ -9,10 +9,20 @@ # --- User accounts (SQLite) --- # AUTH_ENABLED=true # require sign-in for /api/* (except /api/auth/*) # AUTH_REGISTER_OPEN=true # allow POST /api/auth/register +# AUTH_INVITE_ONLY=false # require invite token for registration # AUTH_DATABASE_PATH=./data/editor.db # AUTH_SESSION_DAYS=14 # BOOTSTRAP_ADMIN_USERNAME=admin # first-run only: create superuser if DB has zero users # BOOTSTRAP_ADMIN_PASSWORD=change-me-in-production +# Optional invite email (used by POST /api/users/invites) +# PUBLIC_BASE_URL=http://127.0.0.1:8080 +# SMTP_HOST=smtp.example.com +# SMTP_PORT=587 +# SMTP_USER=mailer@example.com +# SMTP_PASSWORD=app-password +# SMTP_FROM=Python Editor +# SMTP_TLS=true + # Base URL for `pipenv run test-selenium` (app must be running separately) # SELENIUM_BASE_URL=http://127.0.0.1:8080 diff --git a/README.md b/README.md index d7ce749..0fb1168 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # python-editor -Browser-based Python editing: **FastAPI** serves static assets, stores workspace files, and optional **API key auth**. **Pyodide** runs your scripts and **Jedi** (inside Pyodide) powers completions — no server-side Python execution or Jedi. +Browser-based Python editing: **FastAPI** serves static assets, stores workspace files, and optional **API key auth**. **Pyodide** runs your scripts and **Jedi** (inside Pyodide) powers completions and syntax diagnostics — no server-side Python execution or LSP process. ## Run @@ -48,6 +48,13 @@ If nothing is listening, the smoke test **skips** with a short message instead o Open [http://localhost:8080](http://localhost:8080). +### Editor runtime controls + +- `Run Python` runs the active open `.py` tab. +- Enable `Run main.py` to always run `code/main.py` instead. +- Pressing `Run Python` while a script is running will stop and restart with the selected target. +- `LSP` badge in the header shows in-browser Jedi syntax status (`n/a`, `checking...`, `OK`, or issue count). + ## Deploy with Docker Build and run with Docker Compose: @@ -68,6 +75,17 @@ Notes: **User accounts** — Set `AUTH_ENABLED=true` in `.env` to require sign-in for workspace APIs. Users live in a SQLite file (`AUTH_DATABASE_PATH`, default `./data/editor.db`). Use `/register` (if `AUTH_REGISTER_OPEN=true`) or `BOOTSTRAP_ADMIN_USERNAME` / `BOOTSTRAP_ADMIN_PASSWORD` for the first superuser. Superusers can **GET/POST/DELETE `/api/users`** to list, create, or remove accounts. +Email invite signup: + +- Superusers can create invites via `POST /api/users/invites` with `{ "email": "...", "expires_days": 7 }`. +- Response includes `invite_url`; if SMTP is configured the invite email is sent automatically. +- Set `AUTH_INVITE_ONLY=true` to require invite tokens for all registrations. +- Registration page accepts invite links like `/register?invite=`. + +When auth is enabled, file APIs use a per-user workspace under `WORKSPACE_ROOT/users//` for **isolated `code/`**. The `lib/` tree is shared and read-only for all users. When auth is disabled, the shared workspace root is used for everything. + +Admins can open another user's workspace from the home page user management panel (links to `/editor?workspace_user_id=`). Only superusers may use this override. + **API key** — If `EDITOR_API_KEY` is set, requests may use `Authorization: Bearer …` instead of a session (useful for automation). When `AUTH_ENABLED=true`, a valid session *or* API key is accepted. The home page can store the API key in `sessionStorage` when you are not using cookie login, or use `?api_key=` on `/editor`. @@ -95,7 +113,16 @@ np[0] = (255, 0, 0) np.write() ``` -`write()` updates the NeoPixel simulator window so you can verify behavior visually. +`write()` updates the NeoPixel simulator so you can verify behavior visually. + +Simulator modes: + +- Default: in-app LED strip/panel section under the editor. +- `16x16 panel` checkbox: opens a dedicated popup with 16x16 serpentine mapping: + - first LED at top-right + - first row goes right -> left + - rows zig-zag left/right. +- The 16x16 popup closes automatically on **Stop** or when script execution finishes. Tutorial files: diff --git a/src/editor_app/db/models.py b/src/editor_app/db/models.py index 11c4404..8b185fe 100644 --- a/src/editor_app/db/models.py +++ b/src/editor_app/db/models.py @@ -38,3 +38,20 @@ class AuthSession(Base): created_at: Mapped[dt.datetime] = mapped_column(DateTime, default=_utc_naive) user: Mapped[User] = relationship("User", back_populates="sessions") + + +class InviteToken(Base): + __tablename__ = "invite_tokens" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + email: Mapped[str] = mapped_column(String(320), index=True) + token: Mapped[str] = mapped_column(String(128), unique=True, index=True) + expires_at: Mapped[dt.datetime] = mapped_column(DateTime) + used_at: Mapped[dt.datetime | None] = mapped_column(DateTime, nullable=True) + invited_by_user_id: Mapped[int | None] = mapped_column( + ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True + ) + consumed_by_user_id: Mapped[int | None] = mapped_column( + ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True + ) + created_at: Mapped[dt.datetime] = mapped_column(DateTime, default=_utc_naive) diff --git a/src/editor_app/deps.py b/src/editor_app/deps.py index fd3f982..0e1cde7 100644 --- a/src/editor_app/deps.py +++ b/src/editor_app/deps.py @@ -1,12 +1,15 @@ from __future__ import annotations import os +import re +from pathlib import Path -from fastapi import Cookie, Depends, Header, HTTPException +from fastapi import Cookie, Depends, Header, HTTPException, Query from sqlalchemy.orm import Session from editor_app.db.session import get_db from editor_app.db.models import User +from editor_app import config from editor_app.services import accounts @@ -61,3 +64,33 @@ async def require_superuser( if not user.is_superuser: raise HTTPException(status_code=403, detail="Superuser required") return user + + +def _safe_workspace_leaf(user: User) -> str: + base = re.sub(r"[^a-zA-Z0-9._-]+", "-", user.username).strip("-").lower() or "user" + return f"{base}-{user.id}" + + +def _seed_user_workspace(user_root: Path) -> None: + (user_root / "code").mkdir(parents=True, exist_ok=True) + + +async def get_workspace_root( + user: User | None = Depends(get_current_user_optional), + workspace_user_id: int | None = Query(default=None), + db: Session = Depends(get_db), +) -> Path: + root = config.WORKSPACE_ROOT.resolve() + if not accounts.auth_enabled() or user is None: + return root + target_user = user + if workspace_user_id is not None: + if not user.is_superuser: + raise HTTPException(status_code=403, detail="Superuser required for workspace override") + lookup = accounts.get_user_by_id(db, int(workspace_user_id)) + if lookup is None: + raise HTTPException(status_code=404, detail="Workspace user not found") + target_user = lookup + user_root = root / "users" / _safe_workspace_leaf(target_user) + _seed_user_workspace(user_root) + return user_root diff --git a/src/editor_app/routers/auth_routes.py b/src/editor_app/routers/auth_routes.py index 86da76c..72d79f0 100644 --- a/src/editor_app/routers/auth_routes.py +++ b/src/editor_app/routers/auth_routes.py @@ -33,7 +33,11 @@ def _clear_session_cookie(response: Response, request: Request) -> None: @router.get("/status", response_model=AuthStatusResponse) async def auth_status() -> AuthStatusResponse: - return AuthStatusResponse(auth_enabled=accounts.auth_enabled(), register_open=accounts.register_open()) + return AuthStatusResponse( + auth_enabled=accounts.auth_enabled(), + register_open=accounts.register_open(), + invite_required=accounts.invite_required(), + ) @router.get("/me") @@ -57,10 +61,17 @@ async def register( ) -> UserPublic: if not accounts.auth_enabled(): raise HTTPException(status_code=400, detail="Set AUTH_ENABLED=true to use accounts") - if not accounts.register_open(): + if not accounts.register_open() and not body.invite_token: raise HTTPException(status_code=403, detail="Registration is disabled (AUTH_REGISTER_OPEN=false)") try: - user = accounts.register_user(db, body.username, body.password) + if accounts.invite_required(): + if not body.invite_token: + raise HTTPException(status_code=403, detail="Invite token is required") + user = accounts.register_user_with_invite(db, body.username, body.password, body.invite_token) + elif body.invite_token: + user = accounts.register_user_with_invite(db, body.username, body.password, body.invite_token) + else: + user = accounts.register_user(db, body.username, body.password) except ValueError as exc: raise HTTPException(status_code=409, detail=str(exc)) from exc return UserPublic.model_validate(user) diff --git a/src/editor_app/routers/files.py b/src/editor_app/routers/files.py index aa5e48b..4a0852f 100644 --- a/src/editor_app/routers/files.py +++ b/src/editor_app/routers/files.py @@ -1,5 +1,8 @@ -from fastapi import APIRouter +from pathlib import Path +from fastapi import APIRouter, Depends + +from editor_app.deps import get_workspace_root from editor_app.models import FileContent, FolderOperation, MoveFileRequest from editor_app.services import filesystem @@ -7,50 +10,51 @@ router = APIRouter(prefix="/api") @router.get("/files") -async def list_files(path: str = ""): - files = filesystem.list_files(path) +async def list_files(path: str = "", workspace_root: Path = Depends(get_workspace_root)): + files = filesystem.list_files(path, workspace_root=workspace_root) return {"files": files} @router.get("/workspace/py-sources") -async def workspace_python_sources(): - return {"files": filesystem.collect_python_sources()} +async def workspace_python_sources(workspace_root: Path = Depends(get_workspace_root)): + return {"files": filesystem.collect_python_sources(workspace_root=workspace_root)} @router.get("/file/{file_path:path}") -async def read_file(file_path: str): - content, filename = filesystem.read_text_file(file_path) +async def read_file(file_path: str, workspace_root: Path = Depends(get_workspace_root)): + content, filename = filesystem.read_text_file(file_path, workspace_root=workspace_root) return {"content": content, "filename": filename} @router.post("/file/{file_path:path}") -async def save_file(file_path: str, file_data: FileContent): - filename = filesystem.save_text_file(file_path, file_data.content) +async def save_file(file_path: str, file_data: FileContent, workspace_root: Path = Depends(get_workspace_root)): + filename = filesystem.save_text_file(file_path, file_data.content, workspace_root=workspace_root) return {"message": "File saved successfully", "filename": filename} @router.post("/file-move") -async def move_file(move_data: MoveFileRequest): +async def move_file(move_data: MoveFileRequest, workspace_root: Path = Depends(get_workspace_root)): new_path, moved_type = filesystem.move_path( source_path=move_data.source_path, destination_folder=move_data.destination_folder, + workspace_root=workspace_root, ) return {"message": "Path moved successfully", "new_path": new_path, "moved_type": moved_type} @router.delete("/file/{file_path:path}") -async def delete_file(file_path: str): - filesystem.delete_file(file_path) +async def delete_file(file_path: str, workspace_root: Path = Depends(get_workspace_root)): + filesystem.delete_file(file_path, workspace_root=workspace_root) return {"message": "File deleted successfully"} @router.post("/folder/new/{folder_path:path}") -async def create_folder(folder_path: str, folder_data: FolderOperation): - folder_name = filesystem.create_folder(folder_path) +async def create_folder(folder_path: str, folder_data: FolderOperation, workspace_root: Path = Depends(get_workspace_root)): + folder_name = filesystem.create_folder(folder_path, workspace_root=workspace_root) return {"message": "Folder created successfully", "folder": folder_name} @router.delete("/folder/{folder_path:path}") -async def delete_folder(folder_path: str): - filesystem.delete_folder(folder_path) +async def delete_folder(folder_path: str, workspace_root: Path = Depends(get_workspace_root)): + filesystem.delete_folder(folder_path, workspace_root=workspace_root) return {"message": "Folder deleted successfully"} diff --git a/src/editor_app/routers/users_admin.py b/src/editor_app/routers/users_admin.py index 3bc04d5..f99989b 100644 --- a/src/editor_app/routers/users_admin.py +++ b/src/editor_app/routers/users_admin.py @@ -6,7 +6,7 @@ from sqlalchemy.orm import Session from editor_app.db.session import get_db from editor_app.db.models import User from editor_app.deps import require_superuser -from editor_app.schemas.users import UserCreateAdmin, UserPublic +from editor_app.schemas.users import InviteCreateRequest, InviteCreateResponse, UserCreateAdmin, UserPublic from editor_app.services import accounts router = APIRouter(prefix="/api/users", tags=["users"]) @@ -48,3 +48,26 @@ async def delete_user_admin( if not accounts.delete_user(db, user_id): raise HTTPException(status_code=404, detail="User not found") return {"message": "User deleted"} + + +@router.post("/invites", response_model=InviteCreateResponse) +async def create_invite_admin( + body: InviteCreateRequest, + admin: User = Depends(require_superuser), + db: Session = Depends(get_db), +) -> InviteCreateResponse: + email = (body.email or "").strip().lower() + invite = accounts.create_invite( + db, + email, + invited_by_user_id=admin.id, + expires_days=body.expires_days, + ) + invite_url = accounts.build_invite_url(invite.token) + delivered = False + if email: + try: + delivered = accounts.send_invite_email(email, invite_url) + except Exception: + delivered = False + return InviteCreateResponse(email=invite.email, invite_url=invite_url, delivered=delivered) diff --git a/src/editor_app/schemas/users.py b/src/editor_app/schemas/users.py index 5e32016..3450359 100644 --- a/src/editor_app/schemas/users.py +++ b/src/editor_app/schemas/users.py @@ -4,6 +4,7 @@ from pydantic import BaseModel, Field, field_validator class RegisterRequest(BaseModel): username: str = Field(min_length=3, max_length=64) password: str = Field(min_length=8, max_length=128) + invite_token: str | None = Field(default=None, min_length=8, max_length=256) @field_validator("username") @classmethod @@ -44,3 +45,27 @@ class UserCreateAdmin(BaseModel): class AuthStatusResponse(BaseModel): auth_enabled: bool register_open: bool + invite_required: bool = False + + +class InviteCreateRequest(BaseModel): + email: str | None = Field(default=None, max_length=320) + expires_days: int = Field(default=7, ge=1, le=30) + + @field_validator("email") + @classmethod + def email_sane(cls, v: str | None) -> str | None: + if v is None: + return None + s = v.strip().lower() + if not s: + return None + if "@" not in s or "." not in s.split("@")[-1]: + raise ValueError("Invalid email address") + return s + + +class InviteCreateResponse(BaseModel): + email: str + invite_url: str + delivered: bool diff --git a/src/editor_app/services/accounts.py b/src/editor_app/services/accounts.py index 33125aa..4f77fcf 100644 --- a/src/editor_app/services/accounts.py +++ b/src/editor_app/services/accounts.py @@ -3,13 +3,15 @@ from __future__ import annotations import datetime as dt import os import secrets +import smtplib +from email.message import EmailMessage from typing import TYPE_CHECKING import bcrypt from sqlalchemy import func, select from sqlalchemy.orm import Session -from editor_app.db.models import AuthSession, User +from editor_app.db.models import AuthSession, InviteToken, User if TYPE_CHECKING: pass @@ -75,6 +77,20 @@ def register_user(db: Session, username: str, password: str) -> User: return create_user(db, username, password, is_superuser=first) +def register_user_with_invite(db: Session, username: str, password: str, invite_token: str) -> User: + invite = get_valid_invite(db, invite_token) + if invite is None: + raise ValueError("Invite is invalid or expired") + if get_user_by_username(db, username): + raise ValueError("Username already taken") + user = create_user(db, username, password, is_superuser=False) + invite.used_at = _utc_naive() + invite.consumed_by_user_id = user.id + db.add(invite) + db.commit() + return user + + def authenticate(db: Session, username: str, password: str) -> User | None: user = get_user_by_username(db, username.strip()) if not user or not verify_password(password, user.password_hash): @@ -126,3 +142,68 @@ def delete_user(db: Session, user_id: int) -> bool: db.delete(user) db.commit() return True + + +def invite_required() -> bool: + return os.environ.get("AUTH_INVITE_ONLY", "false").strip().lower() in ("1", "true", "yes", "on") + + +def create_invite(db: Session, email: str, invited_by_user_id: int | None = None, expires_days: int = 7) -> InviteToken: + token = secrets.token_urlsafe(36) + expires = _utc_naive() + dt.timedelta(days=max(1, min(30, int(expires_days)))) + row = InviteToken( + email=email.strip().lower(), + token=token, + expires_at=expires, + invited_by_user_id=invited_by_user_id, + ) + db.add(row) + db.commit() + db.refresh(row) + return row + + +def get_valid_invite(db: Session, token: str | None) -> InviteToken | None: + if not token: + return None + row = db.scalars(select(InviteToken).where(InviteToken.token == token.strip())).one_or_none() + if row is None: + return None + if row.used_at is not None: + return None + if row.expires_at < _utc_naive(): + return None + return row + + +def build_invite_url(token: str) -> str: + base = (os.environ.get("PUBLIC_BASE_URL") or "http://127.0.0.1:8080").rstrip("/") + return f"{base}/register?invite={token}" + + +def send_invite_email(email: str, invite_url: str) -> bool: + host = (os.environ.get("SMTP_HOST") or "").strip() + if not host: + return False + port = int((os.environ.get("SMTP_PORT") or "587").strip()) + user = (os.environ.get("SMTP_USER") or "").strip() + password = os.environ.get("SMTP_PASSWORD") or "" + sender = (os.environ.get("SMTP_FROM") or user or "noreply@python-editor.local").strip() + use_tls = (os.environ.get("SMTP_TLS", "true").strip().lower() in ("1", "true", "yes", "on")) + + msg = EmailMessage() + msg["Subject"] = "Your Python Editor invite" + msg["From"] = sender + msg["To"] = email + msg.set_content( + "You have been invited to Python Editor.\n\n" + f"Use this link to sign up:\n{invite_url}\n\n" + "If you did not expect this invite, you can ignore this message.\n" + ) + with smtplib.SMTP(host, port, timeout=10) as smtp: + if use_tls: + smtp.starttls() + if user: + smtp.login(user, password) + smtp.send_message(msg) + return True diff --git a/src/editor_app/services/filesystem.py b/src/editor_app/services/filesystem.py index c3a8da0..1727382 100644 --- a/src/editor_app/services/filesystem.py +++ b/src/editor_app/services/filesystem.py @@ -10,6 +10,14 @@ LIB_DIR_NAME = "lib" WRITABLE_ROOTS = {"code"} +def _workspace_root(workspace_root: Path | None = None) -> Path: + return (workspace_root or config.WORKSPACE_ROOT).resolve() + + +def _shared_lib_root() -> Path: + return (config.WORKSPACE_ROOT.resolve() / LIB_DIR_NAME).resolve() + + def normalize_relative_path(relative_path: str) -> str: cleaned = (relative_path or "").strip().lstrip("/") if not cleaned: @@ -22,33 +30,48 @@ def normalize_relative_path(relative_path: str) -> str: return "/".join(parts) -def resolve_workspace_path(relative_path: str) -> Path: +def resolve_workspace_path(relative_path: str, workspace_root: Path | None = None) -> Path: relative_path = normalize_relative_path(relative_path) - target_path = (config.WORKSPACE_ROOT / relative_path).resolve() + root = _workspace_root(workspace_root) + if relative_path == LIB_DIR_NAME or relative_path.startswith(f"{LIB_DIR_NAME}/"): + suffix = relative_path[len(LIB_DIR_NAME) :].lstrip("/") + target_path = (_shared_lib_root() / suffix).resolve() + try: + target_path.relative_to(_shared_lib_root()) + except ValueError as exc: + raise HTTPException(status_code=400, detail="Path escapes shared lib") from exc + return target_path + target_path = (root / relative_path).resolve() try: - target_path.relative_to(config.WORKSPACE_ROOT.resolve()) + target_path.relative_to(root) except ValueError as exc: raise HTTPException(status_code=400, detail="Path escapes workspace") from exc return target_path -def _is_path_in_lib(target_path: Path) -> bool: - workspace = config.WORKSPACE_ROOT.resolve() +def _is_path_in_lib(target_path: Path, workspace_root: Path | None = None) -> bool: + workspace = _workspace_root(workspace_root) lib_root = (workspace / LIB_DIR_NAME).resolve() + shared_lib_root = _shared_lib_root() try: target_path.resolve().relative_to(lib_root) return True + except ValueError: + pass + try: + target_path.resolve().relative_to(shared_lib_root) + return True except ValueError: return False -def _ensure_not_lib_path(target_path: Path) -> None: - if _is_path_in_lib(target_path): +def _ensure_not_lib_path(target_path: Path, workspace_root: Path | None = None) -> None: + if _is_path_in_lib(target_path, workspace_root): raise HTTPException(status_code=403, detail="lib is read-only") -def _is_writable_path(target_path: Path) -> bool: - workspace = config.WORKSPACE_ROOT.resolve() +def _is_writable_path(target_path: Path, workspace_root: Path | None = None) -> bool: + workspace = _workspace_root(workspace_root) resolved = target_path.resolve() try: relative = resolved.relative_to(workspace) @@ -59,17 +82,18 @@ def _is_writable_path(target_path: Path) -> bool: return relative.parts[0] in WRITABLE_ROOTS -def _ensure_writable_path(target_path: Path) -> None: - if not _is_writable_path(target_path): +def _ensure_writable_path(target_path: Path, workspace_root: Path | None = None) -> None: + if not _is_writable_path(target_path, workspace_root): raise HTTPException( status_code=403, detail="Only code/ is writable (lib is read-only)", ) -def list_files(path: str = "") -> list[FileInfo]: +def list_files(path: str = "", workspace_root: Path | None = None) -> list[FileInfo]: path = normalize_relative_path(path) - target_path = config.WORKSPACE_ROOT / path if path else config.WORKSPACE_ROOT + root = _workspace_root(workspace_root) + target_path = resolve_workspace_path(path, root) if path else root if not target_path.exists() or not target_path.is_dir(): raise HTTPException(status_code=404, detail="Directory not found") @@ -77,6 +101,8 @@ def list_files(path: str = "") -> list[FileInfo]: for item in sorted(target_path.iterdir()): if item.name.startswith("."): continue + if not path and item.name == "users": + continue files.append( FileInfo( name=item.name, @@ -84,11 +110,15 @@ def list_files(path: str = "") -> list[FileInfo]: size=item.stat().st_size if item.is_file() else None, ) ) + if not path: + shared_lib = _shared_lib_root() + if shared_lib.exists() and not any(f.name == LIB_DIR_NAME for f in files): + files.append(FileInfo(name=LIB_DIR_NAME, is_directory=True, size=None)) return files -def read_text_file(file_path: str) -> tuple[str, str]: - target_path = resolve_workspace_path(file_path) +def read_text_file(file_path: str, workspace_root: Path | None = None) -> tuple[str, str]: + target_path = resolve_workspace_path(file_path, workspace_root) if not target_path.exists(): raise HTTPException(status_code=404, detail="File not found") if target_path.is_dir(): @@ -100,19 +130,19 @@ def read_text_file(file_path: str) -> tuple[str, str]: return content, target_path.name -def save_text_file(file_path: str, content: str) -> str: - target_path = resolve_workspace_path(file_path) - _ensure_not_lib_path(target_path) - _ensure_writable_path(target_path) +def save_text_file(file_path: str, content: str, workspace_root: Path | None = None) -> str: + target_path = resolve_workspace_path(file_path, workspace_root) + _ensure_not_lib_path(target_path, workspace_root) + _ensure_writable_path(target_path, workspace_root) target_path.parent.mkdir(parents=True, exist_ok=True) target_path.write_text(content, encoding="utf-8") return target_path.name -def delete_file(file_path: str) -> None: - target_path = resolve_workspace_path(file_path) - _ensure_not_lib_path(target_path) - _ensure_writable_path(target_path) +def delete_file(file_path: str, workspace_root: Path | None = None) -> None: + target_path = resolve_workspace_path(file_path, workspace_root) + _ensure_not_lib_path(target_path, workspace_root) + _ensure_writable_path(target_path, workspace_root) if not target_path.exists(): raise HTTPException(status_code=404, detail="File not found") if target_path.is_dir(): @@ -120,20 +150,21 @@ def delete_file(file_path: str) -> None: target_path.unlink() -def move_path(source_path: str, destination_folder: str) -> tuple[str, str]: - source = resolve_workspace_path(source_path) - _ensure_not_lib_path(source) - _ensure_writable_path(source) +def move_path(source_path: str, destination_folder: str, workspace_root: Path | None = None) -> tuple[str, str]: + root = _workspace_root(workspace_root) + source = resolve_workspace_path(source_path, root) + _ensure_not_lib_path(source, root) + _ensure_writable_path(source, root) if not source.exists(): raise HTTPException(status_code=404, detail="Source path not found") destination_dir = ( - resolve_workspace_path(destination_folder) + resolve_workspace_path(destination_folder, root) if destination_folder - else config.WORKSPACE_ROOT + else root ) - _ensure_not_lib_path(destination_dir) - _ensure_writable_path(destination_dir) + _ensure_not_lib_path(destination_dir, root) + _ensure_writable_path(destination_dir, root) if not destination_dir.exists() or not destination_dir.is_dir(): raise HTTPException(status_code=404, detail="Destination folder not found") @@ -157,23 +188,23 @@ def move_path(source_path: str, destination_folder: str) -> tuple[str, str]: destination.parent.mkdir(parents=True, exist_ok=True) source.rename(destination) moved_type = "folder" if destination.is_dir() else "file" - return str(destination.relative_to(config.WORKSPACE_ROOT)), moved_type + return str(destination.relative_to(root)), moved_type -def create_folder(folder_path: str) -> str: - target_path = resolve_workspace_path(folder_path) - _ensure_not_lib_path(target_path) - _ensure_writable_path(target_path) +def create_folder(folder_path: str, workspace_root: Path | None = None) -> str: + target_path = resolve_workspace_path(folder_path, workspace_root) + _ensure_not_lib_path(target_path, workspace_root) + _ensure_writable_path(target_path, workspace_root) if target_path.exists(): raise HTTPException(status_code=400, detail="Folder already exists") target_path.mkdir(parents=True, exist_ok=False) return target_path.name -def delete_folder(folder_path: str) -> None: - target_path = resolve_workspace_path(folder_path) - _ensure_not_lib_path(target_path) - _ensure_writable_path(target_path) +def delete_folder(folder_path: str, workspace_root: Path | None = None) -> None: + target_path = resolve_workspace_path(folder_path, workspace_root) + _ensure_not_lib_path(target_path, workspace_root) + _ensure_writable_path(target_path, workspace_root) if not target_path.exists(): raise HTTPException(status_code=404, detail="Folder not found") if not target_path.is_dir(): @@ -181,10 +212,10 @@ def delete_folder(folder_path: str) -> None: shutil.rmtree(target_path) -def collect_python_sources() -> dict[str, str]: +def collect_python_sources(workspace_root: Path | None = None) -> dict[str, str]: """Return all UTF-8 .py files under the workspace for browser-side Pyodide sync.""" result: dict[str, str] = {} - workspace = config.WORKSPACE_ROOT.resolve() + workspace = _workspace_root(workspace_root) if not workspace.exists(): return result for path in workspace.rglob("*.py"): @@ -199,4 +230,18 @@ def collect_python_sources() -> dict[str, str]: result[key] = path.read_text(encoding="utf-8") except (UnicodeDecodeError, OSError): continue + shared_lib = _shared_lib_root() + if shared_lib.exists() and shared_lib.is_dir() and shared_lib != (workspace / LIB_DIR_NAME).resolve(): + for path in shared_lib.rglob("*.py"): + try: + rel = path.relative_to(shared_lib) + except ValueError: + continue + if any(part.startswith(".") for part in rel.parts): + continue + try: + key = str(Path(LIB_DIR_NAME) / rel).replace("\\", "/") + result[key] = path.read_text(encoding="utf-8") + except (UnicodeDecodeError, OSError): + continue return result diff --git a/src/static/home.html b/src/static/home.html index 38d9007..9b4f46a 100644 --- a/src/static/home.html +++ b/src/static/home.html @@ -52,6 +52,59 @@ .nav { display: flex; flex-wrap: wrap; gap: 0.5rem; margin-bottom: 1rem; align-items: center; } .nav span { color: #94a3b8; font-size: 0.9rem; } .hidden { display: none !important; } + .invite-panel { + margin: 1rem 0; + padding: 0.9rem; + border: 1px solid rgba(148, 163, 184, 0.35); + border-radius: 10px; + background: rgba(30, 41, 59, 0.45); + } + .invite-row { + display: flex; + gap: 0.5rem; + flex-wrap: wrap; + margin-top: 0.4rem; + } + .invite-row input[type="email"] { + flex: 1 1 260px; + width: auto; + margin: 0; + } + .invite-row button { + margin: 0; + } + .invite-result { + margin-top: 0.55rem; + font-size: 0.85rem; + color: #cbd5e1; + word-break: break-all; + } + .users-panel { + margin: 1rem 0; + padding: 0.9rem; + border: 1px solid rgba(148, 163, 184, 0.35); + border-radius: 10px; + background: rgba(30, 41, 59, 0.45); + } + .users-list { + margin-top: 0.5rem; + display: grid; + gap: 0.45rem; + } + .user-row { + display: flex; + justify-content: space-between; + align-items: center; + gap: 0.5rem; + font-size: 0.9rem; + } + .user-row a { + color: #93c5fd; + text-decoration: none; + border: 1px solid rgba(147, 197, 253, 0.35); + border-radius: 6px; + padding: 0.25rem 0.5rem; + } @@ -70,6 +123,20 @@

The key is kept in sessionStorage. You can also use ?api_key=… on the editor URL.

+ + Open Editor diff --git a/src/static/index.html b/src/static/index.html index 387b880..ecd87e0 100644 --- a/src/static/index.html +++ b/src/static/index.html @@ -30,6 +30,7 @@ Browser · Pyodide LSP: n/a +
Home @@ -86,6 +87,6 @@
- + diff --git a/src/static/register.html b/src/static/register.html index 633e08e..6252afd 100644 --- a/src/static/register.html +++ b/src/static/register.html @@ -61,11 +61,16 @@ +

Sign in · Home