diff --git a/README.md b/README.md index 4dfbf2f..cba6e04 100644 --- a/README.md +++ b/README.md @@ -63,6 +63,7 @@ devplacepy/ | `/` | Home page: marketing splash for guests, personalized home (welcome, feed shortcut, latest posts, news) for signed-in users. Does not redirect. Latest-posts section interleaves authors so no two consecutive posts share an author. | | `/auth` | Signup, login, logout, forgot/reset password | | `/feed` | Post feed with topic/tab filtering and free-text `search` (title, content, and author username) in the left panel (public). Each page interleaves authors so no two consecutive posts share an author. | +| `/topics` | Crawlable per-topic category pages (public): `/topics` hub links every topic with a live post count, `/topics/{topic}` lists that topic's posts with its own canonical URL, title, and breadcrumbs | | `/news` | Developer news listing, detail page with comments | | `/posts` | Post detail, creation | | `/gists` | Code gist listing, detail, creation, and editing; left panel offers language filtering and free-text `search` (title, description, and author username), public read | @@ -974,7 +975,7 @@ subscription is kept. APNs environment is stored per registration so a sandbox d token and a production token can coexist. A notification is also **marked read automatically when you open the page that shows its -content** - viewing a post clears its comment, reply, upvote and mention notifications; +content** - viewing a post clears its comment, reply, thread, upvote and mention notifications; opening a conversation clears its direct-message notifications; visiting a profile clears the matching follow, badge and level notifications; and the issue, reminder and farm-raid notifications clear on their respective pages. You no longer have to dismiss each one by diff --git a/devplacepy/attachments.py b/devplacepy/attachments.py index c1a0590..fe262da 100644 --- a/devplacepy/attachments.py +++ b/devplacepy/attachments.py @@ -10,7 +10,7 @@ from urllib.parse import urlparse from PIL import Image from io import BytesIO import httpx -from devplacepy import stealth +from devplacepy.net_guard import BlockedAddressError, guarded_async_client from devplacepy.database import get_table, db, get_setting from devplacepy.config import UPLOADS_DIR, ATTACHMENTS_DIR from devplacepy.utils import generate_uid @@ -389,7 +389,7 @@ async def fetch_remote_file(url, filename=None): await _guard_public_url(url) max_bytes = _get_max_upload_bytes() try: - async with stealth.stealth_async_client( + async with guarded_async_client( follow_redirects=True, timeout=REMOTE_FETCH_TIMEOUT, headers={"User-Agent": REMOTE_FETCH_USER_AGENT}, @@ -412,6 +412,8 @@ async def fetch_remote_file(url, filename=None): 413, ) data = b"".join(chunks) + except BlockedAddressError as exc: + raise RemoteFetchError(str(exc), 400) from exc except httpx.HTTPError as exc: raise RemoteFetchError(f"Could not fetch {url}: {exc}", 400) from exc @@ -453,6 +455,33 @@ def link_attachments(uids, target_type, target_uid): ) +def split_attachment_uids(raw): + return [ + uid.strip() for item in raw or [] for uid in str(item).split(",") if uid.strip() + ] + + +def get_orphan_attachments_batch(uids, user, admin=False): + if not uids: + return [] + placeholders = ",".join(f":p{i}" for i in range(len(uids))) + params = {f"p{i}": uid for i, uid in enumerate(uids)} + rows = db.query( + f"SELECT * FROM attachments WHERE uid IN ({placeholders}) AND deleted_at IS NULL", + **params, + ) + by_uid = {row["uid"]: row for row in rows} + owned = [] + for uid in uids: + row = by_uid.get(uid) + if not row or row.get("target_uid"): + continue + if row.get("user_uid") and row["user_uid"] != user["uid"] and not admin: + continue + owned.append(uid) + return owned + + def set_gitea_asset_id(uid, asset_id): get_table("attachments").update( {"uid": uid, "gitea_asset_id": int(asset_id)}, ["uid"] diff --git a/devplacepy/database/__init__.py b/devplacepy/database/__init__.py index 3b78cdb..95a5299 100644 --- a/devplacepy/database/__init__.py +++ b/devplacepy/database/__init__.py @@ -74,7 +74,7 @@ from .moderation import ( years_between, ) from .comments import _drop_blocked, _build_comment_items, load_comments, get_recent_comments_by_target_uids, get_recent_comments_by_post_uids, load_comments_by_target_uids -from .content import resolve_by_slug, resolve_object_url, get_uids_by_username_match, text_search_clause, get_daily_topic, get_featured_news, get_trending_topics +from .content import resolve_by_slug, resolve_object_url, get_projects_by_uids, get_uids_by_username_match, text_search_clause, get_daily_topic, get_featured_news, get_trending_topics from .attachments_data import get_attachments, get_attachments_by_type, get_news_images_by_uids, delete_attachment_record, delete_attachments, _delete_attachment_file, get_user_media, get_user_attachments, get_user_attachment, get_deleted_media from .stats import _stats_cache, get_site_stats, _analytics_cache, get_platform_analytics, _gist_languages_cache, get_gist_languages from .schema import BUG_TABLE_RENAMES, migrate_bug_tables_to_issue_tables, init_db, _refresh_query_planner_stats, OLD_GATEWAY_URL, migrate_ai_gateway_settings, backfill_api_keys, _backfill_gamification @@ -296,6 +296,7 @@ __all__ = [ "load_comments_by_target_uids", "resolve_by_slug", "resolve_object_url", + "get_projects_by_uids", "get_uids_by_username_match", "text_search_clause", "get_daily_topic", diff --git a/devplacepy/database/content.py b/devplacepy/database/content.py index 63b7160..93698cd 100644 --- a/devplacepy/database/content.py +++ b/devplacepy/database/content.py @@ -102,6 +102,17 @@ def resolve_object_url(target_type: str, target_uid: str) -> str: return "/feed" +def get_projects_by_uids(uids): + if not uids or "projects" not in db.tables: + return {} + projects = get_table("projects") + if "uid" not in projects.columns: + return {} + seen = set() + unique = [u for u in uids if u not in seen and not seen.add(u)] + return {p["uid"]: p for p in projects.find(projects.table.columns.uid.in_(unique))} + + def get_uids_by_username_match(search, limit=200): term = (search or "").strip() if not term or "users" not in db.tables: diff --git a/devplacepy/database/ranking.py b/devplacepy/database/ranking.py index dc22acb..df46eeb 100644 --- a/devplacepy/database/ranking.py +++ b/devplacepy/database/ranking.py @@ -4,7 +4,7 @@ import os from .core import TTLCache, _in_clause, _now_iso, db, get_table from .users import get_users_by_uids -from .soft_delete import soft_delete, soft_delete_in +from .soft_delete import soft_delete_in VOTABLE_TARGETS: dict[str, str] = { @@ -133,6 +133,26 @@ def update_target_stars(target_type: str, target_uid: str, net_stars: int) -> No get_table(table_name).update({"uid": target_uid, "stars": net_stars}, ["uid"]) +def _child_uids(table_name, parent_column, parent_uids, live_only=False): + if table_name not in db.tables: + return [] + table = db[table_name] + if parent_column not in table.columns: + return [] + clause = table.table.columns[parent_column].in_(parent_uids) + if live_only: + return [row["uid"] for row in table.find(clause, deleted_at=None)] + return [row["uid"] for row in table.find(clause)] + + +def _delete_in(table_name, column, uids): + placeholders, params = _in_clause(uids) + with db: + db.query( + f"DELETE FROM {table_name} WHERE {column} IN ({placeholders})", **params + ) + + def soft_delete_engagement(target_type: str, target_uids: list, deleted_by: str) -> None: uids = [uid for uid in (target_uids or []) if uid] if not uids: @@ -145,21 +165,15 @@ def soft_delete_engagement(target_type: str, target_uids: list, deleted_by: str) "bookmarks", "target_uid", uids, deleted_by, stamp=stamp, target_type=target_type ) if target_type == "post" and "polls" in db.tables: - for uid in uids: - for poll in db["polls"].find(post_uid=uid, deleted_at=None): - soft_delete("poll_votes", deleted_by, stamp=stamp, poll_uid=poll["uid"]) - soft_delete("poll_options", deleted_by, stamp=stamp, poll_uid=poll["uid"]) - soft_delete("polls", deleted_by, stamp=stamp, post_uid=uid) + poll_uids = _child_uids("polls", "post_uid", uids, live_only=True) + soft_delete_in("poll_votes", "poll_uid", poll_uids, deleted_by, stamp=stamp) + soft_delete_in("poll_options", "poll_uid", poll_uids, deleted_by, stamp=stamp) + soft_delete_in("polls", "post_uid", uids, deleted_by, stamp=stamp) if target_type == "post" and "opinion_wars" in db.tables: - for uid in uids: - for war in db["opinion_wars"].find(post_uid=uid, deleted_at=None): - soft_delete( - "opinion_war_fighters", deleted_by, stamp=stamp, war_uid=war["uid"] - ) - soft_delete( - "opinion_war_events", deleted_by, stamp=stamp, war_uid=war["uid"] - ) - soft_delete("opinion_wars", deleted_by, stamp=stamp, post_uid=uid) + war_uids = _child_uids("opinion_wars", "post_uid", uids, live_only=True) + soft_delete_in("opinion_war_fighters", "war_uid", war_uids, deleted_by, stamp=stamp) + soft_delete_in("opinion_war_events", "war_uid", war_uids, deleted_by, stamp=stamp) + soft_delete_in("opinion_wars", "post_uid", uids, deleted_by, stamp=stamp) def delete_engagement(target_type: str, target_uids: list) -> None: @@ -184,21 +198,21 @@ def delete_engagement(target_type: str, target_uids: list) -> None: **params, ) if target_type == "post" and "polls" in tables: - for uid in uids: - for poll in db["polls"].find(post_uid=uid): - if "poll_votes" in tables: - db["poll_votes"].delete(poll_uid=poll["uid"]) - if "poll_options" in tables: - db["poll_options"].delete(poll_uid=poll["uid"]) - db["polls"].delete(post_uid=uid) + poll_uids = _child_uids("polls", "post_uid", uids) + if poll_uids: + if "poll_votes" in tables: + _delete_in("poll_votes", "poll_uid", poll_uids) + if "poll_options" in tables: + _delete_in("poll_options", "poll_uid", poll_uids) + _delete_in("polls", "post_uid", uids) if target_type == "post" and "opinion_wars" in tables: - for uid in uids: - for war in db["opinion_wars"].find(post_uid=uid): - if "opinion_war_fighters" in tables: - db["opinion_war_fighters"].delete(war_uid=war["uid"]) - if "opinion_war_events" in tables: - db["opinion_war_events"].delete(war_uid=war["uid"]) - db["opinion_wars"].delete(post_uid=uid) + war_uids = _child_uids("opinion_wars", "post_uid", uids) + if war_uids: + if "opinion_war_fighters" in tables: + _delete_in("opinion_war_fighters", "war_uid", war_uids) + if "opinion_war_events" in tables: + _delete_in("opinion_war_events", "war_uid", war_uids) + _delete_in("opinion_wars", "post_uid", uids) def get_target_owner_uid(target_type: str, target_uid: str) -> str | None: diff --git a/devplacepy/database/users.py b/devplacepy/database/users.py index 140735e..a5fdc10 100644 --- a/devplacepy/database/users.py +++ b/devplacepy/database/users.py @@ -15,8 +15,6 @@ def get_users_by_uids(uids): _admins_cache = TTLCache(ttl=300, max_size=4) -# The primary administrator must be an account that can actually authenticate, so scan a -# few of the earliest admins and skip any that are soft-deleted or deactivated. PRIMARY_ADMIN_CANDIDATES = 50 diff --git a/devplacepy/dependencies.py b/devplacepy/dependencies.py index 94dc893..c3e1e95 100644 --- a/devplacepy/dependencies.py +++ b/devplacepy/dependencies.py @@ -1,9 +1,4 @@ # retoor -""" -Generic FastAPI dependency that accepts JSON or form-encoded data, -validated against a Pydantic model. -""" - import json import logging from typing import Any, TypeVar, get_origin @@ -17,21 +12,10 @@ logger = logging.getLogger(__name__) _TModel = TypeVar("_TModel", bound=BaseModel) -# Container origins recognised as sequence fields that may receive -# multiple values from form data. _SEQUENCE_ORIGINS = frozenset({list, set, tuple, frozenset}) def _formdata_to_dict(form: FormData, model: type[BaseModel]) -> dict[str, Any]: - """Convert FormData to a dict suitable for Pydantic validation. - - * Sequence-typed model fields collect every submitted value via - ``getlist()``; a lone empty string is dropped (browsers emit empty - hidden inputs by default). - * Scalar fields use ``get()`` (the last value). - * Fields absent from the form are omitted so that Pydantic applies - the model default. - """ body: dict[str, Any] = {} for field_name, field_info in model.model_fields.items(): origin = get_origin(field_info.annotation) @@ -50,8 +34,6 @@ def _formdata_to_dict(form: FormData, model: type[BaseModel]) -> dict[str, Any]: class _JsonOrForm: - """Internal callable that parses JSON or form data and validates.""" - def __init__(self, model: type[BaseModel]): self.model = model @@ -70,7 +52,6 @@ class _JsonOrForm: status_code=400, detail="JSON body must be an object" ) return self.model.model_validate(body) - # Default: form-encoded (multipart or url-encoded) try: form = await request.form() except Exception as exc: @@ -85,11 +66,4 @@ class _JsonOrForm: def json_or_form(model: type[_TModel]) -> _JsonOrForm: - """Dependency factory: accept JSON or form-encoded data for a Pydantic model. - - Usage: - @router.post("/create") - async def create(data: Annotated[PostForm, Depends(json_or_form(PostForm))]): - ... - """ return _JsonOrForm(model) diff --git a/devplacepy/push/providers/apns.py b/devplacepy/push/providers/apns.py index 6124301..904a913 100644 --- a/devplacepy/push/providers/apns.py +++ b/devplacepy/push/providers/apns.py @@ -51,16 +51,8 @@ PUSH_TYPE = "alert" PRIORITY = "10" SHARED_TOKEN_KEY = "push_apns_shared_token" -# Per Apple's documented reason table, only these mean the TOKEN itself is -# permanently dead (410 Unregistered/ExpiredToken, 400 BadDeviceToken). -# DeviceTokenNotForTopic and TopicDisallowed are topic/provisioning -# misconfigurations at 400 - they affect every token uniformly and must -# never be treated as a reason to delete a registration. DEAD_REASONS = frozenset({"BadDeviceToken", "ExpiredToken", "Unregistered"}) -# 403 reasons meaning the provider (JWT) token/credential itself was -# rejected, not any device token. The cached token must be dropped so the -# next attempt re-signs instead of retrying the same rejected token. AUTH_REASONS = frozenset( { "BadCertificate", diff --git a/devplacepy/routers/admin/backups.py b/devplacepy/routers/admin/backups.py index 9053ee9..d9d2edb 100644 --- a/devplacepy/routers/admin/backups.py +++ b/devplacepy/routers/admin/backups.py @@ -89,6 +89,7 @@ async def admin_backups(request: Request): {"name": "Backups", "url": "/admin/backups"}, ], schemas=[website_schema(base)], + robots="noindex,nofollow", ) return respond( request, diff --git a/devplacepy/routers/admin/devii_tasks.py b/devplacepy/routers/admin/devii_tasks.py index 7a3f1c8..1dc8467 100644 --- a/devplacepy/routers/admin/devii_tasks.py +++ b/devplacepy/routers/admin/devii_tasks.py @@ -139,6 +139,7 @@ async def admin_devii_tasks(request: Request, state: str = "active"): {"name": "Devii tasks", "url": "/admin/devii-tasks"}, ], schemas=[website_schema(base)], + robots="noindex,nofollow", ) return respond( request, diff --git a/devplacepy/routers/admin/services.py b/devplacepy/routers/admin/services.py index 843fc9b..253216d 100644 --- a/devplacepy/routers/admin/services.py +++ b/devplacepy/routers/admin/services.py @@ -62,7 +62,7 @@ async def service_detail(request: Request, name: str): request, title=f"{info['title']} - Services", description=info["description"] or f"Configure the {info['title']} service.", - robots="noindex", + robots="noindex,nofollow", breadcrumbs=[ {"name": "Home", "url": "/feed"}, {"name": "Admin", "url": "/admin"}, diff --git a/devplacepy/routers/admin/trash.py b/devplacepy/routers/admin/trash.py index dd20db8..27359bb 100644 --- a/devplacepy/routers/admin/trash.py +++ b/devplacepy/routers/admin/trash.py @@ -89,6 +89,7 @@ async def admin_trash(request: Request, table: str = "posts", page: int = 1): {"name": "Trash", "url": "/admin/trash"}, ], schemas=[website_schema(base)], + robots="noindex,nofollow", ) return respond( request, diff --git a/devplacepy/routers/admin/workspaces.py b/devplacepy/routers/admin/workspaces.py index ef43d09..39585cd 100644 --- a/devplacepy/routers/admin/workspaces.py +++ b/devplacepy/routers/admin/workspaces.py @@ -5,7 +5,7 @@ from typing import Annotated from fastapi import APIRouter, Depends, Request from fastapi.responses import JSONResponse -from devplacepy.database import db, get_table, get_users_by_uids +from devplacepy.database import get_projects_by_uids, get_table, get_users_by_uids from devplacepy.dependencies import json_or_form from devplacepy.models import ( EditorPrefsForm, @@ -34,13 +34,8 @@ router = APIRouter() def _decorate(rows: list[dict]) -> list[dict]: owner_uids = {row.get("workspace_owner_uid") for row in rows if row.get("workspace_owner_uid")} owners = get_users_by_uids(list(owner_uids)) if owner_uids else {} - projects = {} - if "projects" in db.tables: - project_uids = {row.get("project_uid") for row in rows if row.get("project_uid")} - for uid in project_uids: - found = get_table("projects").find_one(uid=uid) - if found: - projects[uid] = found + project_uids = {row.get("project_uid") for row in rows if row.get("project_uid")} + projects = get_projects_by_uids(list(project_uids)) if project_uids else {} decorated = [] for row in rows: view = provision.view(row) diff --git a/devplacepy/routers/auth/token.py b/devplacepy/routers/auth/token.py index 784c47e..5b44844 100644 --- a/devplacepy/routers/auth/token.py +++ b/devplacepy/routers/auth/token.py @@ -22,12 +22,6 @@ async def token( request: Request, data: Annotated[LoginForm, Depends(json_or_form(LoginForm))], ): - """Issue a DevPlace access token. - - Accepts ``email`` + ``password`` (JSON or form-encoded). Returns a JSON - object with ``access_token``, ``token_type``, and ``expires_in`` on success, - or a ``401`` error on bad credentials. - """ identifier = data.email.strip().lower() password = data.password diff --git a/devplacepy/routers/battles.py b/devplacepy/routers/battles.py index f2ba5fb..cb1231e 100644 --- a/devplacepy/routers/battles.py +++ b/devplacepy/routers/battles.py @@ -11,7 +11,7 @@ from devplacepy.dependencies import json_or_form from devplacepy.models import WarJoinForm from devplacepy.responses import action_result, json_error, respond, wants_json from devplacepy.schemas import BattlesOut, WarEventsOut, WarOut -from devplacepy.seo import base_seo_context +from devplacepy.seo import base_seo_context, site_url, website_schema from devplacepy.services.opinionwar import WarError, rules, store from devplacepy.utils import get_current_user, not_found, require_user @@ -43,6 +43,7 @@ async def battles_page( search=search, page=max(1, page), ) + base = site_url(request) seo_ctx = base_seo_context( request, title="Opinion Wars", @@ -54,6 +55,7 @@ async def battles_page( {"name": "Home", "url": "/feed"}, {"name": "Battles", "url": "/battles"}, ], + schemas=[website_schema(base)], ) return respond( request, diff --git a/devplacepy/routers/devii.py b/devplacepy/routers/devii.py index 1033427..146ca22 100644 --- a/devplacepy/routers/devii.py +++ b/devplacepy/routers/devii.py @@ -137,8 +137,6 @@ def _validate_target(value: str) -> str: @router.get("/adopt") async def devii_adopt(request: Request): - # The terminal's agent logged in; adopt the real session it minted into the browser so both - # share one session, then return to where the user was. target = _validate_target(request.query_params.get("next", "/")) response = RedirectResponse(target, status_code=303) svc = _service() diff --git a/devplacepy/routers/docs/CLAUDE.md b/devplacepy/routers/docs/CLAUDE.md index ad0f909..e481acd 100644 --- a/devplacepy/routers/docs/CLAUDE.md +++ b/devplacepy/routers/docs/CLAUDE.md @@ -66,7 +66,7 @@ The `endpoint()` factory derives `min_role` from `auth` (`public` -> Public, `us ## Admin-only pages -A group with `"admin": True` (currently `services`, `admin`) is hidden from the sidebar (`docs.py` filters `visible_pages`) and returns 404 for non-admins (`page.get("admin") and not is_admin -> not_found`, same message as unknown slugs). `index.html` wraps the operator links in `{% if user and user.get('role') == 'Admin' %}`. +A group with `"admin": True` (currently `services`, `admin`, `containers`) is hidden from the sidebar (`docs.py` filters `visible_pages`) and returns 404 for non-admins (`page.get("admin") and not is_admin -> not_found`, same message as unknown slugs). `index.html` wraps the operator links in `{% if user and user.get('role') == 'Admin' %}`. ## Dynamic Background Services page diff --git a/devplacepy/routers/docs/pages.py b/devplacepy/routers/docs/pages.py index 5e2a291..71e4a7b 100644 --- a/devplacepy/routers/docs/pages.py +++ b/devplacepy/routers/docs/pages.py @@ -44,7 +44,6 @@ AUDIENCES = [ ] DOCS_PAGES = [ - # General - how to use the site and Devii (everyone) {"slug": "index", "title": "Overview", "kind": "prose", "section": SECTION_GENERAL}, { "slug": "getting-started", @@ -160,7 +159,6 @@ DOCS_PAGES = [ "kind": "prose", "section": SECTION_GENERAL, }, - # Legal - the policies the platform is operated under (everyone) { "slug": "terms", "title": "Terms of Service", @@ -204,7 +202,6 @@ DOCS_PAGES = [ "section": SECTION_LEGAL, "admin": True, }, - # Tools - public developer tools (everyone) { "slug": "tools-seo", "title": "SEO Diagnostics", @@ -229,7 +226,6 @@ DOCS_PAGES = [ "kind": "prose", "section": SECTION_TOOLS, }, - # Claude Code - the native subagent, command, and workflow setup under .claude/ { "slug": "claude", "title": "Claude Code setup", @@ -260,7 +256,6 @@ DOCS_PAGES = [ "kind": "prose", "section": SECTION_CLAUDE, }, - # Components - custom HTML web components with live examples (everyone) { "slug": "components", "title": "Components overview", @@ -339,7 +334,6 @@ DOCS_PAGES = [ "kind": "prose", "section": SECTION_COMPONENTS, }, - # Styles - the design system: colors, layout, responsiveness, and hard structural rules (everyone) { "slug": "styles", "title": "Design system overview", @@ -370,7 +364,6 @@ DOCS_PAGES = [ "kind": "prose", "section": SECTION_STYLES, }, - # API - developer reference (everyone); admin-only groups are routed to Administration below { "slug": "authentication", "title": "Authentication", @@ -383,7 +376,6 @@ DOCS_PAGES = [ "kind": "prose", "section": SECTION_API, }, - # devRant API - legacy-compatible protocol, spread over focused pages { "slug": "devrant", "title": "Overview", @@ -430,7 +422,6 @@ DOCS_PAGES = [ {**page, "section": (SECTION_ADMIN if page.get("admin") else SECTION_API)} for page in api_doc_pages() ], - # Administration - operational guides (admins only) { "slug": "devii-admin", "title": "Devii for admins", @@ -473,7 +464,6 @@ DOCS_PAGES = [ "admin": True, "section": SECTION_ADMIN, }, - # Devii internals - technical reference for the Devii assistant (admins only) { "slug": "audit-log", "title": "Audit Log", @@ -523,7 +513,6 @@ DOCS_PAGES = [ "admin": True, "section": SECTION_DEVII, }, - # Bots internals - deep technical reference for the autonomous bot fleet (admins only) { "slug": "bots-internals", "title": "Bots internals", @@ -573,7 +562,6 @@ DOCS_PAGES = [ "admin": True, "section": SECTION_BOTS, }, - # Services - the background service framework and every service in detail (admins only) { "slug": "services-overview", "title": "Services overview", @@ -651,7 +639,6 @@ DOCS_PAGES = [ "admin": True, "section": SECTION_SERVICES, }, - # Architecture - platform design, structure, and development process (admins only) { "slug": "architecture", "title": "Architecture overview", @@ -701,7 +688,6 @@ DOCS_PAGES = [ "admin": True, "section": SECTION_ARCH, }, - # Testing - test framework, load testing, and make targets (admins only) { "slug": "testing", "title": "Testing overview", @@ -737,7 +723,6 @@ DOCS_PAGES = [ "admin": True, "section": SECTION_TESTING, }, - # Production - deployment and operations reference (admins only) { "slug": "production", "title": "Production overview", diff --git a/devplacepy/routers/game/farm.py b/devplacepy/routers/game/farm.py index 60af4de..194fc5b 100644 --- a/devplacepy/routers/game/farm.py +++ b/devplacepy/routers/game/farm.py @@ -9,6 +9,7 @@ from devplacepy.models import GameSlotForm from devplacepy.responses import respond, wants_json from devplacepy.schemas import GameFarmViewOut from devplacepy.services.game import GameError, store +from devplacepy.services.audit import record as audit from devplacepy.utils import ( create_notification, get_current_user, @@ -58,10 +59,24 @@ async def water_farm( if not owner: raise HTTPException(status_code=404, detail="Farm not found") try: - store.water(viewer, owner, data.slot) + result = store.water(viewer, owner, data.slot) except GameError as exc: return action_error(request, str(exc), f"/game/farm/{username}") track_action(viewer["uid"], "water") + audit.record( + request, + "game.water", + user=viewer, + target_type="user", + target_uid=owner["uid"], + target_label=owner["username"], + metadata=result, + summary=( + f"{viewer['username']} watered {owner['username']}'s build on plot " + f"{result['slot']} and earned {result['reward_coins']} coins" + ), + links=[audit.target("user", owner["uid"], owner["username"])], + ) await notify_farm(owner["username"]) if wants_json(request): payload = store.serialize_farm( @@ -87,6 +102,31 @@ async def steal_farm( track_action(owner["uid"], "got_stolen_from") if result.get("underdog_triggered"): track_action(viewer["uid"], "underdog_raid") + audit.record( + request, + "game.steal", + user=viewer, + target_type="user", + target_uid=owner["uid"], + target_label=owner["username"], + metadata={ + "thief_uid": viewer["uid"], + "thief_username": viewer["username"], + "owner_uid": owner["uid"], + "owner_username": owner["username"], + "slot": result["slot"], + "crop": result["crop"], + "coins": result["coins"], + "share": result["share"], + "underdog_triggered": result.get("underdog_triggered", False), + }, + summary=( + f"{viewer['username']} raided {owner['username']}'s Code Farm and took " + f"{result['coins']} coins ({round(result['share'] * 100)}%) from their " + f"{result['crop_name']} build" + ), + links=[audit.target("user", owner["uid"], owner["username"])], + ) create_notification( owner["uid"], "harvest_stolen", diff --git a/devplacepy/routers/game/index.py b/devplacepy/routers/game/index.py index de4e4ce..9c37383 100644 --- a/devplacepy/routers/game/index.py +++ b/devplacepy/routers/game/index.py @@ -80,8 +80,21 @@ async def _respond_action(request: Request, user: dict, fn, on_success=None): @router.post("/plant") async def game_plant(request: Request, data: Annotated[GamePlantForm, Form()]): user = require_user(request) + + def recorded(result): + audit.record( + request, + "game.plant", + user=user, + metadata=result, + summary=( + f"{user['username']} planted {result['crop']} on plot " + f"{result['slot']} for {result['spent']} coins" + ), + ) + return await _respond_action( - request, user, lambda: store.plant(user, data.slot, data.crop) + request, user, lambda: store.plant(user, data.slot, data.crop), recorded ) @@ -92,6 +105,16 @@ async def game_harvest(request: Request, data: Annotated[GameSlotForm, Form()]): def reward(result): track_action(user["uid"], "harvest") award_rewards(user["uid"], economy.site_xp_for(result.get("xp", 0))) + audit.record( + request, + "game.harvest", + user=user, + metadata=result, + summary=( + f"{user['username']} harvested {result['crop']} on plot " + f"{result['slot']} for {result['coins']} coins" + ), + ) return await _respond_action( request, user, lambda: store.harvest(user, data.slot), reward @@ -101,25 +124,79 @@ async def game_harvest(request: Request, data: Annotated[GameSlotForm, Form()]): @router.post("/buy-plot") async def game_buy_plot(request: Request): user = require_user(request) - return await _respond_action(request, user, lambda: store.buy_plot(user)) + + def recorded(result): + audit.record( + request, + "game.plot.buy", + user=user, + metadata=result, + summary=( + f"{user['username']} bought Code Farm plot " + f"{result['plot_count']} for {result['spent']} coins" + ), + ) + + return await _respond_action(request, user, lambda: store.buy_plot(user), recorded) @router.post("/upgrade") async def game_upgrade(request: Request): user = require_user(request) - return await _respond_action(request, user, lambda: store.upgrade_ci(user)) + + def recorded(result): + audit.record( + request, + "game.ci.upgrade", + user=user, + metadata=result, + summary=( + f"{user['username']} upgraded Code Farm CI to tier " + f"{result['ci_tier']} for {result['spent']} coins" + ), + ) + + return await _respond_action(request, user, lambda: store.upgrade_ci(user), recorded) @router.post("/fertilize") async def game_fertilize(request: Request, data: Annotated[GameSlotForm, Form()]): user = require_user(request) - return await _respond_action(request, user, lambda: store.fertilize(user, data.slot)) + + def recorded(result): + audit.record( + request, + "game.fertilize", + user=user, + metadata=result, + summary=( + f"{user['username']} fertilized plot {result['slot']} " + f"for {result['spent']} coins" + ), + ) + + return await _respond_action( + request, user, lambda: store.fertilize(user, data.slot), recorded + ) @router.post("/daily") async def game_daily(request: Request): user = require_user(request) - return await _respond_action(request, user, lambda: store.claim_daily(user)) + + def recorded(result): + audit.record( + request, + "game.daily.claim", + user=user, + metadata=result, + summary=( + f"{user['username']} claimed the Code Farm daily bonus of " + f"{result['reward']} coins (streak {result['streak']})" + ), + ) + + return await _respond_action(request, user, lambda: store.claim_daily(user), recorded) @router.post("/grant") @@ -141,8 +218,21 @@ async def game_claim_grant(request: Request): @router.post("/perk") async def game_perk(request: Request, data: Annotated[GamePerkForm, Form()]): user = require_user(request) + + def recorded(result): + audit.record( + request, + "game.perk.upgrade", + user=user, + metadata=result, + summary=( + f"{user['username']} upgraded perk {result['perk']} to level " + f"{result['level']} for {result['spent']} coins" + ), + ) + return await _respond_action( - request, user, lambda: store.upgrade_perk(user, data.perk) + request, user, lambda: store.upgrade_perk(user, data.perk), recorded ) @@ -168,8 +258,21 @@ async def game_prestige(request: Request): @router.post("/legacy") async def game_legacy(request: Request, data: Annotated[GameLegacyForm, Form()]): user = require_user(request) + + def recorded(result): + audit.record( + request, + "game.legacy.upgrade", + user=user, + metadata=result, + summary=( + f"{user['username']} upgraded Legacy {result['key']} to level " + f"{result['level']} for {result['spent']} stars" + ), + ) + return await _respond_action( - request, user, lambda: store.upgrade_legacy(user, data.key) + request, user, lambda: store.upgrade_legacy(user, data.key), recorded ) @@ -179,6 +282,16 @@ async def game_claim_quest(request: Request, data: Annotated[GameQuestForm, Form def reward(result): award_rewards(user["uid"], economy.site_xp_for(result.get("reward_xp", 0))) + audit.record( + request, + "game.quest.claim", + user=user, + metadata=result, + summary=( + f"{user['username']} claimed quest {result['kind']} for " + f"{result['reward_coins']} coins" + ), + ) return await _respond_action( request, user, lambda: store.claim_quest(user, data.quest, data.scope), reward @@ -251,8 +364,21 @@ async def game_buy_infrastructure(request: Request, data: Annotated[GameInfraFor @router.post("/mastery") async def game_upgrade_mastery(request: Request, data: Annotated[GameMasteryForm, Form()]): user = require_user(request) + + def recorded(result): + audit.record( + request, + "game.mastery.upgrade", + user=user, + metadata=result, + summary=( + f"{user['username']} upgraded Mastery {result['key']} to level " + f"{result['level']} for {result['spent']} mastery points" + ), + ) + return await _respond_action( - request, user, lambda: store.upgrade_mastery(user, data.key) + request, user, lambda: store.upgrade_mastery(user, data.key), recorded ) @@ -281,6 +407,16 @@ async def game_buy_cosmetic(request: Request, data: Annotated[GameCosmeticForm, @router.post("/cosmetics/equip") async def game_equip_cosmetic(request: Request, data: Annotated[GameCosmeticForm, Form()]): user = require_user(request) + + def recorded(result): + audit.record( + request, + "game.cosmetic.equip", + user=user, + metadata=result, + summary=f"{user['username']} equipped Code Farm title {result['active_title']}", + ) + return await _respond_action( - request, user, lambda: store.equip_title(user, data.key) + request, user, lambda: store.equip_title(user, data.key), recorded ) diff --git a/devplacepy/routers/issues/attachments.py b/devplacepy/routers/issues/attachments.py index c429c07..0630683 100644 --- a/devplacepy/routers/issues/attachments.py +++ b/devplacepy/routers/issues/attachments.py @@ -8,10 +8,12 @@ from fastapi.responses import JSONResponse from devplacepy.attachments import ( get_attachments, + get_orphan_attachments_batch, link_attachments, mirror_attachment_to_gitea, remove_gitea_asset, soft_delete_attachment, + split_attachment_uids, ) from devplacepy.database import get_table from devplacepy.dependencies import json_or_form @@ -28,10 +30,6 @@ logger = logging.getLogger(__name__) router = APIRouter() -def _split_uids(raw) -> list[str]: - return [u.strip() for item in raw or [] for u in str(item).split(",") if u.strip()] - - def _can_modify(att: dict, user: dict | None, is_open: bool) -> bool: if not user or not is_open: return False @@ -47,21 +45,6 @@ def _payload(rows: list[dict], user: dict | None, is_open: bool) -> list[dict]: return items -def _claim_orphans(uids: list[str], user: dict) -> list[str]: - admin = is_admin(user) - owned = [] - for uid in uids: - row = get_table("attachments").find_one(uid=uid, deleted_at=None) - if not row: - continue - if row.get("user_uid") and row["user_uid"] != user["uid"] and not admin: - continue - if row.get("target_uid"): - continue - owned.append(uid) - return owned - - async def _load_issue(number: int) -> dict: try: return await runtime.get_client().get_issue(number) @@ -106,7 +89,9 @@ async def add_issue_attachment( summary=f"{user['username']} tried to attach to closed issue #{number}", ) return json_error(409, "Attachments cannot be changed on a closed issue") - owned = _claim_orphans(_split_uids(data.attachment_uids), user) + owned = get_orphan_attachments_batch( + split_attachment_uids(data.attachment_uids), user, admin=is_admin(user) + ) if not owned: return json_error(400, "No valid attachments to add") link_attachments(owned, "issue", str(number)) @@ -202,7 +187,9 @@ async def add_comment_attachment( issue = await _load_issue(number) if issue.get("state") != STATE_OPEN: return json_error(409, "Attachments cannot be changed on a closed issue") - owned = _claim_orphans(_split_uids(data.attachment_uids), user) + owned = get_orphan_attachments_batch( + split_attachment_uids(data.attachment_uids), user, admin=is_admin(user) + ) if not owned: return json_error(400, "No valid attachments to add") link_attachments(owned, "issue_comment", str(cid)) diff --git a/devplacepy/routers/issues/comment.py b/devplacepy/routers/issues/comment.py index 2380dc1..ab0fcb2 100644 --- a/devplacepy/routers/issues/comment.py +++ b/devplacepy/routers/issues/comment.py @@ -5,7 +5,12 @@ from typing import Annotated from fastapi import Depends, APIRouter, Request -from devplacepy.attachments import link_attachments, mirror_attachment_to_gitea +from devplacepy.attachments import ( + get_orphan_attachments_batch, + link_attachments, + mirror_attachment_to_gitea, + split_attachment_uids, +) from devplacepy.database import get_table from devplacepy.models import IssueCommentForm from devplacepy.responses import action_result, json_error @@ -21,18 +26,6 @@ logger = logging.getLogger(__name__) router = APIRouter() -def _owned_orphan_uids(raw: list[str], user: dict) -> list[str]: - flat = [u.strip() for item in raw or [] for u in str(item).split(",") if u.strip()] - owned = [] - for uid in flat: - row = get_table("attachments").find_one(uid=uid, deleted_at=None) - if not row or row.get("target_uid"): - continue - if row.get("user_uid") and row["user_uid"] != user["uid"]: - continue - owned.append(uid) - return owned - def _notify_admins(actor: dict, number: int) -> None: for admin in get_table("users").find(role="Admin"): if admin["uid"] == actor["uid"]: @@ -74,7 +67,9 @@ async def comment_issue( comment_id = int(comment.get("id", 0)) store.record_comment_author(comment_id, number, user["uid"]) - owned = _owned_orphan_uids(data.attachment_uids, user) + owned = get_orphan_attachments_batch( + split_attachment_uids(data.attachment_uids), user + ) if owned: link_attachments(owned, "issue_comment", str(comment_id)) for uid in owned: diff --git a/devplacepy/routers/issues/create.py b/devplacepy/routers/issues/create.py index 5a1844d..3fb6b01 100644 --- a/devplacepy/routers/issues/create.py +++ b/devplacepy/routers/issues/create.py @@ -6,7 +6,7 @@ from typing import Annotated from fastapi import Depends, APIRouter, Request from fastapi.responses import JSONResponse -from devplacepy.database import get_table +from devplacepy.attachments import get_orphan_attachments_batch, split_attachment_uids from devplacepy.models import IssueForm from devplacepy.responses import json_error from devplacepy.schemas import IssueJobOut @@ -20,19 +20,6 @@ logger = logging.getLogger(__name__) router = APIRouter() -def _owned_orphan_uids(raw: list[str], user: dict) -> list[str]: - flat = [u.strip() for item in raw or [] for u in str(item).split(",") if u.strip()] - owned = [] - for uid in flat: - row = get_table("attachments").find_one(uid=uid, deleted_at=None) - if not row or row.get("target_uid"): - continue - if row.get("user_uid") and row["user_uid"] != user["uid"]: - continue - owned.append(uid) - return owned - - @router.post("/create") async def create_issue(request: Request, data: Annotated[IssueForm, Depends(json_or_form(IssueForm))]): user = require_user(request) @@ -45,7 +32,9 @@ async def create_issue(request: Request, data: Annotated[IssueForm, Depends(json "author_uid": user["uid"], "title": title, "description": data.description.strip(), - "attachment_uids": _owned_orphan_uids(data.attachment_uids, user), + "attachment_uids": get_orphan_attachments_batch( + split_attachment_uids(data.attachment_uids), user + ), }, "user", user["uid"], diff --git a/devplacepy/routers/projects/containers/instances.py b/devplacepy/routers/projects/containers/instances.py index 927d72f..f925137 100644 --- a/devplacepy/routers/projects/containers/instances.py +++ b/devplacepy/routers/projects/containers/instances.py @@ -83,8 +83,6 @@ async def containers_json(request: Request, project_slug: str): } ) -# ---------------- instances ---------------- - @router.post("/{project_slug}/containers/instances") async def create_instance( request: Request, project_slug: str, data: Annotated[ContainerInstanceForm, Depends(json_or_form(ContainerInstanceForm))] diff --git a/devplacepy/seo.py b/devplacepy/seo.py index 063fd7e..854239a 100644 --- a/devplacepy/seo.py +++ b/devplacepy/seo.py @@ -477,6 +477,7 @@ def _build_sitemap(base_url): urlset.append(url_element(f"{base_url}/tools", changefreq="monthly", priority="0.5")) urlset.append(url_element(f"{base_url}/tools/seo", changefreq="monthly", priority="0.5")) urlset.append(url_element(f"{base_url}/tools/deepsearch", changefreq="monthly", priority="0.5")) + urlset.append(url_element(f"{base_url}/tools/isslop", changefreq="monthly", priority="0.5")) urlset.append( url_element(f"{base_url}/workspaces/index", changefreq="daily", priority="0.6") ) diff --git a/devplacepy/services/access_tokens.py b/devplacepy/services/access_tokens.py index 940e2fa..8fe0d01 100644 --- a/devplacepy/services/access_tokens.py +++ b/devplacepy/services/access_tokens.py @@ -16,11 +16,6 @@ def issue_token( label: str = "", max_age_days: Optional[int] = None, ) -> dict: - """Issue a DevPlace access token for *user*. - - Returns a dict with *access_token*, *token_type*, *expires_in* (seconds), - *expires_at* (ISO), and *uid*. - """ if max_age_days is None: max_age_days = max(1, get_int_setting("session_max_age_days", 7)) max_age_seconds = max_age_days * SECONDS_PER_DAY @@ -54,11 +49,6 @@ def issue_token( def resolve_token(token: str) -> Optional[dict]: - """Resolve a user from an access token string. - - Returns the user dict or ``None`` when the token is invalid, expired, or - belongs to an inactive user. - """ if not token: return None row = get_table("access_tokens").find_one(token=token, deleted_at=None) @@ -83,7 +73,6 @@ def resolve_token(token: str) -> Optional[dict]: def revoke_token(uid: str) -> bool: - """Soft-delete a single access token by its uid. Returns ``True`` on success.""" tokens = get_table("access_tokens") row = tokens.find_one(uid=uid, deleted_at=None) if not row: @@ -97,7 +86,6 @@ def revoke_token(uid: str) -> bool: def revoke_all(user_uid: str) -> int: - """Soft-delete every access token for *user_uid*. Returns the count revoked.""" tokens = get_table("access_tokens") stamp = datetime.now(timezone.utc).isoformat() count = 0 @@ -112,7 +100,6 @@ def revoke_all(user_uid: str) -> int: def prune_expired() -> int: - """Soft-delete all expired access tokens. Returns the count pruned.""" tokens = get_table("access_tokens") now = datetime.now(timezone.utc) stamp = now.isoformat() diff --git a/devplacepy/services/containers/api.py b/devplacepy/services/containers/api.py index c86c321..b59cf6b 100644 --- a/devplacepy/services/containers/api.py +++ b/devplacepy/services/containers/api.py @@ -179,9 +179,6 @@ def parse_env(value) -> dict: return env -# ---------------- instances ---------------- - - def validate_ingress(slug: str, port, port_list) -> tuple: slug = (slug or "").strip().lower() if not slug: @@ -656,9 +653,6 @@ def add_schedule(instance: dict, action: str, schedule: Schedule) -> dict: return store.create_schedule(instance, action, schedule.columns(), to_iso(first)) -# ---------------- aggregation ---------------- - - def _percentile(values: list, pct: float) -> float: if not values: return 0.0 diff --git a/devplacepy/services/containers/store.py b/devplacepy/services/containers/store.py index 66b94e6..cc1f97b 100644 --- a/devplacepy/services/containers/store.py +++ b/devplacepy/services/containers/store.py @@ -32,9 +32,6 @@ def _exists(table: str) -> bool: return table in db.tables -# ---------------- instances ---------------- - - def create_instance(row: dict) -> dict: uid = generate_uid() base = { @@ -138,9 +135,6 @@ def delete_instance(uid: str, deleted_by: str = "system") -> None: ) -# ---------------- events / metrics / schedules ---------------- - - def record_event( instance: dict, event: str, actor_kind: str, actor_id: str, detail: dict = None ) -> None: diff --git a/devplacepy/services/deepsearch/store.py b/devplacepy/services/deepsearch/store.py index 9389c51..193f48d 100644 --- a/devplacepy/services/deepsearch/store.py +++ b/devplacepy/services/deepsearch/store.py @@ -49,7 +49,6 @@ class VectorStore: self._dims: int | None = None async def _run_sync(self, func, *args, timeout: float = CHROMADB_TIMEOUT): - """Run a synchronous ChromaDB call in a thread executor with a timeout.""" try: return await asyncio.wait_for( asyncio.to_thread(func, *args), timeout=timeout diff --git a/devplacepy/services/devii/actions/catalog/admin.py b/devplacepy/services/devii/actions/catalog/admin.py index 9c1ca4f..b30568d 100644 --- a/devplacepy/services/devii/actions/catalog/admin.py +++ b/devplacepy/services/devii/actions/catalog/admin.py @@ -231,6 +231,30 @@ ADMIN_ACTIONS: tuple[Action, ...] = ( params=(path("uid", "News uid."), confirm()), requires_admin=True, ), + Action( + name="admin_get_notification_defaults", + method="GET", + path="/admin/notifications", + summary="View site-wide default notification settings for every type/channel", + requires_admin=True, + ), + Action( + name="admin_set_notification_default", + method="POST", + path="/admin/notifications", + summary="Set the site-wide default for a notification type and channel", + params=( + body("notification_type", "Notification type key, e.g. 'follow', 'mention'.", required=True), + body("channel", "Delivery channel: in_app, push, or telegram.", required=True), + body( + "value", + "true to enable this default, false to disable it.", + required=True, + type="boolean", + ), + ), + requires_admin=True, + ), Action( name="admin_list_services", method="GET", @@ -432,6 +456,47 @@ ADMIN_ACTIONS: tuple[Action, ...] = ( ), requires_admin=True, ), + Action( + name="backup_schedule_edit", + method="POST", + path="/admin/backups/schedules/{uid}/edit", + summary="Edit a recurring backup schedule (admin only)", + description=( + "Replaces every field of an existing schedule (name, target, kind, every_seconds/cron, " + "keep_last) - this is a full update, not a partial patch." + ), + params=( + path("uid", "Backup schedule uid."), + body("name", "Human-readable schedule name.", required=True), + body("target", "One of database, uploads, keys, full.", required=True), + body("kind", "interval or cron.", required=True), + body("every_seconds", "Seconds between runs when kind=interval (min 60)."), + body("cron", "Cron expression when kind=cron, e.g. '0 3 * * *'."), + body("keep_last", "Keep only the newest N backups of this schedule (0 = all)."), + ), + requires_admin=True, + ), + Action( + name="backup_schedule_toggle", + method="POST", + path="/admin/backups/schedules/{uid}/toggle", + summary="Enable or disable a backup schedule (admin only)", + description="Flips the schedule's enabled state; a disabled schedule never fires.", + params=(path("uid", "Backup schedule uid."),), + requires_admin=True, + ), + Action( + name="backup_schedule_run", + method="POST", + path="/admin/backups/schedules/{uid}/run", + summary="Run a backup schedule immediately (admin only)", + description=( + "Enqueues an out-of-band backup job using the schedule's target and keep_last, without " + "waiting for its next scheduled fire. Poll backup_status with the resulting job uid." + ), + params=(path("uid", "Backup schedule uid."),), + requires_admin=True, + ), Action( name="backup_schedule_delete", method="POST", @@ -444,6 +509,35 @@ ADMIN_ACTIONS: tuple[Action, ...] = ( params=(path("uid", "Backup schedule uid."), confirm()), requires_admin=True, ), + Action( + name="admin_list_devii_tasks", + method="GET", + path="/admin/devii-tasks", + summary="List every scheduled Devii task across all owners (admin only)", + description=( + "Returns every scheduled Devii task regardless of owner, with its schedule, status, " + "run/failure counts, quotas, and bounds. state is one of active, inactive, all." + ), + params=(query("state", "Filter: active, inactive, or all."),), + requires_admin=True, + ), + Action( + name="admin_devii_task_disable", + method="POST", + path="/admin/devii-tasks/{uid}/disable", + summary="Disable another owner's scheduled Devii task (admin only)", + description="Stops the task from firing again; it is not deleted and can be re-enabled by its owner.", + params=(path("uid", "Devii task uid."),), + requires_admin=True, + ), + Action( + name="admin_devii_task_delete", + method="POST", + path="/admin/devii-tasks/{uid}/delete", + summary="Delete another owner's scheduled Devii task (admin only). Soft delete, confirmation required", + params=(path("uid", "Devii task uid."), confirm()), + requires_admin=True, + ), Action( name="restore_media", method="POST", diff --git a/devplacepy/services/devii/actions/catalog/issues.py b/devplacepy/services/devii/actions/catalog/issues.py index 2761fa3..5fde7a1 100644 --- a/devplacepy/services/devii/actions/catalog/issues.py +++ b/devplacepy/services/devii/actions/catalog/issues.py @@ -44,8 +44,9 @@ ISSUE_ACTIONS: tuple[Action, ...] = ( "comma-separated list of issue numbers to plan only those tickets; omit it to plan " "every open ticket. Each ticket's full description is reproduced verbatim, both " "inline and in a 'Source Tickets (verbatim)' appendix, so the document is " - "self-contained. Poll the status_url until status is 'done', then show the markdown " - "and the download_url. Admin only." + "self-contained. Poll with planning_status until status is 'done', then show the " + "user the markdown and the download_url (fetch the file itself with " + "planning_download if asked). Admin only." ), params=( body( @@ -56,6 +57,33 @@ ISSUE_ACTIONS: tuple[Action, ...] = ( requires_auth=True, requires_admin=True, ), + Action( + name="planning_status", + method="GET", + path="/issues/planning/{uid}", + summary="Check a planning report job and read it once finished", + description=( + "Returns the job status. When status is 'done', the full markdown document and " + "download_url are populated; while 'pending' or 'running', poll again shortly. Admin only." + ), + params=(path("uid", "Planning job uid returned by planning_report_generate."),), + requires_auth=True, + requires_admin=True, + ), + Action( + name="planning_download", + method="GET", + path="/issues/planning/{uid}/download", + summary="Download the finished planning report file", + description=( + "Returns the raw markdown file of a finished planning report. Use planning_status " + "first; this is only needed if the user wants the file content fetched directly " + "rather than shown inline. Admin only." + ), + params=(path("uid", "Planning job uid returned by planning_report_generate."),), + requires_auth=True, + requires_admin=True, + ), Action( name="issue_job_status", method="GET", diff --git a/devplacepy/services/devii/actions/catalog/tools.py b/devplacepy/services/devii/actions/catalog/tools.py index 4d8396f..2109149 100644 --- a/devplacepy/services/devii/actions/catalog/tools.py +++ b/devplacepy/services/devii/actions/catalog/tools.py @@ -108,6 +108,30 @@ TOOLS_ACTIONS: tuple[Action, ...] = ( params=(path("uid", "DeepSearch job uid returned by deepsearch."),), requires_auth=False, ), + Action( + name="deepsearch_pause", + method="POST", + path="/tools/deepsearch/{uid}/pause", + summary="Pause a running DeepSearch job", + params=(path("uid", "DeepSearch job uid returned by deepsearch."),), + requires_auth=False, + ), + Action( + name="deepsearch_resume", + method="POST", + path="/tools/deepsearch/{uid}/resume", + summary="Resume a paused DeepSearch job", + params=(path("uid", "DeepSearch job uid returned by deepsearch."),), + requires_auth=False, + ), + Action( + name="deepsearch_cancel", + method="POST", + path="/tools/deepsearch/{uid}/cancel", + summary="Cancel a running or paused DeepSearch job", + params=(path("uid", "DeepSearch job uid returned by deepsearch."),), + requires_auth=False, + ), Action( name="isslop", method="POST", @@ -122,7 +146,7 @@ TOOLS_ACTIONS: tuple[Action, ...] = ( params=( body("url", "Repository or website URL to classify.", required=True), ), - requires_auth=True, + requires_auth=False, ), Action( name="isslop_status", @@ -135,7 +159,7 @@ TOOLS_ACTIONS: tuple[Action, ...] = ( "'running', poll again shortly." ), params=(path("uid", "Analysis uid returned by isslop."),), - requires_auth=True, + requires_auth=False, ), Action( name="isslop_report", @@ -148,7 +172,7 @@ TOOLS_ACTIONS: tuple[Action, ...] = ( "embeddable badge snippets. Use it after isslop_status reports status 'completed'." ), params=(path("uid", "Analysis uid returned by isslop."),), - requires_auth=True, + requires_auth=False, ), Action( name="isslop_list", @@ -156,10 +180,10 @@ TOOLS_ACTIONS: tuple[Action, ...] = ( path="/tools/isslop/list", summary="List the user's AI usage analyses", description=( - "Returns the signed-in user's analysis history, newest first, each with its grade, " - "category, status and report_url." + "Returns the signed-in user's (or guest's) analysis history, newest first, each " + "with its grade, category, status and report_url." ), params=(), - requires_auth=True, + requires_auth=False, ), ) diff --git a/devplacepy/services/devii/actions/dispatcher.py b/devplacepy/services/devii/actions/dispatcher.py index e6d0925..6c86fbb 100644 --- a/devplacepy/services/devii/actions/dispatcher.py +++ b/devplacepy/services/devii/actions/dispatcher.py @@ -84,6 +84,7 @@ CONFIRM_REQUIRED = { "publish_quiz", "delete_quiz", "delete_quiz_question", + "admin_devii_task_delete", } CONDITIONAL_CONFIRM = { diff --git a/devplacepy/services/devii/fetch/controller.py b/devplacepy/services/devii/fetch/controller.py index a48cb8e..95003ff 100644 --- a/devplacepy/services/devii/fetch/controller.py +++ b/devplacepy/services/devii/fetch/controller.py @@ -14,7 +14,12 @@ from urllib.parse import urlparse import httpx from devplacepy import stealth -from devplacepy.net_guard import effective_address, is_blocked_address +from devplacepy.net_guard import ( + BlockedAddressError, + effective_address, + guarded_async_client, + is_blocked_address, +) from ..config import Settings from ..errors import NetworkError, ToolInputError, UpstreamError from ..text import html_to_text @@ -238,8 +243,13 @@ class FetchController: response_headers = {key: value for key, value in response.headers.items()} return body, str(response.url), content_type, response.status_code, response_headers + client_factory = ( + stealth.stealth_async_client + if self._settings.fetch_allow_private + else guarded_async_client + ) try: - async with stealth.stealth_async_client( + async with client_factory( headers=merged_headers, follow_redirects=True, timeout=self._settings.fetch_timeout_seconds, @@ -256,5 +266,9 @@ class FetchController: return result except httpx.TimeoutException as exc: raise NetworkError(f"Request timed out fetching {url}", url=url) from exc + except BlockedAddressError as exc: + raise ToolInputError( + f"Refusing to fetch a private or local address: {exc}" + ) from exc except httpx.HTTPError as exc: raise NetworkError(f"Could not fetch {url}: {exc}", url=url) from exc diff --git a/devplacepy/services/devii/hub.py b/devplacepy/services/devii/hub.py index 415bf05..9b1a239 100644 --- a/devplacepy/services/devii/hub.py +++ b/devplacepy/services/devii/hub.py @@ -66,11 +66,6 @@ class DeviiHub: _user_key_resolver(owner_id) if owner_kind == "user" else None ) llm = LLMClient(settings, key_resolver=key_resolver) - # Persistent, owner-isolated stores for signed-in users; ephemeral in-memory for guests. - # The `docs` channel (Docii) is a self-contained documentation assistant: it gets its own - # ephemeral stores so Devii's tasks, lessons, behavior and virtual tools never leak into it - # (and a Docii reflection never pollutes the user's Devii memory). Only the conversation - # thread persists, keyed per channel in the shared ConversationStore. owned_db = ( db if (owner_kind == "user" and channel in ("main", "telegram")) diff --git a/devplacepy/services/devii/session/core.py b/devplacepy/services/devii/session/core.py index 3531867..320385a 100644 --- a/devplacepy/services/devii/session/core.py +++ b/devplacepy/services/devii/session/core.py @@ -790,9 +790,6 @@ class DeviiSession: asyncio.create_task(self._emit(payload, buffer=False)) def _sync_auth(self, name: str) -> None: - # When the agent changes who is logged in, propagate it to the browser so the terminal and - # the browser share one session. Login mints a real platform session in the client's cookie - # jar; hand that token to the browser via /devii/adopt. Logout reuses /auth/logout. if name in ("login", "signup"): token = self.client.session_cookie() if token: @@ -1011,8 +1008,6 @@ class DeviiSession: ) finally: drop.cancel() - # future done -> loop exits and returns; disconnected -> re-send after - # reconnect; otherwise the deadline check at the top raises. return future.result() finally: self._pending.pop(request_id, None) diff --git a/devplacepy/services/devrant/tokens.py b/devplacepy/services/devrant/tokens.py index d433b56..7dacc0e 100644 --- a/devplacepy/services/devrant/tokens.py +++ b/devplacepy/services/devrant/tokens.py @@ -59,11 +59,7 @@ def resolve_user(params: dict) -> Optional[dict]: def resolve_user_by_key(key: str) -> Optional[dict]: - """Resolve a user from a DevRant token key alone (40-char hex). - - Used by the main DevPlace auth chain so that DevRant tokens work as - Bearer / X-API-KEY credentials on every DevPlace endpoint. - """ + # Used by the main DevPlace auth chain so devRant tokens also work as Bearer / X-API-KEY credentials on every DevPlace endpoint. if not key: return None token = get_table("devrant_tokens").find_one(key=key, deleted_at=None) diff --git a/devplacepy/services/jobs/isslop/acquisition/website.py b/devplacepy/services/jobs/isslop/acquisition/website.py index b224b87..afb2fe6 100644 --- a/devplacepy/services/jobs/isslop/acquisition/website.py +++ b/devplacepy/services/jobs/isslop/acquisition/website.py @@ -11,8 +11,7 @@ from urllib.parse import urldefrag, urljoin, urlparse import httpx -from devplacepy.net_guard import BlockedAddressError, guard_public_url -from devplacepy.stealth import stealth_async_client +from devplacepy.net_guard import BlockedAddressError, guard_public_url, guarded_async_client from devplacepy.services.jobs.isslop.acquisition.domcapture import DomSnapshot from devplacepy.services.jobs.isslop.acquisition.workspace import safe_relative_path from devplacepy.services.jobs.isslop.config import ( @@ -136,7 +135,7 @@ async def crawl_website( except RuntimeError as error: logger.warning("Stealth browser crawl failed, falling back to HTTP client: %s", error) yield f"Stealth browser unavailable ({error}); falling back to HTTP client" - async for progress in _crawl_with_http(url, workspace): + async for progress in _crawl_with_http(url, workspace, allow_private=allow_private): yield progress @@ -247,13 +246,15 @@ async def _download_assets( yield f"Downloaded {saved} assets concurrently (scripts, styles, sources)" -async def _crawl_with_http(url: str, workspace: Path) -> AsyncIterator[str]: +async def _crawl_with_http( + url: str, workspace: Path, *, allow_private: bool = False +) -> AsyncIterator[str]: root = urlparse(url) state = CrawlState() queue: list[tuple[str, int]] = [(url, 0)] headers = {"user-agent": USER_AGENT, "accept": "*/*"} yield f"Crawling website {url} with HTTP client (max depth {WEBSITE_MAX_DEPTH}, max {WEBSITE_MAX_FILES} files)" - async with stealth_async_client( + async with guarded_async_client( timeout=WEBSITE_REQUEST_TIMEOUT_SECONDS, follow_redirects=True, headers=headers, @@ -271,8 +272,17 @@ async def _crawl_with_http(url: str, workspace: Path) -> AsyncIterator[str]: continue if any(current.lower().startswith(scheme) for scheme in SKIP_SCHEMES): continue + try: + await guard_public_url(current, allow_private=allow_private) + except BlockedAddressError: + yield f"Blocked private/local address: {current}" + continue try: response = await client.get(current) + except BlockedAddressError as error: + logger.warning("Fetch blocked %s: %s", current, error) + yield f"Blocked private/local address on redirect: {current} ({error})" + continue except httpx.HTTPError as error: logger.warning("Fetch failed %s: %s", current, error) yield f"Fetch failed: {current} ({error})" diff --git a/devplacepy/services/registry.py b/devplacepy/services/registry.py index bc5df60..b45d097 100644 --- a/devplacepy/services/registry.py +++ b/devplacepy/services/registry.py @@ -38,13 +38,7 @@ def _instantiate_all(): def admin_shell_manager() -> ServiceManager: - """Read-only ServiceManager holding one instance of every managed service. - - Never supervised (no set_lock_owner/supervise call) - used only for - describe()/config metadata/key derivation, all of which read or write - through the database layer to the shared service_state / site_settings tables. - The owning Tier 3 process is the only one that ever ticks these services. - """ + # Never supervised (no set_lock_owner/supervise call): only used for describe()/config metadata/key derivation, since the owning Tier 3 process is the only one that ever ticks these services. global _SHELL_MANAGER if _SHELL_MANAGER is None: manager = ServiceManager() diff --git a/devplacepy/services/weblock.py b/devplacepy/services/weblock.py index 215b1a3..9cb98be 100644 --- a/devplacepy/services/weblock.py +++ b/devplacepy/services/weblock.py @@ -12,10 +12,7 @@ _LOCK_FD = None def acquire_web_lock(lock_path: Path) -> bool: - """Non-blocking exclusive lock so exactly one `web` worker owns - in-process-hub-dependent work (DbApiJobService, its query WS). - Web is the one service that can still run N>1 real OS workers - (workers="auto"), unlike every other Tier 3 service (workers=1).""" + # Non-blocking: web is the one Tier 3 service allowed N>1 real OS workers, so exactly one must own in-process-hub-dependent work (DbApiJobService, its query WS). global _LOCK_FD lock_path.parent.mkdir(parents=True, exist_ok=True) fd = open(lock_path, "w") diff --git a/devplacepy/static/css/admin.css b/devplacepy/static/css/admin.css index 112f817..0db45d5 100644 --- a/devplacepy/static/css/admin.css +++ b/devplacepy/static/css/admin.css @@ -69,7 +69,7 @@ align-items: center; gap: 0.4rem; padding: 0.4rem 0.85rem; - border-radius: var(--radius-sm, 6px); + border-radius: var(--radius); border: 1px solid var(--border); color: var(--text-muted); font-size: 0.8125rem; diff --git a/devplacepy/static/css/audit.css b/devplacepy/static/css/audit.css index 3653ba5..faddc8b 100644 --- a/devplacepy/static/css/audit.css +++ b/devplacepy/static/css/audit.css @@ -51,7 +51,7 @@ font-weight: 600; background: var(--bg-card-hover); color: var(--text-secondary); - font-family: var(--font-mono, monospace); + font-family: var(--font-mono); } .audit-badge-origin { @@ -88,11 +88,11 @@ .audit-result-denied { background: rgba(234, 179, 8, 0.15); - color: var(--warning, #eab308); + color: var(--warning); } .audit-actor-ip { - font-family: var(--font-mono, monospace); + font-family: var(--font-mono); color: var(--text-muted); font-size: 0.75rem; } @@ -148,7 +148,7 @@ } .audit-value-change { - font-family: var(--font-mono, monospace); + font-family: var(--font-mono); font-size: 0.8125rem; } diff --git a/devplacepy/static/css/base.css b/devplacepy/static/css/base.css index 3742335..0ddfccb 100644 --- a/devplacepy/static/css/base.css +++ b/devplacepy/static/css/base.css @@ -1178,7 +1178,7 @@ body:has(.page-messages) { transform: translateY(-150%); padding: 0.75rem 1.25rem; background: var(--accent); - color: #fff; + color: var(--white); border-radius: 0 0 var(--radius-input) 0; font-weight: 600; text-decoration: none; @@ -1187,7 +1187,7 @@ body:has(.page-messages) { .skip-link:focus { transform: translateY(0); - outline: 2px solid #fff; + outline: 2px solid var(--white); outline-offset: 2px; } diff --git a/devplacepy/static/css/containers.css b/devplacepy/static/css/containers.css index 7c2b640..5b45468 100644 --- a/devplacepy/static/css/containers.css +++ b/devplacepy/static/css/containers.css @@ -162,7 +162,7 @@ .cm-form textarea { width: 100%; padding: 0.5rem 0.65rem; - background: var(--bg-input, var(--bg-card)); + background: var(--bg-input); color: var(--text-primary); border: 1px solid var(--border); border-radius: var(--radius); @@ -170,7 +170,7 @@ } .cm-form textarea { - font-family: var(--font-mono, monospace); + font-family: var(--font-mono); resize: vertical; } @@ -213,7 +213,7 @@ } .cm-suggest li.cm-suggest-active { - outline: 2px solid var(--accent, var(--border)); + outline: 2px solid var(--accent); outline-offset: -2px; } @@ -255,5 +255,5 @@ .ci-event-time { margin-left: auto; color: var(--text-muted); - font-family: var(--font-mono, monospace); + font-family: var(--font-mono); } diff --git a/devplacepy/static/css/deepsearch.css b/devplacepy/static/css/deepsearch.css index 9f647f6..b9eaf7a 100644 --- a/devplacepy/static/css/deepsearch.css +++ b/devplacepy/static/css/deepsearch.css @@ -60,7 +60,7 @@ } .ds-degraded { - border-left: 4px solid var(--warning, #d97706); + border-left: 4px solid var(--warning); color: var(--text-primary); } @@ -105,7 +105,7 @@ } .ds-form-error { - color: var(--danger, #e5484d); + color: var(--danger); margin: var(--space-md) 0 0; font-size: 0.875rem; } @@ -457,7 +457,7 @@ } .ds-cite { - color: var(--accent, #2563eb); + color: var(--accent); font-weight: 600; font-size: 0.85em; text-decoration: none; @@ -482,9 +482,9 @@ } .ds-sources li:target { - background: var(--bg-highlight, rgba(37, 99, 235, 0.12)); + background: var(--accent-light); border-radius: var(--radius-input); - outline: 2px solid var(--accent, #2563eb); + outline: 2px solid var(--accent); outline-offset: 2px; } @@ -580,7 +580,7 @@ dp-deepsearch-chat { .ds-chat-msg.error .ds-chat-bubble { background: var(--bg-card-hover); - color: var(--danger, #e5484d); + color: var(--danger); } .ds-chat-citations { diff --git a/devplacepy/static/css/docs-chat.css b/devplacepy/static/css/docs-chat.css index c598bc7..1ced925 100644 --- a/devplacepy/static/css/docs-chat.css +++ b/devplacepy/static/css/docs-chat.css @@ -104,7 +104,7 @@ dp-docs-chat { border: 1px solid var(--border); border-radius: var(--radius); padding: 0.05rem 0.3rem; - font-family: var(--font-mono, monospace); + font-family: var(--font-mono); font-size: 0.85em; } @@ -127,7 +127,7 @@ dp-docs-chat { background: none; border: none; padding: 0; - font-family: var(--font-mono, monospace); + font-family: var(--font-mono); } .docs-chat-bubble .md-copy { diff --git a/devplacepy/static/css/docs.css b/devplacepy/static/css/docs.css index f28ca22..1888ec8 100644 --- a/devplacepy/static/css/docs.css +++ b/devplacepy/static/css/docs.css @@ -2,10 +2,10 @@ .docs-layout { display: grid; - grid-template-columns: var(--sidebar-width, 240px) 1fr; + grid-template-columns: var(--sidebar-width) 1fr; gap: 1.5rem; align-items: start; - max-width: var(--max-content, 1200px); + max-width: var(--max-content); margin: 0 auto; padding: 1.5rem 1rem; } @@ -250,7 +250,7 @@ } .param-allowed-values { - font-family: var(--font-mono, monospace); + font-family: var(--font-mono); } .param-input { @@ -563,7 +563,7 @@ pre.code-pre > .code-gutter { padding: 0.45rem 0.6rem; font-size: 0.875rem; color: var(--text-primary); - background: var(--bg-input, var(--bg-card)); + background: var(--bg-input); border: 1px solid var(--border); border-radius: var(--radius); font-family: inherit; diff --git a/devplacepy/static/css/floating-window.css b/devplacepy/static/css/floating-window.css index 03f52c4..a483cc5 100644 --- a/devplacepy/static/css/floating-window.css +++ b/devplacepy/static/css/floating-window.css @@ -7,6 +7,7 @@ --fw-accent: #3fb950; --fw-bar-bg: #0b0b0b; --fw-border: #1c1f24; + --fw-hover-bg: #1b1f25; --fw-shadow: 0 18px 60px rgba(0, 0, 0, 0.6); --fw-radius: 10px; --fw-font: "SFMono-Regular", "JetBrains Mono", "Fira Code", Consolas, "Courier New", monospace; @@ -162,7 +163,7 @@ .fw-host .fw-winctl button:hover { color: var(--fw-fg); - background: #1b1f25; + background: var(--fw-hover-bg); } .fw-host .fw-winctl button[data-win="close"]:hover { diff --git a/devplacepy/static/css/game.css b/devplacepy/static/css/game.css index da52bde..34a5960 100644 --- a/devplacepy/static/css/game.css +++ b/devplacepy/static/css/game.css @@ -1,5 +1,10 @@ /* retoor */ +:root { + --game-gold-rgb: 245, 197, 24; + --game-gold: rgb(var(--game-gold-rgb)); +} + .game-page { max-width: var(--max-content); margin: 0 auto; @@ -536,12 +541,12 @@ } .legacy-card { - border-color: #f5c518; + border-color: var(--game-gold); } .game-plot-golden { - border-color: #f5c518; - box-shadow: 0 0 0 1px #f5c518 inset, 0 0 12px rgba(245, 197, 24, 0.4); + border-color: var(--game-gold); + box-shadow: 0 0 0 1px var(--game-gold) inset, 0 0 12px rgba(var(--game-gold-rgb), 0.4); } .plot-steal-locked { diff --git a/devplacepy/static/css/gateway.css b/devplacepy/static/css/gateway.css index 891863e..9b06d45 100644 --- a/devplacepy/static/css/gateway.css +++ b/devplacepy/static/css/gateway.css @@ -43,7 +43,7 @@ .gw-default code, .admin-table .gw-code { - font-family: var(--font-mono, monospace); + font-family: var(--font-mono); font-size: 0.8125rem; color: var(--text-primary); word-break: break-all; @@ -125,10 +125,10 @@ .gw-field select { width: 100%; padding: 0.5rem 0.65rem; - background: var(--bg-input, var(--bg-card)); + background: var(--bg-input); color: var(--text-primary); border: 1px solid var(--border); - border-radius: var(--radius-input, var(--radius)); + border-radius: var(--radius-input); font: inherit; } diff --git a/devplacepy/static/css/issues.css b/devplacepy/static/css/issues.css index 86c4be5..0c0392b 100644 --- a/devplacepy/static/css/issues.css +++ b/devplacepy/static/css/issues.css @@ -226,15 +226,15 @@ height: 22px; border: none; border-radius: 50%; - background: rgba(0, 0, 0, 0.6); - color: #fff; + background: var(--overlay-dark); + color: var(--white); font-size: 1rem; line-height: 1; cursor: pointer; z-index: 1; } .attachment-gallery-item .issue-att-delete:hover { - background: var(--danger, #c0392b); + background: var(--danger); } .issue-attach-form { display: flex; diff --git a/devplacepy/static/css/post.css b/devplacepy/static/css/post.css index 937e99b..8c2151a 100644 --- a/devplacepy/static/css/post.css +++ b/devplacepy/static/css/post.css @@ -191,7 +191,7 @@ @keyframes comment-highlight-fade { 0% { - background-color: var(--accent-soft, rgba(99, 102, 241, 0.18)); + background-color: var(--accent-light); } 100% { background-color: transparent; diff --git a/devplacepy/static/css/profile.css b/devplacepy/static/css/profile.css index 6a90d5f..13c947a 100644 --- a/devplacepy/static/css/profile.css +++ b/devplacepy/static/css/profile.css @@ -468,7 +468,7 @@ a.profile-stat-value:hover { } .ai-correction-status[data-state="error"] { - color: var(--danger, #e03131); + color: var(--danger); } .follow-row { @@ -603,7 +603,7 @@ a.profile-stat-value:hover { } .ai-quota-fill.ai-quota-danger { - background: var(--danger, #d9534f); + background: var(--danger); } .ai-quota-meta { diff --git a/devplacepy/static/css/services.css b/devplacepy/static/css/services.css index be4eceb..2debacf 100644 --- a/devplacepy/static/css/services.css +++ b/devplacepy/static/css/services.css @@ -312,7 +312,7 @@ max-width: 80ch; border: 1px solid var(--border); border-radius: var(--radius); - background: var(--surface-2, var(--surface)); + background: var(--bg-card); padding: 0.5rem 0.75rem; } diff --git a/devplacepy/static/css/statistics.css b/devplacepy/static/css/statistics.css index ee0acfd..d281a41 100644 --- a/devplacepy/static/css/statistics.css +++ b/devplacepy/static/css/statistics.css @@ -99,14 +99,14 @@ .statistics-charts { display: grid; - grid-template-columns: 1fr; + grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--space-md); margin-bottom: var(--space-md); } -@media (min-width: 768px) { +@media (max-width: 767px) { .statistics-charts { - grid-template-columns: repeat(2, minmax(0, 1fr)); + grid-template-columns: 1fr; } } diff --git a/devplacepy/static/js/AiUsageMonitor.js b/devplacepy/static/js/AiUsageMonitor.js index aa7e721..abe91da 100644 --- a/devplacepy/static/js/AiUsageMonitor.js +++ b/devplacepy/static/js/AiUsageMonitor.js @@ -45,7 +45,6 @@ class AiUsageMonitor { const data = await Http.getJson(`/admin/ai-usage/data?hours=${this.hours}`); this.render(data); } catch { - // silently ignore poll errors } } diff --git a/devplacepy/static/js/BackupMonitor.js b/devplacepy/static/js/BackupMonitor.js index 1f2db70..b2aa2f2 100644 --- a/devplacepy/static/js/BackupMonitor.js +++ b/devplacepy/static/js/BackupMonitor.js @@ -47,7 +47,6 @@ class BackupMonitor { this.targets = data.targets || []; this.render(data); } catch { - // ignore transient poll errors } } @@ -63,7 +62,6 @@ class BackupMonitor { window.app.toast.show("Backup started", { type: "info" }); this.followJob(result.status_url); } catch { - // Http.send already surfaced the error } finally { button.disabled = false; } @@ -103,7 +101,6 @@ class BackupMonitor { window.app.toast.show("Schedule saved", { type: "success" }); this.poll(); } catch { - // error already surfaced } }); } @@ -168,7 +165,6 @@ class BackupMonitor { await Http.send(urls[action], {}); this.poll(); } catch { - // error already surfaced } }); } diff --git a/devplacepy/static/js/CodeBlock.js b/devplacepy/static/js/CodeBlock.js index 12472f7..af0e312 100644 --- a/devplacepy/static/js/CodeBlock.js +++ b/devplacepy/static/js/CodeBlock.js @@ -8,7 +8,6 @@ export class CodeBlock { try { hljs.highlightElement(code); } catch { - // leave plain text on failure } } diff --git a/devplacepy/static/js/ContainerInstance.js b/devplacepy/static/js/ContainerInstance.js index 7d96e65..c409701 100644 --- a/devplacepy/static/js/ContainerInstance.js +++ b/devplacepy/static/js/ContainerInstance.js @@ -62,8 +62,6 @@ export class ContainerInstance { return span.innerHTML; } - // ---------------- actions ---------------- - renderActions(status) { if (!this.canManage) { const box = this.q("#ci-actions"); @@ -107,8 +105,6 @@ export class ContainerInstance { } catch (err) { this.toast(err.message, "error"); } } - // ---------------- polling ---------------- - startDetailPoll() { this.detailPoll = new Poller(async () => { const detail = await Http.getJson(`${this.base}/instances/${this.uid}`); @@ -164,8 +160,6 @@ export class ContainerInstance { if (logs) pre.scrollTop = pre.scrollHeight; } - // ---------------- exec ---------------- - async execOnce() { const input = this.q("#ci-exec-input"); const cmd = input.value.trim(); @@ -203,8 +197,6 @@ export class ContainerInstance { .replace(/\x1b[=>]/g, ""); } - // ---------------- schedules ---------------- - syncScheduleFields(kind) { const form = document.getElementById("ci-schedule-form"); form.querySelectorAll("[data-sched-field]").forEach((field) => { diff --git a/devplacepy/static/js/DomUtils.js b/devplacepy/static/js/DomUtils.js index 74c4e44..2d514be 100644 --- a/devplacepy/static/js/DomUtils.js +++ b/devplacepy/static/js/DomUtils.js @@ -42,7 +42,6 @@ export class DomUtils { await navigator.clipboard.writeText(source.textContent); Toast.flash(btn, "Copied!", 2000); } catch { - // silently fail } }); } @@ -56,7 +55,6 @@ export class DomUtils { await navigator.clipboard.writeText(url); Toast.flash(btn, "Copied!", 1000); } catch { - // silently fail } }); } diff --git a/devplacepy/static/js/GatewayAdmin.js b/devplacepy/static/js/GatewayAdmin.js index a590323..d75230a 100644 --- a/devplacepy/static/js/GatewayAdmin.js +++ b/devplacepy/static/js/GatewayAdmin.js @@ -287,10 +287,7 @@ export class GatewayAdmin { } async remove(url) { - const response = await fetch(url, { method: "DELETE", headers: { Accept: "application/json" } }); - if (!response.ok && response.status !== 404) { - throw new Error(`Delete failed: ${response.status}`); - } + await Http.sendDelete(url, { silent: true }); } async deleteProvider(name) { diff --git a/devplacepy/static/js/Http.js b/devplacepy/static/js/Http.js index 42306aa..6b74782 100644 --- a/devplacepy/static/js/Http.js +++ b/devplacepy/static/js/Http.js @@ -105,6 +105,28 @@ export class Http { return data; } + static async sendDelete(url, options = {}) { + const response = await fetch(url, { + method: "DELETE", + headers: { "Accept": "application/json" }, + }); + if (response.redirected && response.url.includes("/auth/login")) { + Http.toLogin(); + return Http.suspend(); + } + const data = await response.json().catch(() => ({})); + if (!response.ok || data.ok === false) { + const message = (data.error && data.error.message) || `request failed: ${response.status}`; + const gate = Http._gate(data, options, () => + Http.sendDelete(url, { ...options, termsRetry: true }) + ); + if (gate) return gate; + if (!options.silent) Http.notifyError(message); + throw new Error(message); + } + return data; + } + static async postJson(url, body, options = {}) { const response = await fetch(url, { method: "POST", diff --git a/devplacepy/static/js/IssueAttachments.js b/devplacepy/static/js/IssueAttachments.js index bb230fe..16a5f64 100644 --- a/devplacepy/static/js/IssueAttachments.js +++ b/devplacepy/static/js/IssueAttachments.js @@ -46,19 +46,11 @@ export class IssueAttachments { }); if (!confirmed) return; try { - const response = await fetch(button.dataset.action, { - method: "DELETE", - headers: { "Accept": "application/json" }, - }); - const data = await response.json().catch(() => ({})); - if (!response.ok || data.ok === false) { - const message = (data.error && data.error.message) || "Could not delete the attachment"; - throw new Error(message); - } + await Http.sendDelete(button.dataset.action, { silent: true }); const item = button.closest(".attachment-gallery-item"); if (item) item.remove(); } catch (error) { - Http.notifyError(error.message); + Http.notifyError(error.message || "Could not delete the attachment"); } } } diff --git a/devplacepy/static/js/ServiceMonitor.js b/devplacepy/static/js/ServiceMonitor.js index b88de72..03f36f1 100644 --- a/devplacepy/static/js/ServiceMonitor.js +++ b/devplacepy/static/js/ServiceMonitor.js @@ -128,7 +128,6 @@ class ServiceMonitor { this.applyData(await Http.getJson("/admin/services/data")); } } catch { - // silently ignore poll errors } } diff --git a/devplacepy/static/js/StatisticsCharts.js b/devplacepy/static/js/StatisticsCharts.js index eeb7eb0..b9afe0b 100644 --- a/devplacepy/static/js/StatisticsCharts.js +++ b/devplacepy/static/js/StatisticsCharts.js @@ -21,7 +21,6 @@ export class StatisticsCharts { try { chart.destroy(); } catch { - // ignore destroy errors } } this.instances = []; diff --git a/devplacepy/static/js/components/AppDocsChat.js b/devplacepy/static/js/components/AppDocsChat.js index 166614a..f06abd7 100644 --- a/devplacepy/static/js/components/AppDocsChat.js +++ b/devplacepy/static/js/components/AppDocsChat.js @@ -96,7 +96,6 @@ export class AppDocsChat extends Component { try { await fetch("/devii/session", { credentials: "same-origin" }); } catch (error) { - // Best-effort: ensures the guest cookie exists before the socket opens. } this.socket = new DeviiSocket( { diff --git a/devplacepy/static/js/components/ContainerTerminal.js b/devplacepy/static/js/components/ContainerTerminal.js index e53c870..2c220ce 100644 --- a/devplacepy/static/js/components/ContainerTerminal.js +++ b/devplacepy/static/js/components/ContainerTerminal.js @@ -364,7 +364,6 @@ export default class ContainerTerminalElement extends FloatingWindow { try { if (this.term) this.term.dispose(); } catch (error) { - // already disposed } this.remove(); } diff --git a/events.md b/events.md index 51bb91c..5c322bf 100644 --- a/events.md +++ b/events.md @@ -2,7 +2,7 @@ retoor -Every state-changing action in DevPlace records one append-only row through `devplacepy/services/audit/record.py` (`record` for a request-scoped actor, `record_system` for a background one). This file is the authoritative catalogue of the 331 event keys currently emitted, grouped by the category `services/audit/categories.py` `category_for` resolves them to. +Every state-changing action in DevPlace records one append-only row through `devplacepy/services/audit/record.py` (`record` for a request-scoped actor, `record_system` for a background one). This file is the authoritative catalogue of the 355 event keys currently emitted, grouped by the category `services/audit/categories.py` `category_for` resolves them to. ## Rules @@ -25,19 +25,19 @@ Every state-changing action in DevPlace records one append-only row through `dev | `auth` | 8 | | `backup` | 2 | | `cli` | 43 | -| `container` | 36 | +| `container` | 41 | | `content` | 37 | | `database` | 4 | | `devii` | 18 | | `email` | 6 | -| `engagement` | 23 | -| `game` | 6 | +| `engagement` | 28 | +| `game` | 19 | | `ingress` | 1 | | `message` | 2 | | `moderation` | 12 | | `news` | 9 | | `notification` | 4 | -| `project` | 11 | +| `project` | 12 | | `project_files` | 14 | | `pubsub` | 1 | | `push` | 2 | @@ -48,7 +48,7 @@ Every state-changing action in DevPlace records one append-only row through `dev | `telegram` | 5 | | `tools` | 12 | -**Total: 331 keys.** +**Total: 355 keys.** ## Account and profile (`account`) @@ -199,10 +199,14 @@ Every state-changing action in DevPlace records one append-only row through `dev | `container.instance.create` | `routers/admin/containers.py`, `routers/projects/containers/instances.py` | | `container.instance.delete` | `routers/admin/containers.py`, `routers/projects/containers/instances.py` | | `container.instance.exec` | `routers/projects/containers/instances.py`, `services/devii/actions/dispatcher.py` | +| `container.instance.pause` | `routers/admin/containers.py`, `routers/projects/containers/instances.py`, `services/devii/actions/dispatcher.py` | +| `container.instance.restart` | `routers/admin/containers.py`, `routers/projects/containers/instances.py`, `services/devii/actions/dispatcher.py` | +| `container.instance.resume` | `routers/admin/containers.py`, `routers/projects/containers/instances.py`, `services/devii/actions/dispatcher.py` | | `container.instance.shell.close` | `routers/projects/containers/instances.py` | | `container.instance.shell.open` | `routers/projects/containers/instances.py` | -| `container.instance.start` | `docs_api/groups/admin.py` | +| `container.instance.start` | `routers/admin/containers.py`, `routers/projects/containers/instances.py`, `services/devii/actions/dispatcher.py` | | `container.instance.status` | `services/containers/service.py` | +| `container.instance.stop` | `routers/admin/containers.py`, `routers/projects/containers/instances.py`, `services/devii/actions/dispatcher.py` | | `container.instance.sync` | `routers/admin/containers.py`, `routers/projects/containers/instances.py` | | `container.reconcile.action` | `services/containers/service.py` | | `container.tunnel.cert.failure` | `services/containers/workspace_service.py` | @@ -212,10 +216,10 @@ Every state-changing action in DevPlace records one append-only row through `dev | `container.tunnel.failure` | `services/containers/workspace_service.py` | | `container.tunnel.suspend` | `services/containers/workspace_service.py` | | `container.workspace.create` | `routers/projects/containers/workspace.py` | -| `container.workspace.delete` | `routers/projects/containers/workspace.py` | +| `container.workspace.delete` | `routers/projects/containers/workspace.py`, `routers/admin/workspaces.py` | | `container.workspace.editor.update` | `routers/projects/containers/workspace.py`, `routers/admin/workspaces.py` | | `container.workspace.flag.dismiss` | `routers/admin/workspaces.py` | -| `container.workspace.flag.raise` | `services/containers/workspace_service.py` | +| `container.workspace.flag.raise` | `routers/admin/workspaces.py` | | `container.workspace.flag.resolve` | `routers/admin/workspaces.py` | | `container.workspace.idle.stop` | `services/containers/workspace_service.py` | | `container.workspace.idle.warn` | `services/containers/workspace_service.py` | @@ -224,10 +228,10 @@ Every state-changing action in DevPlace records one append-only row through `dev | `container.workspace.quota.block` | `routers/projects/containers/workspace.py` | | `container.workspace.quota.warn` | `services/containers/workspace_service.py` | | `container.workspace.restore` | `routers/admin/workspaces.py` | -| `container.workspace.resume` | `routers/projects/containers/workspace.py` | +| `container.workspace.resume` | `routers/admin/workspaces.py` | | `container.workspace.retention.warn` | `services/containers/workspace_service.py` | | `container.workspace.settings.update` | `routers/admin/workspaces.py` | -| `container.workspace.stop` | `routers/projects/containers/workspace.py` | +| `container.workspace.stop` | `routers/projects/containers/workspace.py`, `routers/admin/workspaces.py` | | `container.workspace.suspend` | `routers/admin/workspaces.py` | | `container.workspace.unsuspend` | `routers/admin/workspaces.py` | | `container.schedule.create` | `routers/projects/containers/schedules.py`, `services/devii/actions/dispatcher.py` | @@ -355,12 +359,25 @@ Every state-changing action in DevPlace records one append-only row through `dev | Event key | Recorded in | |---|---| +| `game.ci.upgrade` | `routers/game/index.py` | | `game.cosmetic.buy` | `routers/game/index.py` | +| `game.cosmetic.equip` | `routers/game/index.py` | +| `game.daily.claim` | `routers/game/index.py` | | `game.defense.downgrade` | `routers/game/index.py` | | `game.defense.upgrade` | `routers/game/index.py` | +| `game.fertilize` | `routers/game/index.py` | | `game.grant.claim` | `routers/game/index.py` | +| `game.harvest` | `routers/game/index.py` | | `game.infrastructure.buy` | `routers/game/index.py` | +| `game.legacy.upgrade` | `routers/game/index.py` | +| `game.mastery.upgrade` | `routers/game/index.py` | +| `game.perk.upgrade` | `routers/game/index.py` | +| `game.plant` | `routers/game/index.py` | +| `game.plot.buy` | `routers/game/index.py` | | `game.prestige` | `routers/game/index.py` | +| `game.quest.claim` | `routers/game/index.py` | +| `game.steal` | `routers/game/farm.py` | +| `game.water` | `routers/game/farm.py` | ## Container ingress (`ingress`) diff --git a/prompt.md b/prompt.md new file mode 100644 index 0000000..63300ff --- /dev/null +++ b/prompt.md @@ -0,0 +1,5 @@ +This project does contain a quiz system. When doing the quiz, it will show all questions you answered as well, but it should only show one at a time . +Also some quiz endpoint that is linked to after making the quiz shows rest response. Please find out which one (of the links) does link to a action that only responds with json., Its not appropiate. + + +FInd for everyting root cause and fix it. diff --git a/prompt2.md b/prompt2.md new file mode 100644 index 0000000..efa8511 --- /dev/null +++ b/prompt2.md @@ -0,0 +1 @@ +I see when i ask for where my ai costs went that a lot went ot devii `internal`... Well, literally every ai costs has to be someones when it comes to devii. SOmeone created the action or whatnot. Please find the root and update that part perfectly. : diff --git a/tests/api/dbapi/query.py b/tests/api/dbapi/query.py index b58dc3c..50f56fb 100644 --- a/tests/api/dbapi/query.py +++ b/tests/api/dbapi/query.py @@ -1,8 +1,13 @@ # retoor +import json +import shutil +from datetime import datetime, timezone + import pytest from starlette.websockets import WebSocketDisconnect +from devplacepy.config import DBAPI_DIR from devplacepy.services.manager import service_manager @@ -74,3 +79,73 @@ def test_ws_non_owner_retries(client, auth): assert info.value.code == 4013 finally: service_manager.set_lock_owner(True) + + +def test_result_route_serves_the_persisted_rows(client, auth): + enqueued = client.post( + "/dbapi/query/async", json={"sql": "SELECT 1 AS n"}, headers=auth + ) + uid = enqueued.json()["uid"] + output_dir = DBAPI_DIR / uid + output_dir.mkdir(parents=True, exist_ok=True) + result = { + "sql": "SELECT 1 AS n", + "row_count": 1, + "truncated": False, + "suspicious": [], + "rows": [{"n": 1}], + } + (output_dir / "result.json").write_text(json.dumps(result), encoding="utf-8") + try: + response = client.get(f"/dbapi/query/{uid}/result", headers=auth) + assert response.status_code == 200, response.text + assert response.json()["rows"] == [{"n": 1}] + finally: + shutil.rmtree(output_dir, ignore_errors=True) + + +def test_result_route_404s_without_a_written_result(client, auth): + enqueued = client.post( + "/dbapi/query/async", json={"sql": "SELECT 1 AS n"}, headers=auth + ) + uid = enqueued.json()["uid"] + response = client.get(f"/dbapi/query/{uid}/result", headers=auth) + assert response.status_code == 404 + + +def test_result_route_rejects_a_traversal_uid(client, auth): + from devplacepy.database import get_table + + evil_uid = ".." + now = datetime.now(timezone.utc).isoformat() + get_table("jobs").insert( + { + "uid": evil_uid, + "kind": "dbquery", + "status": "done", + "owner_kind": "user", + "owner_id": "traversal-owner", + "preferred_name": "", + "payload": "{}", + "result": "{}", + "error": "", + "retry_count": 0, + "created_at": now, + "started_at": now, + "completed_at": now, + "updated_at": now, + "duration_ms": 0, + "last_accessed_at": "", + "expires_at": "", + "bytes_in": 0, + "bytes_out": 0, + "item_count": 0, + } + ) + escape_target = DBAPI_DIR.parent / "result.json" + try: + response = client.get(f"/dbapi/query/{evil_uid}/result", headers=auth) + assert response.status_code in (400, 404), response.text + assert not escape_target.exists() + finally: + get_table("jobs").delete(uid=evil_uid) diff --git a/tests/api/projects/workspace.py b/tests/api/projects/workspace.py index eec05c4..196f9da 100644 --- a/tests/api/projects/workspace.py +++ b/tests/api/projects/workspace.py @@ -61,8 +61,10 @@ def _instance(**overrides) -> dict: def test_open_workspace_requires_enabled_setting(): set_setting("workspace_enabled", "0") - assert can_open_workspace(_project(), {"uid": OWNER, "role": "Member"}) is False - set_setting("workspace_enabled", "1") + try: + assert can_open_workspace(_project(), {"uid": OWNER, "role": "Member"}) is False + finally: + set_setting("workspace_enabled", "1") assert can_open_workspace(_project(), {"uid": OWNER, "role": "Member"}) is True @@ -98,11 +100,13 @@ def test_create_or_resume_is_idempotent(): def test_workspace_quota_blocks_beyond_limit(): set_setting("workspace_max_per_user", "1") - user = {"uid": OWNER, "username": "owner"} - run_async(provision.ensure(_project("p-a"), user)) - with pytest.raises(WorkspaceError): - run_async(provision.ensure(_project("p-b"), user)) - set_setting("workspace_max_per_user", "2") + try: + user = {"uid": OWNER, "username": "owner"} + run_async(provision.ensure(_project("p-a"), user)) + with pytest.raises(WorkspaceError): + run_async(provision.ensure(_project("p-b"), user)) + finally: + set_setting("workspace_max_per_user", "2") def test_tunnel_revives_rather_than_duplicates(): diff --git a/tests/api/tools/isslop.py b/tests/api/tools/isslop.py index d994ffd..4bdee1a 100644 --- a/tests/api/tools/isslop.py +++ b/tests/api/tools/isslop.py @@ -1,11 +1,14 @@ # retoor import time +import uuid import requests from tests.conftest import BASE_URL from devplacepy.database import get_table, refresh_snapshot +from devplacepy.services.jobs.isslop import store +from devplacepy.utils import generate_uid _counter_isslop = [0] @@ -192,3 +195,103 @@ def test_guest_history_claimed_on_signup(app_server): assert get_table("isslop_analyses").count(uid=uid) == 1 finally: _clear_isslop_data() + + +def test_source_route_serves_the_annotated_file(app_server): + uid = generate_uid() + store.create_analysis(uid, "https://github.com/owner/repository", "guest", "src-owner") + source_name = "s" + uuid.uuid4().hex[:16] + ".txt" + store.insert_file_result( + uid, + { + "path": "app.py", + "language": "python", + "lines": 1, + "origin_score": 0.1, + "quality_deficit_score": 0.1, + "category": "human-authored", + "signals": "[]", + "source": source_name, + }, + ) + media_dir = store.media_dir_for(uid) + media_dir.mkdir(parents=True, exist_ok=True) + (media_dir / source_name).write_text("print('hello')\n", encoding="utf-8") + try: + r = requests.get( + f"{BASE_URL}/tools/isslop/{uid}/source", + params={"path": "app.py"}, + headers=_json_headers(), + ) + assert r.status_code == 200, r.text + body = r.json() + assert "print" in body["source"] + assert body["path"] == "app.py" + finally: + store.purge_analysis(uid) + + +def test_source_route_rejects_an_unsafe_source_token(app_server): + uid = generate_uid() + store.create_analysis(uid, "https://github.com/owner/repository", "guest", "src-traversal-owner") + store.insert_file_result( + uid, + { + "path": "app.py", + "language": "python", + "lines": 1, + "origin_score": 0.0, + "quality_deficit_score": 0.0, + "category": "human-authored", + "signals": "[]", + "source": "../../../../etc/passwd", + }, + ) + try: + r = requests.get( + f"{BASE_URL}/tools/isslop/{uid}/source", + params={"path": "app.py"}, + headers=_json_headers(), + ) + assert r.status_code == 404 + finally: + store.purge_analysis(uid) + + +def test_media_route_serves_a_thumbnail(app_server): + uid = generate_uid() + store.create_analysis(uid, "https://github.com/owner/repository", "guest", "media-owner") + name = uuid.uuid4().hex[:16] + ".webp" + store.insert_image_result( + uid, + { + "path": "assets/hero.png", + "ai_probability": 0.2, + "grade": "n/a", + "verdict": "uncertain", + "image_kind": "image", + "tells": "[]", + "description": "", + "thumb": name, + }, + ) + media_dir = store.media_dir_for(uid) + media_dir.mkdir(parents=True, exist_ok=True) + (media_dir / name).write_bytes(b"not-a-real-webp-but-bytes") + try: + r = requests.get(f"{BASE_URL}/tools/isslop/{uid}/media/{name}") + assert r.status_code == 200, r.text + assert r.headers["content-type"].startswith("image/webp") + assert r.content == b"not-a-real-webp-but-bytes" + finally: + store.purge_analysis(uid) + + +def test_media_route_rejects_a_name_outside_the_hex_pattern(app_server): + uid = generate_uid() + store.create_analysis(uid, "https://github.com/owner/repository", "guest", "media-traversal-owner") + try: + r = requests.get(f"{BASE_URL}/tools/isslop/{uid}/media/..%2fetc%2fpasswd") + assert r.status_code == 404, r.text + finally: + store.purge_analysis(uid) diff --git a/tests/unit/dependencies.py b/tests/unit/dependencies.py index 440c3e5..54c5ce7 100644 --- a/tests/unit/dependencies.py +++ b/tests/unit/dependencies.py @@ -77,7 +77,7 @@ def test_formdata_list_field_mixed_empty_and_real(): def test_formdata_literal_fields_are_scalar(): """Literal-typed fields (e.g. target_type in CommentForm) are treated as - scalars — the origin check must not crash on typing.Literal.""" + scalars - the origin check must not crash on typing.Literal.""" fd = FormData([ ("content", "Great comment here!"), ("target_type", "post"), @@ -108,14 +108,14 @@ def test_formdata_literal_form_from_browser(): def test_formdata_vote_form(): - """value: int field with form data — string '1' is kept for Pydantic coerce.""" + """value: int field with form data - string '1' is kept for Pydantic coerce.""" fd = FormData([("value", "1")]) body = _formdata_to_dict(fd, VoteForm) assert body["value"] == "1" # Pydantic coerces str→int def test_formdata_issue_status_literal(): - """IssueStatusForm has Literal['open','closed'] — must not crash.""" + """IssueStatusForm has Literal['open','closed'] - must not crash.""" fd = FormData([("status", "open")]) body = _formdata_to_dict(fd, IssueStatusForm) assert body["status"] == "open" diff --git a/tests/unit/test_access_tokens.py b/tests/unit/test_access_tokens.py index 3774afb..61e36b1 100644 --- a/tests/unit/test_access_tokens.py +++ b/tests/unit/test_access_tokens.py @@ -81,7 +81,7 @@ def test_issue_token_returns_expected_fields(local_db): def test_resolve_token_valid(local_db): - """Issue a token then resolve it — returns the correct user.""" + """Issue a token then resolve it - returns the correct user.""" uid = _seed_user("resolver", "resolver@test.dev") user = get_table("users").find_one(uid=uid) @@ -239,10 +239,10 @@ def test_resolve_user_access_token_priority_after_api_key(local_db): """api_key user wins over access token when the same string matches both.""" shared_key = secrets.token_hex(32) # 64-char hex - # User A — owns the api_key + # User A - owns the api_key uid_a = _seed_user("apikey_winner", "apikeywinner@test.dev", api_key=shared_key) - # User B — owns an access token with the same string as the key + # User B - owns an access token with the same string as the key uid_b = _seed_user("acctok_loser", "acctokloser@test.dev") get_table("access_tokens").insert( { @@ -257,14 +257,14 @@ def test_resolve_user_access_token_priority_after_api_key(local_db): } ) - # X-API-KEY path — api_key user wins + # X-API-KEY path - api_key user wins request = _MockRequest(headers={"X-API-KEY": shared_key}) resolved = _resolve_user(request) assert resolved is not None assert resolved["uid"] == uid_a assert resolved["username"] == "apikey_winner" - # Bearer path — api_key user wins + # Bearer path - api_key user wins request2 = _MockRequest(headers={"Authorization": f"Bearer {shared_key}"}) resolved2 = _resolve_user(request2) assert resolved2 is not None diff --git a/tests/unit/test_devrant_token_auth.py b/tests/unit/test_devrant_token_auth.py index 9658406..36e8433 100644 --- a/tests/unit/test_devrant_token_auth.py +++ b/tests/unit/test_devrant_token_auth.py @@ -68,7 +68,7 @@ def _seed_token(user_uid, key, expire_time): # ── resolve_user_by_key tests ──────────────────────────────────────────────── def test_resolve_user_by_key_valid(local_db): - """Create a devrant token for a user, then resolve it by key — the right user is returned.""" + """Create a devrant token for a user, then resolve it by key - the right user is returned.""" uid = _seed_user("dr_valid", "drvalid@test.dev") token_key = secrets.token_hex(20) expire_time = int(datetime.now(timezone.utc).timestamp()) + 86400 @@ -81,13 +81,13 @@ def test_resolve_user_by_key_valid(local_db): def test_resolve_user_by_key_not_found(local_db): - """Look up a random key that has no token — verify None.""" + """Look up a random key that has no token - verify None.""" random_key = secrets.token_hex(20) assert resolve_user_by_key(random_key) is None def test_resolve_user_by_key_expired(local_db): - """Create an expired token — resolve returns None.""" + """Create an expired token - resolve returns None.""" uid = _seed_user("dr_expired", "drexpired@test.dev") token_key = secrets.token_hex(20) expire_time = int(datetime.now(timezone.utc).timestamp()) - 86400 @@ -97,7 +97,7 @@ def test_resolve_user_by_key_expired(local_db): def test_resolve_user_by_key_inactive_user(local_db): - """Create a token for an inactive user — resolve returns None.""" + """Create a token for an inactive user - resolve returns None.""" uid = _seed_user("dr_inactive", "drinactive@test.dev", is_active=False) token_key = secrets.token_hex(20) expire_time = int(datetime.now(timezone.utc).timestamp()) + 86400 @@ -162,10 +162,10 @@ def test_resolve_user_prefers_api_key_over_devrant(local_db): """If a key matches both an api_key and a devrant token, the api_key user wins.""" shared_key = secrets.token_hex(20) - # User A — owns the api_key + # User A - owns the api_key uid_a = _seed_user("api_key_owner", "apikey@test.dev", api_key=shared_key) - # User B — owns the devrant token with the same key + # User B - owns the devrant token with the same key uid_b = _seed_user("devrant_owner", "drowner@test.dev") expire_time = int(datetime.now(timezone.utc).timestamp()) + 86400 _seed_token(uid_b, shared_key, expire_time)