|
# retoor <retoor@molodetz.nl>
|
|
|
|
from typing import Annotated
|
|
|
|
from fastapi import APIRouter, Depends, Form, Request, WebSocket
|
|
from starlette.responses import Response
|
|
|
|
from devplacepy.content import (
|
|
can_manage_workspace,
|
|
can_open_workspace,
|
|
)
|
|
from devplacepy.database import get_table, resolve_by_slug
|
|
from devplacepy.dependencies import json_or_form
|
|
from devplacepy.models import EditorPrefsForm, TunnelForm
|
|
from devplacepy.responses import action_result, json_error, respond
|
|
from devplacepy.schemas import WorkspaceOut
|
|
from devplacepy.services.audit import record as audit
|
|
from devplacepy.services.containers import activity, api, forward, store
|
|
from devplacepy.services.containers.api import ContainerError
|
|
from devplacepy.services.containers.workspace import editor, provision, quota, tunnels
|
|
from devplacepy.services.containers.workspace.provision import WorkspaceError
|
|
from devplacepy.utils import not_found, require_user
|
|
|
|
from ._shared import audit_instance, fail
|
|
|
|
router = APIRouter()
|
|
|
|
|
|
def _restart_required(instance: dict, profile: editor.EditorProfile) -> bool:
|
|
if instance.get("status") != store.ST_RUNNING:
|
|
return False
|
|
return editor.restart_required(instance, profile)
|
|
|
|
|
|
def _project_or_404(slug: str) -> dict:
|
|
project = resolve_by_slug(get_table("projects"), slug)
|
|
if not project:
|
|
raise not_found("Project not found")
|
|
return project
|
|
|
|
|
|
def _workspace_or_404(project: dict, user: dict) -> dict:
|
|
instance = provision.find_for_project(project["uid"], user["uid"])
|
|
if not instance:
|
|
raise not_found("No workspace for this project")
|
|
return instance
|
|
|
|
|
|
def _guard(request: Request, project: dict, user: dict, event_key: str) -> None:
|
|
if can_open_workspace(project, user):
|
|
return
|
|
audit.record(
|
|
request,
|
|
event_key,
|
|
user=user,
|
|
target_type="project",
|
|
target_uid=project["uid"],
|
|
target_label=project.get("title"),
|
|
summary=f"{user['username']} denied workspace access",
|
|
result="denied",
|
|
)
|
|
raise not_found("Workspaces are not available for this project")
|
|
|
|
|
|
@router.get("/{slug}/workspace")
|
|
async def workspace_page(request: Request, slug: str):
|
|
user = require_user(request)
|
|
if isinstance(user, Response):
|
|
return user
|
|
project = _project_or_404(slug)
|
|
_guard(request, project, user, "container.workspace.open")
|
|
instance = provision.find_for_project(project["uid"], user["uid"])
|
|
limits = quota.resolve(user["uid"])
|
|
profile = editor.resolve(user["uid"], instance)
|
|
context = {
|
|
"project": project,
|
|
"workspace": provision.view(instance) if instance else None,
|
|
"has_workspace": bool(instance),
|
|
"viewer_can_workspace": True,
|
|
"workspace_count": provision.count_for_owner(user["uid"]),
|
|
"max_workspaces": limits.max_workspaces,
|
|
"editor_url": (
|
|
f"/projects/{slug}/containers/instances/{instance['uid']}/code/"
|
|
if instance
|
|
else ""
|
|
),
|
|
"editor_password": (
|
|
api.ensure_editor_password(instance) if instance else ""
|
|
),
|
|
"editor": editor.view(user["uid"], instance),
|
|
"restart_required": (
|
|
_restart_required(instance, profile) if instance else False
|
|
),
|
|
"user": user,
|
|
}
|
|
return respond(request, "workspace.html", context, model=WorkspaceOut)
|
|
|
|
|
|
@router.post("/{slug}/workspace")
|
|
async def workspace_open(request: Request, slug: str):
|
|
user = require_user(request)
|
|
if isinstance(user, Response):
|
|
return user
|
|
project = _project_or_404(slug)
|
|
_guard(request, project, user, "container.workspace.quota.block")
|
|
try:
|
|
instance = await provision.ensure(project, user)
|
|
instance = provision.resume(instance)
|
|
except WorkspaceError as error:
|
|
audit.record(
|
|
request,
|
|
"container.workspace.quota.block",
|
|
user=user,
|
|
target_type="project",
|
|
target_uid=project["uid"],
|
|
summary=str(error),
|
|
result="denied",
|
|
)
|
|
return json_error(400, str(error))
|
|
except ContainerError as error:
|
|
return fail(error)
|
|
audit_instance(
|
|
request,
|
|
user,
|
|
"container.workspace.create",
|
|
instance,
|
|
project,
|
|
summary=f"{user['username']} opened workspace for {project.get('title')}",
|
|
)
|
|
provision.write_manifest(instance)
|
|
return action_result(
|
|
request, f"/projects/{slug}/workspace", data=provision.view(instance)
|
|
)
|
|
|
|
|
|
@router.post("/{slug}/workspace/stop")
|
|
async def workspace_stop(request: Request, slug: str):
|
|
user = require_user(request)
|
|
if isinstance(user, Response):
|
|
return user
|
|
project = _project_or_404(slug)
|
|
instance = _workspace_or_404(project, user)
|
|
if not can_manage_workspace(instance, project, user):
|
|
return json_error(403, "Not allowed to manage this workspace")
|
|
provision.stop(instance)
|
|
audit_instance(request, user, "container.workspace.stop", instance, project)
|
|
return action_result(request, f"/projects/{slug}/workspace")
|
|
|
|
|
|
@router.post("/{slug}/workspace/delete")
|
|
async def workspace_delete(request: Request, slug: str):
|
|
user = require_user(request)
|
|
if isinstance(user, Response):
|
|
return user
|
|
project = _project_or_404(slug)
|
|
instance = _workspace_or_404(project, user)
|
|
if not can_manage_workspace(instance, project, user):
|
|
return json_error(403, "Not allowed to manage this workspace")
|
|
for row in tunnels.list_for_instance(instance["uid"]):
|
|
tunnels.soft_delete(row["uid"], user["uid"])
|
|
store.delete_instance(instance["uid"], user["uid"])
|
|
audit_instance(request, user, "container.workspace.delete", instance, project)
|
|
return action_result(request, f"/projects/{slug}/workspace")
|
|
|
|
|
|
@router.get("/{slug}/workspace/editor")
|
|
async def editor_prefs_read(request: Request, slug: str):
|
|
user = require_user(request)
|
|
if isinstance(user, Response):
|
|
return user
|
|
project = _project_or_404(slug)
|
|
instance = _workspace_or_404(project, user)
|
|
if not can_manage_workspace(instance, project, user):
|
|
return json_error(403, "Not allowed to manage this workspace")
|
|
profile = editor.resolve(user["uid"], instance)
|
|
return {
|
|
"editor": editor.view(user["uid"], instance),
|
|
"restart_required": _restart_required(instance, profile),
|
|
}
|
|
|
|
|
|
@router.post("/{slug}/workspace/editor")
|
|
async def editor_prefs_write(
|
|
request: Request,
|
|
slug: str,
|
|
data: Annotated[EditorPrefsForm, Depends(json_or_form(EditorPrefsForm))],
|
|
):
|
|
user = require_user(request)
|
|
if isinstance(user, Response):
|
|
return user
|
|
project = _project_or_404(slug)
|
|
instance = _workspace_or_404(project, user)
|
|
if not can_manage_workspace(instance, project, user):
|
|
return json_error(403, "Not allowed to manage this workspace")
|
|
owner_uid = instance.get("workspace_owner_uid") or user["uid"]
|
|
if data.reset:
|
|
editor.reset_prefs(owner_uid, user["uid"])
|
|
summary = f"{user['username']} reset their workspace editor preferences"
|
|
else:
|
|
editor.save_prefs(
|
|
owner_uid, data.model_dump(exclude={"reset"}, exclude_unset=True)
|
|
)
|
|
summary = f"{user['username']} updated their workspace editor preferences"
|
|
audit_instance(
|
|
request,
|
|
user,
|
|
"container.workspace.editor.update",
|
|
instance,
|
|
project,
|
|
summary=summary,
|
|
)
|
|
profile = editor.resolve(owner_uid, instance)
|
|
return action_result(
|
|
request,
|
|
f"/projects/{slug}/workspace",
|
|
data={
|
|
"editor": editor.view(owner_uid, instance),
|
|
"restart_required": _restart_required(instance, profile),
|
|
},
|
|
)
|
|
|
|
|
|
@router.get("/{slug}/workspace/tunnels")
|
|
async def tunnel_list(request: Request, slug: str):
|
|
user = require_user(request)
|
|
if isinstance(user, Response):
|
|
return user
|
|
project = _project_or_404(slug)
|
|
instance = _workspace_or_404(project, user)
|
|
if not can_manage_workspace(instance, project, user):
|
|
return json_error(403, "Not allowed to manage this workspace")
|
|
return {"tunnels": tunnels.list_for_instance(instance["uid"])}
|
|
|
|
|
|
@router.post("/{slug}/workspace/tunnels")
|
|
async def tunnel_create(
|
|
request: Request, slug: str, data: Annotated[TunnelForm, Form()]
|
|
):
|
|
user = require_user(request)
|
|
if isinstance(user, Response):
|
|
return user
|
|
project = _project_or_404(slug)
|
|
instance = _workspace_or_404(project, user)
|
|
if not can_manage_workspace(instance, project, user):
|
|
return json_error(403, "Not allowed to manage this workspace")
|
|
try:
|
|
row = provision.publish_tunnel(
|
|
instance, data.label, data.container_port, user["uid"]
|
|
)
|
|
except provision.WorkspaceError as error:
|
|
return json_error(400, str(error))
|
|
audit_instance(
|
|
request,
|
|
user,
|
|
"container.tunnel.create",
|
|
instance,
|
|
project,
|
|
metadata={"hostname": row["hostname"], "port": data.container_port},
|
|
)
|
|
return action_result(request, f"/projects/{slug}/workspace", data=row)
|
|
|
|
|
|
@router.delete("/{slug}/workspace/tunnels/{uid}")
|
|
@router.post("/{slug}/workspace/tunnels/{uid}/delete")
|
|
async def tunnel_delete(request: Request, slug: str, uid: str):
|
|
user = require_user(request)
|
|
if isinstance(user, Response):
|
|
return user
|
|
project = _project_or_404(slug)
|
|
instance = _workspace_or_404(project, user)
|
|
if not can_manage_workspace(instance, project, user):
|
|
return json_error(403, "Not allowed to manage this workspace")
|
|
row = tunnels.get(uid)
|
|
if not row or row.get("instance_uid") != instance["uid"]:
|
|
raise not_found("Tunnel not found")
|
|
tunnels.soft_delete(uid, user["uid"])
|
|
audit_instance(
|
|
request,
|
|
user,
|
|
"container.tunnel.delete",
|
|
instance,
|
|
project,
|
|
metadata={"hostname": row.get("hostname", "")},
|
|
)
|
|
provision.write_manifest(instance)
|
|
return action_result(request, f"/projects/{slug}/workspace")
|
|
|
|
|
|
def _editor_guard(request: Request, slug: str, uid: str):
|
|
user = require_user(request)
|
|
if isinstance(user, Response):
|
|
return None, None, user
|
|
project = _project_or_404(slug)
|
|
instance = store.get_instance(uid)
|
|
if not instance or not instance.get("is_workspace"):
|
|
raise not_found("Workspace not found")
|
|
if not can_manage_workspace(instance, project, user):
|
|
return None, None, json_error(403, "Not allowed to open this workspace")
|
|
return project, instance, None
|
|
|
|
|
|
@router.api_route(
|
|
"/{slug}/containers/instances/{uid}/code", methods=forward.METHODS
|
|
)
|
|
@router.api_route(
|
|
"/{slug}/containers/instances/{uid}/code/{path:path}", methods=forward.METHODS
|
|
)
|
|
async def editor_proxy(request: Request, slug: str, uid: str, path: str = ""):
|
|
project, instance, denial = _editor_guard(request, slug, uid)
|
|
if denial is not None:
|
|
return denial
|
|
if instance.get("suspended_at"):
|
|
return Response("this workspace is suspended", status_code=403)
|
|
if instance.get("status") != store.ST_RUNNING:
|
|
return Response("this workspace is not running", status_code=409)
|
|
host, port = provision.editor_target(instance)
|
|
if not host or not port:
|
|
return Response("the editor has no reachable port", status_code=502)
|
|
activity.touch(instance["uid"])
|
|
prefix = f"/projects/{slug}/containers/instances/{uid}/code"
|
|
return await forward.proxy_http(request, host, port, path, prefix=prefix)
|
|
|
|
|
|
@router.websocket("/{slug}/containers/instances/{uid}/code")
|
|
@router.websocket("/{slug}/containers/instances/{uid}/code/{path:path}")
|
|
async def editor_proxy_ws(
|
|
websocket: WebSocket, slug: str, uid: str, path: str = ""
|
|
):
|
|
from devplacepy.utils import get_current_user
|
|
|
|
user = get_current_user(websocket)
|
|
project = resolve_by_slug(get_table("projects"), slug)
|
|
instance = store.get_instance(uid)
|
|
if not user or not project or not instance or not instance.get("is_workspace"):
|
|
await websocket.close(code=1008)
|
|
return
|
|
if not can_manage_workspace(instance, project, user):
|
|
await websocket.close(code=1008)
|
|
return
|
|
if instance.get("suspended_at") or instance.get("status") != store.ST_RUNNING:
|
|
await websocket.close(code=1011)
|
|
return
|
|
host, port = provision.editor_target(instance)
|
|
if not host or not port:
|
|
await websocket.close(code=1011)
|
|
return
|
|
activity.touch(instance["uid"])
|
|
prefix = f"/projects/{slug}/containers/instances/{uid}/code"
|
|
await forward.proxy_ws(websocket, host, port, path, prefix=prefix)
|