Add browser Python editor with Pyodide, user auth, and workspace API

- FastAPI serves static UI, file CRUD under code/ and read-only lib/
- Pyodide worker runs Python and Jedi completions in the browser
- SQLite accounts: login/register, session cookies, superuser user management
- Optional EDITOR_API_KEY, AUTH_* env vars, .env.example
- Pipenv, pytest, Selenium smoke test, README

Made-with: Cursor
This commit is contained in:
2026-05-01 14:33:26 +12:00
parent d245ecd353
commit f204109a84
40 changed files with 4950 additions and 2 deletions

View File

@@ -0,0 +1,128 @@
from __future__ import annotations
import datetime as dt
import os
import secrets
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
if TYPE_CHECKING:
pass
SESSION_COOKIE_NAME = "editor_session"
SESSION_DAYS_DEFAULT = 14
def auth_enabled() -> bool:
return os.environ.get("AUTH_ENABLED", "false").strip().lower() in ("1", "true", "yes", "on")
def register_open() -> bool:
return os.environ.get("AUTH_REGISTER_OPEN", "true").strip().lower() in ("1", "true", "yes", "on")
def session_ttl_days() -> int:
try:
return max(1, min(365, int(os.environ.get("AUTH_SESSION_DAYS", str(SESSION_DAYS_DEFAULT)))))
except ValueError:
return SESSION_DAYS_DEFAULT
def hash_password(password: str) -> str:
return bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()).decode("ascii")
def verify_password(plain: str, hashed: str) -> bool:
try:
return bcrypt.checkpw(plain.encode("utf-8"), hashed.encode("ascii"))
except (ValueError, TypeError):
return False
def get_user_by_username(db: Session, username: str) -> User | None:
return db.scalars(select(User).where(User.username == username)).one_or_none()
def get_user_by_id(db: Session, user_id: int) -> User | None:
return db.scalars(select(User).where(User.id == user_id)).one_or_none()
def count_users(db: Session) -> int:
return int(db.scalar(select(func.count()).select_from(User)) or 0)
def create_user(db: Session, username: str, password: str, *, is_superuser: bool = False) -> User:
user = User(
username=username,
password_hash=hash_password(password),
is_superuser=is_superuser,
)
db.add(user)
db.commit()
db.refresh(user)
return user
def register_user(db: Session, username: str, password: str) -> User:
if get_user_by_username(db, username):
raise ValueError("Username already taken")
first = count_users(db) == 0
return create_user(db, username, password, is_superuser=first)
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):
return None
return user
def _utc_naive() -> dt.datetime:
return dt.datetime.now(dt.UTC).replace(tzinfo=None)
def create_session(db: Session, user: User) -> AuthSession:
token = secrets.token_urlsafe(48)
expires = _utc_naive() + dt.timedelta(days=session_ttl_days())
row = AuthSession(user_id=user.id, token=token, expires_at=expires)
db.add(row)
db.commit()
db.refresh(row)
return row
def get_session_user(db: Session, token: str | None) -> User | None:
if not token:
return None
now = _utc_naive()
row = db.scalars(select(AuthSession).where(AuthSession.token == token)).one_or_none()
if not row or row.expires_at < now:
return None
return get_user_by_id(db, row.user_id)
def delete_session(db: Session, token: str | None) -> None:
if not token:
return
row = db.scalars(select(AuthSession).where(AuthSession.token == token)).one_or_none()
if row:
db.delete(row)
db.commit()
def list_users(db: Session) -> list[User]:
return list(db.scalars(select(User).order_by(User.username)).all())
def delete_user(db: Session, user_id: int) -> bool:
user = get_user_by_id(db, user_id)
if not user:
return False
db.delete(user)
db.commit()
return True