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
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user