Default per-user main.py; invite-only by default
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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=<token>`.
|
||||
|
||||
When auth is enabled, file APIs use a per-user workspace under `WORKSPACE_ROOT/users/<username-id>/` 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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
27
src/editor_app/services/user_workspace.py
Normal file
27
src/editor_app/services/user_workspace.py
Normal file
@@ -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")
|
||||
@@ -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')
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user