diff --git a/.env.example b/.env.example index ff5c938..d9a556b 100644 --- a/.env.example +++ b/.env.example @@ -8,8 +8,8 @@ # --- 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_REGISTER_OPEN=false # allow POST /api/auth/register +# AUTH_INVITE_ONLY=true # default: require invite token; set false for open signup # AUTH_DATABASE_PATH=./data/editor.db # AUTH_SESSION_DAYS=14 # BOOTSTRAP_ADMIN_USERNAME=admin # first-run only: create superuser if DB has zero users diff --git a/README.md b/README.md index 0fb1168..1838601 100644 --- a/README.md +++ b/README.md @@ -73,13 +73,13 @@ Notes: - `data/` is mounted to `/app/data` for the SQLite auth DB. - In container mode, `WORKSPACE_ROOT` and `AUTH_DATABASE_PATH` are set by `docker-compose.yml`. -**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. +**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` with an invite link (unless you opt into open signup) 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: +- By default **`AUTH_INVITE_ONLY=true`**: registrations need a valid invite token. Set **`AUTH_INVITE_ONLY=false`** to allow open signup whenever **`AUTH_REGISTER_OPEN=true`**. - 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. diff --git a/src/editor_app/deps.py b/src/editor_app/deps.py index 0e1cde7..62ef90c 100644 --- a/src/editor_app/deps.py +++ b/src/editor_app/deps.py @@ -1,7 +1,6 @@ from __future__ import annotations import os -import re from pathlib import Path from fastapi import Cookie, Depends, Header, HTTPException, Query @@ -11,6 +10,7 @@ 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 +from editor_app.services import user_workspace def api_key_valid(authorization: str | None) -> bool: @@ -66,13 +66,8 @@ async def require_superuser( 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) + user_workspace.ensure_default_code_main(user_root) async def get_workspace_root( @@ -91,6 +86,6 @@ async def get_workspace_root( 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) + user_root = user_workspace.user_workspace_root(target_user.id, target_user.username, workspace_root=root) _seed_user_workspace(user_root) return user_root diff --git a/src/editor_app/services/accounts.py b/src/editor_app/services/accounts.py index 4f77fcf..9a00eb2 100644 --- a/src/editor_app/services/accounts.py +++ b/src/editor_app/services/accounts.py @@ -12,6 +12,7 @@ from sqlalchemy import func, select from sqlalchemy.orm import Session from editor_app.db.models import AuthSession, InviteToken, User +from editor_app.services import user_workspace if TYPE_CHECKING: pass @@ -67,6 +68,10 @@ def create_user(db: Session, username: str, password: str, *, is_superuser: bool db.add(user) db.commit() db.refresh(user) + if auth_enabled(): + user_workspace.ensure_default_code_main( + user_workspace.user_workspace_root(user.id, user.username) + ) return user @@ -145,7 +150,7 @@ def delete_user(db: Session, user_id: int) -> bool: def invite_required() -> bool: - return os.environ.get("AUTH_INVITE_ONLY", "false").strip().lower() in ("1", "true", "yes", "on") + return os.environ.get("AUTH_INVITE_ONLY", "true").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: diff --git a/src/editor_app/services/user_workspace.py b/src/editor_app/services/user_workspace.py new file mode 100644 index 0000000..8be2029 --- /dev/null +++ b/src/editor_app/services/user_workspace.py @@ -0,0 +1,27 @@ +from __future__ import annotations + +import re +from pathlib import Path + +from editor_app import config + +DEFAULT_MAIN_PY = 'print("Hello, World!")\n' + + +def safe_workspace_leaf(username: str, user_id: int) -> str: + base = re.sub(r"[^a-zA-Z0-9._-]+", "-", username.strip()).strip("-").lower() or "user" + return f"{base}-{user_id}" + + +def user_workspace_root(user_id: int, username: str, workspace_root: Path | None = None) -> Path: + root = (workspace_root or config.WORKSPACE_ROOT).resolve() + return root / "users" / safe_workspace_leaf(username, user_id) + + +def ensure_default_code_main(user_root: Path) -> None: + """Ensure code/ exists and add a starter main.py when missing.""" + code_dir = user_root / "code" + code_dir.mkdir(parents=True, exist_ok=True) + main_py = code_dir / "main.py" + if not main_py.exists(): + main_py.write_text(DEFAULT_MAIN_PY, encoding="utf-8") diff --git a/src/static/script.js b/src/static/script.js index bbe9afb..835512c 100644 --- a/src/static/script.js +++ b/src/static/script.js @@ -89,7 +89,11 @@ class TextEditor { this.updateWorkspaceBanner(); this.prewarmPyWorker(); this.fetchViewerRole() - .finally(() => this.loadInitialDirectoryState().then(() => this.restoreSessionTabs())); + .finally(() => + this.loadInitialDirectoryState().then(() => + this.restoreSessionTabs().then(() => this.ensureDefaultMainOpen()) + ) + ); } async fetchViewerRole() { @@ -308,6 +312,13 @@ class TextEditor { this.saveSessionState(); } + async ensureDefaultMainOpen() { + if (this.openTabs.length > 0) { + return; + } + await this.openFile('code/main.py'); + } + async restoreExplorerState(session) { const expanded = Array.isArray(session.expandedDirs) ? session.expandedDirs.filter((path) => typeof path === 'string') diff --git a/tests/test_auth.py b/tests/test_auth.py index 3602492..71eca33 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -31,6 +31,32 @@ def test_auth_status_public(tmp_path, monkeypatch): assert r.json() == {"auth_enabled": False, "register_open": True, "invite_required": False} +def test_auth_invite_only_defaults_on(monkeypatch, tmp_path): + """When AUTH_INVITE_ONLY is unset, require invites (deployment-safe default).""" + import editor_app.config as config + import editor_app.db.session as db_sess + import editor_app.main as main + + monkeypatch.setenv("WORKSPACE_ROOT", str(tmp_path)) + monkeypatch.setenv("AUTH_DATABASE_PATH", str(tmp_path / "auth.db")) + monkeypatch.setenv("AUTH_REGISTER_OPEN", "true") + monkeypatch.setenv("AUTH_ENABLED", "true") + monkeypatch.delenv("AUTH_INVITE_ONLY", raising=False) + monkeypatch.delenv("EDITOR_API_KEY", raising=False) + monkeypatch.delenv("BOOTSTRAP_ADMIN_USERNAME", raising=False) + monkeypatch.delenv("BOOTSTRAP_ADMIN_PASSWORD", raising=False) + config.WORKSPACE_ROOT = tmp_path + db_sess.reset_engine() + importlib.reload(main) + + with TestClient(main.app) as client: + st = client.get("/api/auth/status") + assert st.status_code == 200 + assert st.json()["invite_required"] is True + denied = client.post("/api/auth/register", json={"username": "noc", "password": "password99"}) + assert denied.status_code == 403 + + def test_register_login_and_api_access(tmp_path, monkeypatch): with TestClient( _reload_app(tmp_path, monkeypatch, AUTH_ENABLED="true", AUTH_REGISTER_OPEN="true") @@ -58,6 +84,25 @@ def test_register_login_and_api_access(tmp_path, monkeypatch): assert client.get("/api/files").status_code == 401 +def test_new_user_workspace_has_default_main_py(tmp_path, monkeypatch): + with TestClient( + _reload_app(tmp_path, monkeypatch, AUTH_ENABLED="true", AUTH_REGISTER_OPEN="true") + ) as client: + reg = client.post("/api/auth/register", json={"username": "alice", "password": "password99"}) + assert reg.status_code == 200 + assert reg.json()["username"] == "alice" + uid = reg.json()["id"] + on_disk = tmp_path / "users" / f"alice-{uid}" / "code" / "main.py" + assert on_disk.is_file() + assert on_disk.read_text(encoding="utf-8") == 'print("Hello, World!")\n' + + assert client.post("/api/auth/login", json={"username": "alice", "password": "password99"}).status_code == 200 + fetched = client.get("/api/file/code/main.py") + assert fetched.status_code == 200 + assert fetched.json()["filename"] == "main.py" + assert 'Hello, World!' in fetched.json()["content"] + + def test_second_user_not_superuser(tmp_path, monkeypatch): with TestClient( _reload_app(tmp_path, monkeypatch, AUTH_ENABLED="true", AUTH_REGISTER_OPEN="true")