Attribute Devii AI spend to its invoking action, fix quiz question-at-a-time review, DB API/isslop result routes, workspace docs, and drop redundant docstrings

- Route Devii-driven AI gateway cost to the action/tool that triggered
  it instead of a blanket "internal" bucket, so per-feature AI spend
  is attributable.
- Fix the quiz attempt review to show one previously-answered question
  at a time instead of all of them at once, and stop a quiz endpoint
  linked from the quiz flow from responding with raw JSON.
- Add DB API async query result route and AI Usage Analyzer annotated
  source/media routes, with traversal-safe uid/path handling and
  matching tests.
- Add Code Farm action audit logging (plant/harvest/buy-plot/upgrade/
  fertilize) and related admin workspace/services/trash/gateway route
  and doc touch-ups.
- Drop redundant docstrings from access_tokens.py per the no-comments
  convention.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VL9Xn57W5UR3HZbbuuzxdK
This commit is contained in:
2026-09-03 08:47:57 +02:00
co-authored by Claude Sonnet 5
parent 9d7b3db314
commit 57087536e5
76 changed files with 805 additions and 338 deletions
+2 -1
View File
@@ -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
+31 -2
View File
@@ -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"]
+2 -1
View File
@@ -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",
+11
View File
@@ -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:
+43 -29
View File
@@ -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:
-2
View File
@@ -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
-26
View File
@@ -1,9 +1,4 @@
# retoor <retoor@molodetz.nl>
"""
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)
-8
View File
@@ -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",
+1
View File
@@ -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,
+1
View File
@@ -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,
+1 -1
View File
@@ -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"},
+1
View File
@@ -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,
+3 -8
View File
@@ -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)
-6
View File
@@ -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
+3 -1
View File
@@ -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,
-2
View File
@@ -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()
+1 -1
View File
@@ -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
-15
View File
@@ -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",
+41 -1
View File
@@ -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",
+145 -9
View File
@@ -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
)
+8 -21
View File
@@ -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))
+9 -14
View File
@@ -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:
+4 -15
View File
@@ -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"],
@@ -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))]
+1
View File
@@ -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")
)
-13
View File
@@ -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()
-6
View File
@@ -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
-6
View File
@@ -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:
-1
View File
@@ -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
@@ -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",
@@ -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",
@@ -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,
),
)
@@ -84,6 +84,7 @@ CONFIRM_REQUIRED = {
"publish_quiz",
"delete_quiz",
"delete_quiz_question",
"admin_devii_task_delete",
}
CONDITIONAL_CONFIRM = {
+16 -2
View File
@@ -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
-5
View File
@@ -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"))
@@ -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)
+1 -5
View File
@@ -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)
@@ -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})"
+1 -7
View File
@@ -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()
+1 -4
View File
@@ -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")
+1 -1
View File
@@ -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;
+4 -4
View File
@@ -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;
}
+2 -2
View File
@@ -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;
}
+4 -4
View File
@@ -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);
}
+6 -6
View File
@@ -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 {
+2 -2
View File
@@ -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 {
+4 -4
View File
@@ -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;
+2 -1
View File
@@ -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 {
+8 -3
View File
@@ -1,5 +1,10 @@
/* retoor <retoor@molodetz.nl> */
: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 {
+3 -3
View File
@@ -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;
}
+3 -3
View File
@@ -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;
+1 -1
View File
@@ -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;
+2 -2
View File
@@ -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 {
+1 -1
View File
@@ -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;
}
+3 -3
View File
@@ -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;
}
}
-1
View File
@@ -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
}
}
-4
View File
@@ -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
}
});
}
-1
View File
@@ -8,7 +8,6 @@ export class CodeBlock {
try {
hljs.highlightElement(code);
} catch {
// leave plain text on failure
}
}
@@ -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) => {
-2
View File
@@ -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
}
});
}
+1 -4
View File
@@ -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) {
+22
View File
@@ -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",
+2 -10
View File
@@ -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");
}
}
}
-1
View File
@@ -128,7 +128,6 @@ class ServiceMonitor {
this.applyData(await Http.getJson("/admin/services/data"));
}
} catch {
// silently ignore poll errors
}
}
-1
View File
@@ -21,7 +21,6 @@ export class StatisticsCharts {
try {
chart.destroy();
} catch {
// ignore destroy errors
}
}
this.instances = [];
@@ -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(
{
@@ -364,7 +364,6 @@ export default class ContainerTerminalElement extends FloatingWindow {
try {
if (this.term) this.term.dispose();
} catch (error) {
// already disposed
}
this.remove();
}
+28 -11
View File
@@ -2,7 +2,7 @@
retoor <retoor@molodetz.nl>
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`)
+5
View File
@@ -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.
+1
View File
@@ -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. :
+75
View File
@@ -1,8 +1,13 @@
# retoor <retoor@molodetz.nl>
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)
+11 -7
View File
@@ -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():
+103
View File
@@ -1,11 +1,14 @@
# retoor <retoor@molodetz.nl>
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)
+3 -3
View File
@@ -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"
+5 -5
View File
@@ -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
+6 -6
View File
@@ -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)