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:
@@ -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
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,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))
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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))]
|
||||
|
||||
@@ -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")
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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})"
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -128,7 +128,6 @@ class ServiceMonitor {
|
||||
this.applyData(await Http.getJson("/admin/services/data"));
|
||||
}
|
||||
} catch {
|
||||
// silently ignore poll errors
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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`)
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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. :
|
||||
@@ -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)
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user