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. |
|
| `/` | 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 |
|
| `/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. |
|
| `/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 |
|
| `/news` | Developer news listing, detail page with comments |
|
||||||
| `/posts` | Post detail, creation |
|
| `/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 |
|
| `/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.
|
token and a production token can coexist.
|
||||||
|
|
||||||
A notification is also **marked read automatically when you open the page that shows its
|
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
|
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
|
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
|
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 PIL import Image
|
||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
import httpx
|
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.database import get_table, db, get_setting
|
||||||
from devplacepy.config import UPLOADS_DIR, ATTACHMENTS_DIR
|
from devplacepy.config import UPLOADS_DIR, ATTACHMENTS_DIR
|
||||||
from devplacepy.utils import generate_uid
|
from devplacepy.utils import generate_uid
|
||||||
@@ -389,7 +389,7 @@ async def fetch_remote_file(url, filename=None):
|
|||||||
await _guard_public_url(url)
|
await _guard_public_url(url)
|
||||||
max_bytes = _get_max_upload_bytes()
|
max_bytes = _get_max_upload_bytes()
|
||||||
try:
|
try:
|
||||||
async with stealth.stealth_async_client(
|
async with guarded_async_client(
|
||||||
follow_redirects=True,
|
follow_redirects=True,
|
||||||
timeout=REMOTE_FETCH_TIMEOUT,
|
timeout=REMOTE_FETCH_TIMEOUT,
|
||||||
headers={"User-Agent": REMOTE_FETCH_USER_AGENT},
|
headers={"User-Agent": REMOTE_FETCH_USER_AGENT},
|
||||||
@@ -412,6 +412,8 @@ async def fetch_remote_file(url, filename=None):
|
|||||||
413,
|
413,
|
||||||
)
|
)
|
||||||
data = b"".join(chunks)
|
data = b"".join(chunks)
|
||||||
|
except BlockedAddressError as exc:
|
||||||
|
raise RemoteFetchError(str(exc), 400) from exc
|
||||||
except httpx.HTTPError as exc:
|
except httpx.HTTPError as exc:
|
||||||
raise RemoteFetchError(f"Could not fetch {url}: {exc}", 400) from 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):
|
def set_gitea_asset_id(uid, asset_id):
|
||||||
get_table("attachments").update(
|
get_table("attachments").update(
|
||||||
{"uid": uid, "gitea_asset_id": int(asset_id)}, ["uid"]
|
{"uid": uid, "gitea_asset_id": int(asset_id)}, ["uid"]
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ from .moderation import (
|
|||||||
years_between,
|
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 .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 .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 .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
|
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",
|
"load_comments_by_target_uids",
|
||||||
"resolve_by_slug",
|
"resolve_by_slug",
|
||||||
"resolve_object_url",
|
"resolve_object_url",
|
||||||
|
"get_projects_by_uids",
|
||||||
"get_uids_by_username_match",
|
"get_uids_by_username_match",
|
||||||
"text_search_clause",
|
"text_search_clause",
|
||||||
"get_daily_topic",
|
"get_daily_topic",
|
||||||
|
|||||||
@@ -102,6 +102,17 @@ def resolve_object_url(target_type: str, target_uid: str) -> str:
|
|||||||
return "/feed"
|
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):
|
def get_uids_by_username_match(search, limit=200):
|
||||||
term = (search or "").strip()
|
term = (search or "").strip()
|
||||||
if not term or "users" not in db.tables:
|
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 .core import TTLCache, _in_clause, _now_iso, db, get_table
|
||||||
from .users import get_users_by_uids
|
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] = {
|
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"])
|
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:
|
def soft_delete_engagement(target_type: str, target_uids: list, deleted_by: str) -> None:
|
||||||
uids = [uid for uid in (target_uids or []) if uid]
|
uids = [uid for uid in (target_uids or []) if uid]
|
||||||
if not uids:
|
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
|
"bookmarks", "target_uid", uids, deleted_by, stamp=stamp, target_type=target_type
|
||||||
)
|
)
|
||||||
if target_type == "post" and "polls" in db.tables:
|
if target_type == "post" and "polls" in db.tables:
|
||||||
for uid in uids:
|
poll_uids = _child_uids("polls", "post_uid", uids, live_only=True)
|
||||||
for poll in db["polls"].find(post_uid=uid, deleted_at=None):
|
soft_delete_in("poll_votes", "poll_uid", poll_uids, deleted_by, stamp=stamp)
|
||||||
soft_delete("poll_votes", deleted_by, stamp=stamp, poll_uid=poll["uid"])
|
soft_delete_in("poll_options", "poll_uid", poll_uids, deleted_by, stamp=stamp)
|
||||||
soft_delete("poll_options", deleted_by, stamp=stamp, poll_uid=poll["uid"])
|
soft_delete_in("polls", "post_uid", uids, deleted_by, stamp=stamp)
|
||||||
soft_delete("polls", deleted_by, stamp=stamp, post_uid=uid)
|
|
||||||
if target_type == "post" and "opinion_wars" in db.tables:
|
if target_type == "post" and "opinion_wars" in db.tables:
|
||||||
for uid in uids:
|
war_uids = _child_uids("opinion_wars", "post_uid", uids, live_only=True)
|
||||||
for war in db["opinion_wars"].find(post_uid=uid, deleted_at=None):
|
soft_delete_in("opinion_war_fighters", "war_uid", war_uids, deleted_by, stamp=stamp)
|
||||||
soft_delete(
|
soft_delete_in("opinion_war_events", "war_uid", war_uids, deleted_by, stamp=stamp)
|
||||||
"opinion_war_fighters", deleted_by, stamp=stamp, war_uid=war["uid"]
|
soft_delete_in("opinion_wars", "post_uid", uids, deleted_by, stamp=stamp)
|
||||||
)
|
|
||||||
soft_delete(
|
|
||||||
"opinion_war_events", deleted_by, stamp=stamp, war_uid=war["uid"]
|
|
||||||
)
|
|
||||||
soft_delete("opinion_wars", deleted_by, stamp=stamp, post_uid=uid)
|
|
||||||
|
|
||||||
|
|
||||||
def delete_engagement(target_type: str, target_uids: list) -> None:
|
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,
|
**params,
|
||||||
)
|
)
|
||||||
if target_type == "post" and "polls" in tables:
|
if target_type == "post" and "polls" in tables:
|
||||||
for uid in uids:
|
poll_uids = _child_uids("polls", "post_uid", uids)
|
||||||
for poll in db["polls"].find(post_uid=uid):
|
if poll_uids:
|
||||||
if "poll_votes" in tables:
|
if "poll_votes" in tables:
|
||||||
db["poll_votes"].delete(poll_uid=poll["uid"])
|
_delete_in("poll_votes", "poll_uid", poll_uids)
|
||||||
if "poll_options" in tables:
|
if "poll_options" in tables:
|
||||||
db["poll_options"].delete(poll_uid=poll["uid"])
|
_delete_in("poll_options", "poll_uid", poll_uids)
|
||||||
db["polls"].delete(post_uid=uid)
|
_delete_in("polls", "post_uid", uids)
|
||||||
if target_type == "post" and "opinion_wars" in tables:
|
if target_type == "post" and "opinion_wars" in tables:
|
||||||
for uid in uids:
|
war_uids = _child_uids("opinion_wars", "post_uid", uids)
|
||||||
for war in db["opinion_wars"].find(post_uid=uid):
|
if war_uids:
|
||||||
if "opinion_war_fighters" in tables:
|
if "opinion_war_fighters" in tables:
|
||||||
db["opinion_war_fighters"].delete(war_uid=war["uid"])
|
_delete_in("opinion_war_fighters", "war_uid", war_uids)
|
||||||
if "opinion_war_events" in tables:
|
if "opinion_war_events" in tables:
|
||||||
db["opinion_war_events"].delete(war_uid=war["uid"])
|
_delete_in("opinion_war_events", "war_uid", war_uids)
|
||||||
db["opinion_wars"].delete(post_uid=uid)
|
_delete_in("opinion_wars", "post_uid", uids)
|
||||||
|
|
||||||
|
|
||||||
def get_target_owner_uid(target_type: str, target_uid: str) -> str | None:
|
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)
|
_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
|
PRIMARY_ADMIN_CANDIDATES = 50
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,4 @@
|
|||||||
# retoor <retoor@molodetz.nl>
|
# retoor <retoor@molodetz.nl>
|
||||||
"""
|
|
||||||
Generic FastAPI dependency that accepts JSON or form-encoded data,
|
|
||||||
validated against a Pydantic model.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
from typing import Any, TypeVar, get_origin
|
from typing import Any, TypeVar, get_origin
|
||||||
@@ -17,21 +12,10 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
_TModel = TypeVar("_TModel", bound=BaseModel)
|
_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})
|
_SEQUENCE_ORIGINS = frozenset({list, set, tuple, frozenset})
|
||||||
|
|
||||||
|
|
||||||
def _formdata_to_dict(form: FormData, model: type[BaseModel]) -> dict[str, Any]:
|
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] = {}
|
body: dict[str, Any] = {}
|
||||||
for field_name, field_info in model.model_fields.items():
|
for field_name, field_info in model.model_fields.items():
|
||||||
origin = get_origin(field_info.annotation)
|
origin = get_origin(field_info.annotation)
|
||||||
@@ -50,8 +34,6 @@ def _formdata_to_dict(form: FormData, model: type[BaseModel]) -> dict[str, Any]:
|
|||||||
|
|
||||||
|
|
||||||
class _JsonOrForm:
|
class _JsonOrForm:
|
||||||
"""Internal callable that parses JSON or form data and validates."""
|
|
||||||
|
|
||||||
def __init__(self, model: type[BaseModel]):
|
def __init__(self, model: type[BaseModel]):
|
||||||
self.model = model
|
self.model = model
|
||||||
|
|
||||||
@@ -70,7 +52,6 @@ class _JsonOrForm:
|
|||||||
status_code=400, detail="JSON body must be an object"
|
status_code=400, detail="JSON body must be an object"
|
||||||
)
|
)
|
||||||
return self.model.model_validate(body)
|
return self.model.model_validate(body)
|
||||||
# Default: form-encoded (multipart or url-encoded)
|
|
||||||
try:
|
try:
|
||||||
form = await request.form()
|
form = await request.form()
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
@@ -85,11 +66,4 @@ class _JsonOrForm:
|
|||||||
|
|
||||||
|
|
||||||
def json_or_form(model: type[_TModel]) -> _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)
|
return _JsonOrForm(model)
|
||||||
|
|||||||
@@ -51,16 +51,8 @@ PUSH_TYPE = "alert"
|
|||||||
PRIORITY = "10"
|
PRIORITY = "10"
|
||||||
SHARED_TOKEN_KEY = "push_apns_shared_token"
|
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"})
|
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(
|
AUTH_REASONS = frozenset(
|
||||||
{
|
{
|
||||||
"BadCertificate",
|
"BadCertificate",
|
||||||
|
|||||||
@@ -89,6 +89,7 @@ async def admin_backups(request: Request):
|
|||||||
{"name": "Backups", "url": "/admin/backups"},
|
{"name": "Backups", "url": "/admin/backups"},
|
||||||
],
|
],
|
||||||
schemas=[website_schema(base)],
|
schemas=[website_schema(base)],
|
||||||
|
robots="noindex,nofollow",
|
||||||
)
|
)
|
||||||
return respond(
|
return respond(
|
||||||
request,
|
request,
|
||||||
|
|||||||
@@ -139,6 +139,7 @@ async def admin_devii_tasks(request: Request, state: str = "active"):
|
|||||||
{"name": "Devii tasks", "url": "/admin/devii-tasks"},
|
{"name": "Devii tasks", "url": "/admin/devii-tasks"},
|
||||||
],
|
],
|
||||||
schemas=[website_schema(base)],
|
schemas=[website_schema(base)],
|
||||||
|
robots="noindex,nofollow",
|
||||||
)
|
)
|
||||||
return respond(
|
return respond(
|
||||||
request,
|
request,
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ async def service_detail(request: Request, name: str):
|
|||||||
request,
|
request,
|
||||||
title=f"{info['title']} - Services",
|
title=f"{info['title']} - Services",
|
||||||
description=info["description"] or f"Configure the {info['title']} service.",
|
description=info["description"] or f"Configure the {info['title']} service.",
|
||||||
robots="noindex",
|
robots="noindex,nofollow",
|
||||||
breadcrumbs=[
|
breadcrumbs=[
|
||||||
{"name": "Home", "url": "/feed"},
|
{"name": "Home", "url": "/feed"},
|
||||||
{"name": "Admin", "url": "/admin"},
|
{"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"},
|
{"name": "Trash", "url": "/admin/trash"},
|
||||||
],
|
],
|
||||||
schemas=[website_schema(base)],
|
schemas=[website_schema(base)],
|
||||||
|
robots="noindex,nofollow",
|
||||||
)
|
)
|
||||||
return respond(
|
return respond(
|
||||||
request,
|
request,
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ from typing import Annotated
|
|||||||
from fastapi import APIRouter, Depends, Request
|
from fastapi import APIRouter, Depends, Request
|
||||||
from fastapi.responses import JSONResponse
|
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.dependencies import json_or_form
|
||||||
from devplacepy.models import (
|
from devplacepy.models import (
|
||||||
EditorPrefsForm,
|
EditorPrefsForm,
|
||||||
@@ -34,13 +34,8 @@ router = APIRouter()
|
|||||||
def _decorate(rows: list[dict]) -> list[dict]:
|
def _decorate(rows: list[dict]) -> list[dict]:
|
||||||
owner_uids = {row.get("workspace_owner_uid") for row in rows if row.get("workspace_owner_uid")}
|
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 {}
|
owners = get_users_by_uids(list(owner_uids)) if owner_uids else {}
|
||||||
projects = {}
|
project_uids = {row.get("project_uid") for row in rows if row.get("project_uid")}
|
||||||
if "projects" in db.tables:
|
projects = get_projects_by_uids(list(project_uids)) if project_uids else {}
|
||||||
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
|
|
||||||
decorated = []
|
decorated = []
|
||||||
for row in rows:
|
for row in rows:
|
||||||
view = provision.view(row)
|
view = provision.view(row)
|
||||||
|
|||||||
@@ -22,12 +22,6 @@ async def token(
|
|||||||
request: Request,
|
request: Request,
|
||||||
data: Annotated[LoginForm, Depends(json_or_form(LoginForm))],
|
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()
|
identifier = data.email.strip().lower()
|
||||||
password = data.password
|
password = data.password
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ from devplacepy.dependencies import json_or_form
|
|||||||
from devplacepy.models import WarJoinForm
|
from devplacepy.models import WarJoinForm
|
||||||
from devplacepy.responses import action_result, json_error, respond, wants_json
|
from devplacepy.responses import action_result, json_error, respond, wants_json
|
||||||
from devplacepy.schemas import BattlesOut, WarEventsOut, WarOut
|
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.services.opinionwar import WarError, rules, store
|
||||||
from devplacepy.utils import get_current_user, not_found, require_user
|
from devplacepy.utils import get_current_user, not_found, require_user
|
||||||
|
|
||||||
@@ -43,6 +43,7 @@ async def battles_page(
|
|||||||
search=search,
|
search=search,
|
||||||
page=max(1, page),
|
page=max(1, page),
|
||||||
)
|
)
|
||||||
|
base = site_url(request)
|
||||||
seo_ctx = base_seo_context(
|
seo_ctx = base_seo_context(
|
||||||
request,
|
request,
|
||||||
title="Opinion Wars",
|
title="Opinion Wars",
|
||||||
@@ -54,6 +55,7 @@ async def battles_page(
|
|||||||
{"name": "Home", "url": "/feed"},
|
{"name": "Home", "url": "/feed"},
|
||||||
{"name": "Battles", "url": "/battles"},
|
{"name": "Battles", "url": "/battles"},
|
||||||
],
|
],
|
||||||
|
schemas=[website_schema(base)],
|
||||||
)
|
)
|
||||||
return respond(
|
return respond(
|
||||||
request,
|
request,
|
||||||
|
|||||||
@@ -137,8 +137,6 @@ def _validate_target(value: str) -> str:
|
|||||||
|
|
||||||
@router.get("/adopt")
|
@router.get("/adopt")
|
||||||
async def devii_adopt(request: Request):
|
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", "/"))
|
target = _validate_target(request.query_params.get("next", "/"))
|
||||||
response = RedirectResponse(target, status_code=303)
|
response = RedirectResponse(target, status_code=303)
|
||||||
svc = _service()
|
svc = _service()
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ The `endpoint()` factory derives `min_role` from `auth` (`public` -> Public, `us
|
|||||||
|
|
||||||
## Admin-only pages
|
## 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
|
## Dynamic Background Services page
|
||||||
|
|
||||||
|
|||||||
@@ -44,7 +44,6 @@ AUDIENCES = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
DOCS_PAGES = [
|
DOCS_PAGES = [
|
||||||
# General - how to use the site and Devii (everyone)
|
|
||||||
{"slug": "index", "title": "Overview", "kind": "prose", "section": SECTION_GENERAL},
|
{"slug": "index", "title": "Overview", "kind": "prose", "section": SECTION_GENERAL},
|
||||||
{
|
{
|
||||||
"slug": "getting-started",
|
"slug": "getting-started",
|
||||||
@@ -160,7 +159,6 @@ DOCS_PAGES = [
|
|||||||
"kind": "prose",
|
"kind": "prose",
|
||||||
"section": SECTION_GENERAL,
|
"section": SECTION_GENERAL,
|
||||||
},
|
},
|
||||||
# Legal - the policies the platform is operated under (everyone)
|
|
||||||
{
|
{
|
||||||
"slug": "terms",
|
"slug": "terms",
|
||||||
"title": "Terms of Service",
|
"title": "Terms of Service",
|
||||||
@@ -204,7 +202,6 @@ DOCS_PAGES = [
|
|||||||
"section": SECTION_LEGAL,
|
"section": SECTION_LEGAL,
|
||||||
"admin": True,
|
"admin": True,
|
||||||
},
|
},
|
||||||
# Tools - public developer tools (everyone)
|
|
||||||
{
|
{
|
||||||
"slug": "tools-seo",
|
"slug": "tools-seo",
|
||||||
"title": "SEO Diagnostics",
|
"title": "SEO Diagnostics",
|
||||||
@@ -229,7 +226,6 @@ DOCS_PAGES = [
|
|||||||
"kind": "prose",
|
"kind": "prose",
|
||||||
"section": SECTION_TOOLS,
|
"section": SECTION_TOOLS,
|
||||||
},
|
},
|
||||||
# Claude Code - the native subagent, command, and workflow setup under .claude/
|
|
||||||
{
|
{
|
||||||
"slug": "claude",
|
"slug": "claude",
|
||||||
"title": "Claude Code setup",
|
"title": "Claude Code setup",
|
||||||
@@ -260,7 +256,6 @@ DOCS_PAGES = [
|
|||||||
"kind": "prose",
|
"kind": "prose",
|
||||||
"section": SECTION_CLAUDE,
|
"section": SECTION_CLAUDE,
|
||||||
},
|
},
|
||||||
# Components - custom HTML web components with live examples (everyone)
|
|
||||||
{
|
{
|
||||||
"slug": "components",
|
"slug": "components",
|
||||||
"title": "Components overview",
|
"title": "Components overview",
|
||||||
@@ -339,7 +334,6 @@ DOCS_PAGES = [
|
|||||||
"kind": "prose",
|
"kind": "prose",
|
||||||
"section": SECTION_COMPONENTS,
|
"section": SECTION_COMPONENTS,
|
||||||
},
|
},
|
||||||
# Styles - the design system: colors, layout, responsiveness, and hard structural rules (everyone)
|
|
||||||
{
|
{
|
||||||
"slug": "styles",
|
"slug": "styles",
|
||||||
"title": "Design system overview",
|
"title": "Design system overview",
|
||||||
@@ -370,7 +364,6 @@ DOCS_PAGES = [
|
|||||||
"kind": "prose",
|
"kind": "prose",
|
||||||
"section": SECTION_STYLES,
|
"section": SECTION_STYLES,
|
||||||
},
|
},
|
||||||
# API - developer reference (everyone); admin-only groups are routed to Administration below
|
|
||||||
{
|
{
|
||||||
"slug": "authentication",
|
"slug": "authentication",
|
||||||
"title": "Authentication",
|
"title": "Authentication",
|
||||||
@@ -383,7 +376,6 @@ DOCS_PAGES = [
|
|||||||
"kind": "prose",
|
"kind": "prose",
|
||||||
"section": SECTION_API,
|
"section": SECTION_API,
|
||||||
},
|
},
|
||||||
# devRant API - legacy-compatible protocol, spread over focused pages
|
|
||||||
{
|
{
|
||||||
"slug": "devrant",
|
"slug": "devrant",
|
||||||
"title": "Overview",
|
"title": "Overview",
|
||||||
@@ -430,7 +422,6 @@ DOCS_PAGES = [
|
|||||||
{**page, "section": (SECTION_ADMIN if page.get("admin") else SECTION_API)}
|
{**page, "section": (SECTION_ADMIN if page.get("admin") else SECTION_API)}
|
||||||
for page in api_doc_pages()
|
for page in api_doc_pages()
|
||||||
],
|
],
|
||||||
# Administration - operational guides (admins only)
|
|
||||||
{
|
{
|
||||||
"slug": "devii-admin",
|
"slug": "devii-admin",
|
||||||
"title": "Devii for admins",
|
"title": "Devii for admins",
|
||||||
@@ -473,7 +464,6 @@ DOCS_PAGES = [
|
|||||||
"admin": True,
|
"admin": True,
|
||||||
"section": SECTION_ADMIN,
|
"section": SECTION_ADMIN,
|
||||||
},
|
},
|
||||||
# Devii internals - technical reference for the Devii assistant (admins only)
|
|
||||||
{
|
{
|
||||||
"slug": "audit-log",
|
"slug": "audit-log",
|
||||||
"title": "Audit Log",
|
"title": "Audit Log",
|
||||||
@@ -523,7 +513,6 @@ DOCS_PAGES = [
|
|||||||
"admin": True,
|
"admin": True,
|
||||||
"section": SECTION_DEVII,
|
"section": SECTION_DEVII,
|
||||||
},
|
},
|
||||||
# Bots internals - deep technical reference for the autonomous bot fleet (admins only)
|
|
||||||
{
|
{
|
||||||
"slug": "bots-internals",
|
"slug": "bots-internals",
|
||||||
"title": "Bots internals",
|
"title": "Bots internals",
|
||||||
@@ -573,7 +562,6 @@ DOCS_PAGES = [
|
|||||||
"admin": True,
|
"admin": True,
|
||||||
"section": SECTION_BOTS,
|
"section": SECTION_BOTS,
|
||||||
},
|
},
|
||||||
# Services - the background service framework and every service in detail (admins only)
|
|
||||||
{
|
{
|
||||||
"slug": "services-overview",
|
"slug": "services-overview",
|
||||||
"title": "Services overview",
|
"title": "Services overview",
|
||||||
@@ -651,7 +639,6 @@ DOCS_PAGES = [
|
|||||||
"admin": True,
|
"admin": True,
|
||||||
"section": SECTION_SERVICES,
|
"section": SECTION_SERVICES,
|
||||||
},
|
},
|
||||||
# Architecture - platform design, structure, and development process (admins only)
|
|
||||||
{
|
{
|
||||||
"slug": "architecture",
|
"slug": "architecture",
|
||||||
"title": "Architecture overview",
|
"title": "Architecture overview",
|
||||||
@@ -701,7 +688,6 @@ DOCS_PAGES = [
|
|||||||
"admin": True,
|
"admin": True,
|
||||||
"section": SECTION_ARCH,
|
"section": SECTION_ARCH,
|
||||||
},
|
},
|
||||||
# Testing - test framework, load testing, and make targets (admins only)
|
|
||||||
{
|
{
|
||||||
"slug": "testing",
|
"slug": "testing",
|
||||||
"title": "Testing overview",
|
"title": "Testing overview",
|
||||||
@@ -737,7 +723,6 @@ DOCS_PAGES = [
|
|||||||
"admin": True,
|
"admin": True,
|
||||||
"section": SECTION_TESTING,
|
"section": SECTION_TESTING,
|
||||||
},
|
},
|
||||||
# Production - deployment and operations reference (admins only)
|
|
||||||
{
|
{
|
||||||
"slug": "production",
|
"slug": "production",
|
||||||
"title": "Production overview",
|
"title": "Production overview",
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ from devplacepy.models import GameSlotForm
|
|||||||
from devplacepy.responses import respond, wants_json
|
from devplacepy.responses import respond, wants_json
|
||||||
from devplacepy.schemas import GameFarmViewOut
|
from devplacepy.schemas import GameFarmViewOut
|
||||||
from devplacepy.services.game import GameError, store
|
from devplacepy.services.game import GameError, store
|
||||||
|
from devplacepy.services.audit import record as audit
|
||||||
from devplacepy.utils import (
|
from devplacepy.utils import (
|
||||||
create_notification,
|
create_notification,
|
||||||
get_current_user,
|
get_current_user,
|
||||||
@@ -58,10 +59,24 @@ async def water_farm(
|
|||||||
if not owner:
|
if not owner:
|
||||||
raise HTTPException(status_code=404, detail="Farm not found")
|
raise HTTPException(status_code=404, detail="Farm not found")
|
||||||
try:
|
try:
|
||||||
store.water(viewer, owner, data.slot)
|
result = store.water(viewer, owner, data.slot)
|
||||||
except GameError as exc:
|
except GameError as exc:
|
||||||
return action_error(request, str(exc), f"/game/farm/{username}")
|
return action_error(request, str(exc), f"/game/farm/{username}")
|
||||||
track_action(viewer["uid"], "water")
|
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"])
|
await notify_farm(owner["username"])
|
||||||
if wants_json(request):
|
if wants_json(request):
|
||||||
payload = store.serialize_farm(
|
payload = store.serialize_farm(
|
||||||
@@ -87,6 +102,31 @@ async def steal_farm(
|
|||||||
track_action(owner["uid"], "got_stolen_from")
|
track_action(owner["uid"], "got_stolen_from")
|
||||||
if result.get("underdog_triggered"):
|
if result.get("underdog_triggered"):
|
||||||
track_action(viewer["uid"], "underdog_raid")
|
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(
|
create_notification(
|
||||||
owner["uid"],
|
owner["uid"],
|
||||||
"harvest_stolen",
|
"harvest_stolen",
|
||||||
|
|||||||
@@ -80,8 +80,21 @@ async def _respond_action(request: Request, user: dict, fn, on_success=None):
|
|||||||
@router.post("/plant")
|
@router.post("/plant")
|
||||||
async def game_plant(request: Request, data: Annotated[GamePlantForm, Form()]):
|
async def game_plant(request: Request, data: Annotated[GamePlantForm, Form()]):
|
||||||
user = require_user(request)
|
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(
|
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):
|
def reward(result):
|
||||||
track_action(user["uid"], "harvest")
|
track_action(user["uid"], "harvest")
|
||||||
award_rewards(user["uid"], economy.site_xp_for(result.get("xp", 0)))
|
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(
|
return await _respond_action(
|
||||||
request, user, lambda: store.harvest(user, data.slot), reward
|
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")
|
@router.post("/buy-plot")
|
||||||
async def game_buy_plot(request: Request):
|
async def game_buy_plot(request: Request):
|
||||||
user = require_user(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")
|
@router.post("/upgrade")
|
||||||
async def game_upgrade(request: Request):
|
async def game_upgrade(request: Request):
|
||||||
user = require_user(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")
|
@router.post("/fertilize")
|
||||||
async def game_fertilize(request: Request, data: Annotated[GameSlotForm, Form()]):
|
async def game_fertilize(request: Request, data: Annotated[GameSlotForm, Form()]):
|
||||||
user = require_user(request)
|
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")
|
@router.post("/daily")
|
||||||
async def game_daily(request: Request):
|
async def game_daily(request: Request):
|
||||||
user = require_user(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")
|
@router.post("/grant")
|
||||||
@@ -141,8 +218,21 @@ async def game_claim_grant(request: Request):
|
|||||||
@router.post("/perk")
|
@router.post("/perk")
|
||||||
async def game_perk(request: Request, data: Annotated[GamePerkForm, Form()]):
|
async def game_perk(request: Request, data: Annotated[GamePerkForm, Form()]):
|
||||||
user = require_user(request)
|
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(
|
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")
|
@router.post("/legacy")
|
||||||
async def game_legacy(request: Request, data: Annotated[GameLegacyForm, Form()]):
|
async def game_legacy(request: Request, data: Annotated[GameLegacyForm, Form()]):
|
||||||
user = require_user(request)
|
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(
|
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):
|
def reward(result):
|
||||||
award_rewards(user["uid"], economy.site_xp_for(result.get("reward_xp", 0)))
|
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(
|
return await _respond_action(
|
||||||
request, user, lambda: store.claim_quest(user, data.quest, data.scope), reward
|
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")
|
@router.post("/mastery")
|
||||||
async def game_upgrade_mastery(request: Request, data: Annotated[GameMasteryForm, Form()]):
|
async def game_upgrade_mastery(request: Request, data: Annotated[GameMasteryForm, Form()]):
|
||||||
user = require_user(request)
|
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(
|
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")
|
@router.post("/cosmetics/equip")
|
||||||
async def game_equip_cosmetic(request: Request, data: Annotated[GameCosmeticForm, Form()]):
|
async def game_equip_cosmetic(request: Request, data: Annotated[GameCosmeticForm, Form()]):
|
||||||
user = require_user(request)
|
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(
|
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 (
|
from devplacepy.attachments import (
|
||||||
get_attachments,
|
get_attachments,
|
||||||
|
get_orphan_attachments_batch,
|
||||||
link_attachments,
|
link_attachments,
|
||||||
mirror_attachment_to_gitea,
|
mirror_attachment_to_gitea,
|
||||||
remove_gitea_asset,
|
remove_gitea_asset,
|
||||||
soft_delete_attachment,
|
soft_delete_attachment,
|
||||||
|
split_attachment_uids,
|
||||||
)
|
)
|
||||||
from devplacepy.database import get_table
|
from devplacepy.database import get_table
|
||||||
from devplacepy.dependencies import json_or_form
|
from devplacepy.dependencies import json_or_form
|
||||||
@@ -28,10 +30,6 @@ logger = logging.getLogger(__name__)
|
|||||||
router = APIRouter()
|
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:
|
def _can_modify(att: dict, user: dict | None, is_open: bool) -> bool:
|
||||||
if not user or not is_open:
|
if not user or not is_open:
|
||||||
return False
|
return False
|
||||||
@@ -47,21 +45,6 @@ def _payload(rows: list[dict], user: dict | None, is_open: bool) -> list[dict]:
|
|||||||
return items
|
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:
|
async def _load_issue(number: int) -> dict:
|
||||||
try:
|
try:
|
||||||
return await runtime.get_client().get_issue(number)
|
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}",
|
summary=f"{user['username']} tried to attach to closed issue #{number}",
|
||||||
)
|
)
|
||||||
return json_error(409, "Attachments cannot be changed on a closed issue")
|
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:
|
if not owned:
|
||||||
return json_error(400, "No valid attachments to add")
|
return json_error(400, "No valid attachments to add")
|
||||||
link_attachments(owned, "issue", str(number))
|
link_attachments(owned, "issue", str(number))
|
||||||
@@ -202,7 +187,9 @@ async def add_comment_attachment(
|
|||||||
issue = await _load_issue(number)
|
issue = await _load_issue(number)
|
||||||
if issue.get("state") != STATE_OPEN:
|
if issue.get("state") != STATE_OPEN:
|
||||||
return json_error(409, "Attachments cannot be changed on a closed issue")
|
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:
|
if not owned:
|
||||||
return json_error(400, "No valid attachments to add")
|
return json_error(400, "No valid attachments to add")
|
||||||
link_attachments(owned, "issue_comment", str(cid))
|
link_attachments(owned, "issue_comment", str(cid))
|
||||||
|
|||||||
@@ -5,7 +5,12 @@ from typing import Annotated
|
|||||||
|
|
||||||
from fastapi import Depends, APIRouter, Request
|
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.database import get_table
|
||||||
from devplacepy.models import IssueCommentForm
|
from devplacepy.models import IssueCommentForm
|
||||||
from devplacepy.responses import action_result, json_error
|
from devplacepy.responses import action_result, json_error
|
||||||
@@ -21,18 +26,6 @@ logger = logging.getLogger(__name__)
|
|||||||
router = APIRouter()
|
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:
|
def _notify_admins(actor: dict, number: int) -> None:
|
||||||
for admin in get_table("users").find(role="Admin"):
|
for admin in get_table("users").find(role="Admin"):
|
||||||
if admin["uid"] == actor["uid"]:
|
if admin["uid"] == actor["uid"]:
|
||||||
@@ -74,7 +67,9 @@ async def comment_issue(
|
|||||||
|
|
||||||
comment_id = int(comment.get("id", 0))
|
comment_id = int(comment.get("id", 0))
|
||||||
store.record_comment_author(comment_id, number, user["uid"])
|
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:
|
if owned:
|
||||||
link_attachments(owned, "issue_comment", str(comment_id))
|
link_attachments(owned, "issue_comment", str(comment_id))
|
||||||
for uid in owned:
|
for uid in owned:
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ from typing import Annotated
|
|||||||
from fastapi import Depends, APIRouter, Request
|
from fastapi import Depends, APIRouter, Request
|
||||||
from fastapi.responses import JSONResponse
|
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.models import IssueForm
|
||||||
from devplacepy.responses import json_error
|
from devplacepy.responses import json_error
|
||||||
from devplacepy.schemas import IssueJobOut
|
from devplacepy.schemas import IssueJobOut
|
||||||
@@ -20,19 +20,6 @@ logger = logging.getLogger(__name__)
|
|||||||
router = APIRouter()
|
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")
|
@router.post("/create")
|
||||||
async def create_issue(request: Request, data: Annotated[IssueForm, Depends(json_or_form(IssueForm))]):
|
async def create_issue(request: Request, data: Annotated[IssueForm, Depends(json_or_form(IssueForm))]):
|
||||||
user = require_user(request)
|
user = require_user(request)
|
||||||
@@ -45,7 +32,9 @@ async def create_issue(request: Request, data: Annotated[IssueForm, Depends(json
|
|||||||
"author_uid": user["uid"],
|
"author_uid": user["uid"],
|
||||||
"title": title,
|
"title": title,
|
||||||
"description": data.description.strip(),
|
"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",
|
||||||
user["uid"],
|
user["uid"],
|
||||||
|
|||||||
@@ -83,8 +83,6 @@ async def containers_json(request: Request, project_slug: str):
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
# ---------------- instances ----------------
|
|
||||||
|
|
||||||
@router.post("/{project_slug}/containers/instances")
|
@router.post("/{project_slug}/containers/instances")
|
||||||
async def create_instance(
|
async def create_instance(
|
||||||
request: Request, project_slug: str, data: Annotated[ContainerInstanceForm, Depends(json_or_form(ContainerInstanceForm))]
|
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", 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/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/deepsearch", changefreq="monthly", priority="0.5"))
|
||||||
|
urlset.append(url_element(f"{base_url}/tools/isslop", changefreq="monthly", priority="0.5"))
|
||||||
urlset.append(
|
urlset.append(
|
||||||
url_element(f"{base_url}/workspaces/index", changefreq="daily", priority="0.6")
|
url_element(f"{base_url}/workspaces/index", changefreq="daily", priority="0.6")
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -16,11 +16,6 @@ def issue_token(
|
|||||||
label: str = "",
|
label: str = "",
|
||||||
max_age_days: Optional[int] = None,
|
max_age_days: Optional[int] = None,
|
||||||
) -> dict:
|
) -> 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:
|
if max_age_days is None:
|
||||||
max_age_days = max(1, get_int_setting("session_max_age_days", 7))
|
max_age_days = max(1, get_int_setting("session_max_age_days", 7))
|
||||||
max_age_seconds = max_age_days * SECONDS_PER_DAY
|
max_age_seconds = max_age_days * SECONDS_PER_DAY
|
||||||
@@ -54,11 +49,6 @@ def issue_token(
|
|||||||
|
|
||||||
|
|
||||||
def resolve_token(token: str) -> Optional[dict]:
|
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:
|
if not token:
|
||||||
return None
|
return None
|
||||||
row = get_table("access_tokens").find_one(token=token, deleted_at=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:
|
def revoke_token(uid: str) -> bool:
|
||||||
"""Soft-delete a single access token by its uid. Returns ``True`` on success."""
|
|
||||||
tokens = get_table("access_tokens")
|
tokens = get_table("access_tokens")
|
||||||
row = tokens.find_one(uid=uid, deleted_at=None)
|
row = tokens.find_one(uid=uid, deleted_at=None)
|
||||||
if not row:
|
if not row:
|
||||||
@@ -97,7 +86,6 @@ def revoke_token(uid: str) -> bool:
|
|||||||
|
|
||||||
|
|
||||||
def revoke_all(user_uid: str) -> int:
|
def revoke_all(user_uid: str) -> int:
|
||||||
"""Soft-delete every access token for *user_uid*. Returns the count revoked."""
|
|
||||||
tokens = get_table("access_tokens")
|
tokens = get_table("access_tokens")
|
||||||
stamp = datetime.now(timezone.utc).isoformat()
|
stamp = datetime.now(timezone.utc).isoformat()
|
||||||
count = 0
|
count = 0
|
||||||
@@ -112,7 +100,6 @@ def revoke_all(user_uid: str) -> int:
|
|||||||
|
|
||||||
|
|
||||||
def prune_expired() -> int:
|
def prune_expired() -> int:
|
||||||
"""Soft-delete all expired access tokens. Returns the count pruned."""
|
|
||||||
tokens = get_table("access_tokens")
|
tokens = get_table("access_tokens")
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
stamp = now.isoformat()
|
stamp = now.isoformat()
|
||||||
|
|||||||
@@ -179,9 +179,6 @@ def parse_env(value) -> dict:
|
|||||||
return env
|
return env
|
||||||
|
|
||||||
|
|
||||||
# ---------------- instances ----------------
|
|
||||||
|
|
||||||
|
|
||||||
def validate_ingress(slug: str, port, port_list) -> tuple:
|
def validate_ingress(slug: str, port, port_list) -> tuple:
|
||||||
slug = (slug or "").strip().lower()
|
slug = (slug or "").strip().lower()
|
||||||
if not slug:
|
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))
|
return store.create_schedule(instance, action, schedule.columns(), to_iso(first))
|
||||||
|
|
||||||
|
|
||||||
# ---------------- aggregation ----------------
|
|
||||||
|
|
||||||
|
|
||||||
def _percentile(values: list, pct: float) -> float:
|
def _percentile(values: list, pct: float) -> float:
|
||||||
if not values:
|
if not values:
|
||||||
return 0.0
|
return 0.0
|
||||||
|
|||||||
@@ -32,9 +32,6 @@ def _exists(table: str) -> bool:
|
|||||||
return table in db.tables
|
return table in db.tables
|
||||||
|
|
||||||
|
|
||||||
# ---------------- instances ----------------
|
|
||||||
|
|
||||||
|
|
||||||
def create_instance(row: dict) -> dict:
|
def create_instance(row: dict) -> dict:
|
||||||
uid = generate_uid()
|
uid = generate_uid()
|
||||||
base = {
|
base = {
|
||||||
@@ -138,9 +135,6 @@ def delete_instance(uid: str, deleted_by: str = "system") -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
# ---------------- events / metrics / schedules ----------------
|
|
||||||
|
|
||||||
|
|
||||||
def record_event(
|
def record_event(
|
||||||
instance: dict, event: str, actor_kind: str, actor_id: str, detail: dict = None
|
instance: dict, event: str, actor_kind: str, actor_id: str, detail: dict = None
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|||||||
@@ -49,7 +49,6 @@ class VectorStore:
|
|||||||
self._dims: int | None = None
|
self._dims: int | None = None
|
||||||
|
|
||||||
async def _run_sync(self, func, *args, timeout: float = CHROMADB_TIMEOUT):
|
async def _run_sync(self, func, *args, timeout: float = CHROMADB_TIMEOUT):
|
||||||
"""Run a synchronous ChromaDB call in a thread executor with a timeout."""
|
|
||||||
try:
|
try:
|
||||||
return await asyncio.wait_for(
|
return await asyncio.wait_for(
|
||||||
asyncio.to_thread(func, *args), timeout=timeout
|
asyncio.to_thread(func, *args), timeout=timeout
|
||||||
|
|||||||
@@ -231,6 +231,30 @@ ADMIN_ACTIONS: tuple[Action, ...] = (
|
|||||||
params=(path("uid", "News uid."), confirm()),
|
params=(path("uid", "News uid."), confirm()),
|
||||||
requires_admin=True,
|
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(
|
Action(
|
||||||
name="admin_list_services",
|
name="admin_list_services",
|
||||||
method="GET",
|
method="GET",
|
||||||
@@ -432,6 +456,47 @@ ADMIN_ACTIONS: tuple[Action, ...] = (
|
|||||||
),
|
),
|
||||||
requires_admin=True,
|
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(
|
Action(
|
||||||
name="backup_schedule_delete",
|
name="backup_schedule_delete",
|
||||||
method="POST",
|
method="POST",
|
||||||
@@ -444,6 +509,35 @@ ADMIN_ACTIONS: tuple[Action, ...] = (
|
|||||||
params=(path("uid", "Backup schedule uid."), confirm()),
|
params=(path("uid", "Backup schedule uid."), confirm()),
|
||||||
requires_admin=True,
|
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(
|
Action(
|
||||||
name="restore_media",
|
name="restore_media",
|
||||||
method="POST",
|
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 "
|
"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 "
|
"every open ticket. Each ticket's full description is reproduced verbatim, both "
|
||||||
"inline and in a 'Source Tickets (verbatim)' appendix, so the document is "
|
"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 "
|
"self-contained. Poll with planning_status until status is 'done', then show the "
|
||||||
"and the download_url. Admin only."
|
"user the markdown and the download_url (fetch the file itself with "
|
||||||
|
"planning_download if asked). Admin only."
|
||||||
),
|
),
|
||||||
params=(
|
params=(
|
||||||
body(
|
body(
|
||||||
@@ -56,6 +57,33 @@ ISSUE_ACTIONS: tuple[Action, ...] = (
|
|||||||
requires_auth=True,
|
requires_auth=True,
|
||||||
requires_admin=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(
|
Action(
|
||||||
name="issue_job_status",
|
name="issue_job_status",
|
||||||
method="GET",
|
method="GET",
|
||||||
|
|||||||
@@ -108,6 +108,30 @@ TOOLS_ACTIONS: tuple[Action, ...] = (
|
|||||||
params=(path("uid", "DeepSearch job uid returned by deepsearch."),),
|
params=(path("uid", "DeepSearch job uid returned by deepsearch."),),
|
||||||
requires_auth=False,
|
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(
|
Action(
|
||||||
name="isslop",
|
name="isslop",
|
||||||
method="POST",
|
method="POST",
|
||||||
@@ -122,7 +146,7 @@ TOOLS_ACTIONS: tuple[Action, ...] = (
|
|||||||
params=(
|
params=(
|
||||||
body("url", "Repository or website URL to classify.", required=True),
|
body("url", "Repository or website URL to classify.", required=True),
|
||||||
),
|
),
|
||||||
requires_auth=True,
|
requires_auth=False,
|
||||||
),
|
),
|
||||||
Action(
|
Action(
|
||||||
name="isslop_status",
|
name="isslop_status",
|
||||||
@@ -135,7 +159,7 @@ TOOLS_ACTIONS: tuple[Action, ...] = (
|
|||||||
"'running', poll again shortly."
|
"'running', poll again shortly."
|
||||||
),
|
),
|
||||||
params=(path("uid", "Analysis uid returned by isslop."),),
|
params=(path("uid", "Analysis uid returned by isslop."),),
|
||||||
requires_auth=True,
|
requires_auth=False,
|
||||||
),
|
),
|
||||||
Action(
|
Action(
|
||||||
name="isslop_report",
|
name="isslop_report",
|
||||||
@@ -148,7 +172,7 @@ TOOLS_ACTIONS: tuple[Action, ...] = (
|
|||||||
"embeddable badge snippets. Use it after isslop_status reports status 'completed'."
|
"embeddable badge snippets. Use it after isslop_status reports status 'completed'."
|
||||||
),
|
),
|
||||||
params=(path("uid", "Analysis uid returned by isslop."),),
|
params=(path("uid", "Analysis uid returned by isslop."),),
|
||||||
requires_auth=True,
|
requires_auth=False,
|
||||||
),
|
),
|
||||||
Action(
|
Action(
|
||||||
name="isslop_list",
|
name="isslop_list",
|
||||||
@@ -156,10 +180,10 @@ TOOLS_ACTIONS: tuple[Action, ...] = (
|
|||||||
path="/tools/isslop/list",
|
path="/tools/isslop/list",
|
||||||
summary="List the user's AI usage analyses",
|
summary="List the user's AI usage analyses",
|
||||||
description=(
|
description=(
|
||||||
"Returns the signed-in user's analysis history, newest first, each with its grade, "
|
"Returns the signed-in user's (or guest's) analysis history, newest first, each "
|
||||||
"category, status and report_url."
|
"with its grade, category, status and report_url."
|
||||||
),
|
),
|
||||||
params=(),
|
params=(),
|
||||||
requires_auth=True,
|
requires_auth=False,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -84,6 +84,7 @@ CONFIRM_REQUIRED = {
|
|||||||
"publish_quiz",
|
"publish_quiz",
|
||||||
"delete_quiz",
|
"delete_quiz",
|
||||||
"delete_quiz_question",
|
"delete_quiz_question",
|
||||||
|
"admin_devii_task_delete",
|
||||||
}
|
}
|
||||||
|
|
||||||
CONDITIONAL_CONFIRM = {
|
CONDITIONAL_CONFIRM = {
|
||||||
|
|||||||
@@ -14,7 +14,12 @@ from urllib.parse import urlparse
|
|||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
from devplacepy import stealth
|
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 ..config import Settings
|
||||||
from ..errors import NetworkError, ToolInputError, UpstreamError
|
from ..errors import NetworkError, ToolInputError, UpstreamError
|
||||||
from ..text import html_to_text
|
from ..text import html_to_text
|
||||||
@@ -238,8 +243,13 @@ class FetchController:
|
|||||||
response_headers = {key: value for key, value in response.headers.items()}
|
response_headers = {key: value for key, value in response.headers.items()}
|
||||||
return body, str(response.url), content_type, response.status_code, response_headers
|
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:
|
try:
|
||||||
async with stealth.stealth_async_client(
|
async with client_factory(
|
||||||
headers=merged_headers,
|
headers=merged_headers,
|
||||||
follow_redirects=True,
|
follow_redirects=True,
|
||||||
timeout=self._settings.fetch_timeout_seconds,
|
timeout=self._settings.fetch_timeout_seconds,
|
||||||
@@ -256,5 +266,9 @@ class FetchController:
|
|||||||
return result
|
return result
|
||||||
except httpx.TimeoutException as exc:
|
except httpx.TimeoutException as exc:
|
||||||
raise NetworkError(f"Request timed out fetching {url}", url=url) from 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:
|
except httpx.HTTPError as exc:
|
||||||
raise NetworkError(f"Could not fetch {url}: {exc}", url=url) from 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
|
_user_key_resolver(owner_id) if owner_kind == "user" else None
|
||||||
)
|
)
|
||||||
llm = LLMClient(settings, key_resolver=key_resolver)
|
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 = (
|
owned_db = (
|
||||||
db
|
db
|
||||||
if (owner_kind == "user" and channel in ("main", "telegram"))
|
if (owner_kind == "user" and channel in ("main", "telegram"))
|
||||||
|
|||||||
@@ -790,9 +790,6 @@ class DeviiSession:
|
|||||||
asyncio.create_task(self._emit(payload, buffer=False))
|
asyncio.create_task(self._emit(payload, buffer=False))
|
||||||
|
|
||||||
def _sync_auth(self, name: str) -> None:
|
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"):
|
if name in ("login", "signup"):
|
||||||
token = self.client.session_cookie()
|
token = self.client.session_cookie()
|
||||||
if token:
|
if token:
|
||||||
@@ -1011,8 +1008,6 @@ class DeviiSession:
|
|||||||
)
|
)
|
||||||
finally:
|
finally:
|
||||||
drop.cancel()
|
drop.cancel()
|
||||||
# future done -> loop exits and returns; disconnected -> re-send after
|
|
||||||
# reconnect; otherwise the deadline check at the top raises.
|
|
||||||
return future.result()
|
return future.result()
|
||||||
finally:
|
finally:
|
||||||
self._pending.pop(request_id, None)
|
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]:
|
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 devRant tokens also work as Bearer / X-API-KEY credentials on every DevPlace endpoint.
|
||||||
|
|
||||||
Used by the main DevPlace auth chain so that DevRant tokens work as
|
|
||||||
Bearer / X-API-KEY credentials on every DevPlace endpoint.
|
|
||||||
"""
|
|
||||||
if not key:
|
if not key:
|
||||||
return None
|
return None
|
||||||
token = get_table("devrant_tokens").find_one(key=key, deleted_at=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
|
import httpx
|
||||||
|
|
||||||
from devplacepy.net_guard import BlockedAddressError, guard_public_url
|
from devplacepy.net_guard import BlockedAddressError, guard_public_url, guarded_async_client
|
||||||
from devplacepy.stealth import stealth_async_client
|
|
||||||
from devplacepy.services.jobs.isslop.acquisition.domcapture import DomSnapshot
|
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.acquisition.workspace import safe_relative_path
|
||||||
from devplacepy.services.jobs.isslop.config import (
|
from devplacepy.services.jobs.isslop.config import (
|
||||||
@@ -136,7 +135,7 @@ async def crawl_website(
|
|||||||
except RuntimeError as error:
|
except RuntimeError as error:
|
||||||
logger.warning("Stealth browser crawl failed, falling back to HTTP client: %s", 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"
|
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
|
yield progress
|
||||||
|
|
||||||
|
|
||||||
@@ -247,13 +246,15 @@ async def _download_assets(
|
|||||||
yield f"Downloaded {saved} assets concurrently (scripts, styles, sources)"
|
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)
|
root = urlparse(url)
|
||||||
state = CrawlState()
|
state = CrawlState()
|
||||||
queue: list[tuple[str, int]] = [(url, 0)]
|
queue: list[tuple[str, int]] = [(url, 0)]
|
||||||
headers = {"user-agent": USER_AGENT, "accept": "*/*"}
|
headers = {"user-agent": USER_AGENT, "accept": "*/*"}
|
||||||
yield f"Crawling website {url} with HTTP client (max depth {WEBSITE_MAX_DEPTH}, max {WEBSITE_MAX_FILES} files)"
|
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,
|
timeout=WEBSITE_REQUEST_TIMEOUT_SECONDS,
|
||||||
follow_redirects=True,
|
follow_redirects=True,
|
||||||
headers=headers,
|
headers=headers,
|
||||||
@@ -271,8 +272,17 @@ async def _crawl_with_http(url: str, workspace: Path) -> AsyncIterator[str]:
|
|||||||
continue
|
continue
|
||||||
if any(current.lower().startswith(scheme) for scheme in SKIP_SCHEMES):
|
if any(current.lower().startswith(scheme) for scheme in SKIP_SCHEMES):
|
||||||
continue
|
continue
|
||||||
|
try:
|
||||||
|
await guard_public_url(current, allow_private=allow_private)
|
||||||
|
except BlockedAddressError:
|
||||||
|
yield f"Blocked private/local address: {current}"
|
||||||
|
continue
|
||||||
try:
|
try:
|
||||||
response = await client.get(current)
|
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:
|
except httpx.HTTPError as error:
|
||||||
logger.warning("Fetch failed %s: %s", current, error)
|
logger.warning("Fetch failed %s: %s", current, error)
|
||||||
yield f"Fetch failed: {current} ({error})"
|
yield f"Fetch failed: {current} ({error})"
|
||||||
|
|||||||
@@ -38,13 +38,7 @@ def _instantiate_all():
|
|||||||
|
|
||||||
|
|
||||||
def admin_shell_manager() -> ServiceManager:
|
def admin_shell_manager() -> ServiceManager:
|
||||||
"""Read-only ServiceManager holding one instance of every managed service.
|
# 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.
|
||||||
|
|
||||||
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.
|
|
||||||
"""
|
|
||||||
global _SHELL_MANAGER
|
global _SHELL_MANAGER
|
||||||
if _SHELL_MANAGER is None:
|
if _SHELL_MANAGER is None:
|
||||||
manager = ServiceManager()
|
manager = ServiceManager()
|
||||||
|
|||||||
@@ -12,10 +12,7 @@ _LOCK_FD = None
|
|||||||
|
|
||||||
|
|
||||||
def acquire_web_lock(lock_path: Path) -> bool:
|
def acquire_web_lock(lock_path: Path) -> bool:
|
||||||
"""Non-blocking exclusive lock so exactly one `web` worker owns
|
# 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).
|
||||||
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)."""
|
|
||||||
global _LOCK_FD
|
global _LOCK_FD
|
||||||
lock_path.parent.mkdir(parents=True, exist_ok=True)
|
lock_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
fd = open(lock_path, "w")
|
fd = open(lock_path, "w")
|
||||||
|
|||||||
@@ -69,7 +69,7 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.4rem;
|
gap: 0.4rem;
|
||||||
padding: 0.4rem 0.85rem;
|
padding: 0.4rem 0.85rem;
|
||||||
border-radius: var(--radius-sm, 6px);
|
border-radius: var(--radius);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
font-size: 0.8125rem;
|
font-size: 0.8125rem;
|
||||||
|
|||||||
@@ -51,7 +51,7 @@
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
background: var(--bg-card-hover);
|
background: var(--bg-card-hover);
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
font-family: var(--font-mono, monospace);
|
font-family: var(--font-mono);
|
||||||
}
|
}
|
||||||
|
|
||||||
.audit-badge-origin {
|
.audit-badge-origin {
|
||||||
@@ -88,11 +88,11 @@
|
|||||||
|
|
||||||
.audit-result-denied {
|
.audit-result-denied {
|
||||||
background: rgba(234, 179, 8, 0.15);
|
background: rgba(234, 179, 8, 0.15);
|
||||||
color: var(--warning, #eab308);
|
color: var(--warning);
|
||||||
}
|
}
|
||||||
|
|
||||||
.audit-actor-ip {
|
.audit-actor-ip {
|
||||||
font-family: var(--font-mono, monospace);
|
font-family: var(--font-mono);
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
}
|
}
|
||||||
@@ -148,7 +148,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.audit-value-change {
|
.audit-value-change {
|
||||||
font-family: var(--font-mono, monospace);
|
font-family: var(--font-mono);
|
||||||
font-size: 0.8125rem;
|
font-size: 0.8125rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1178,7 +1178,7 @@ body:has(.page-messages) {
|
|||||||
transform: translateY(-150%);
|
transform: translateY(-150%);
|
||||||
padding: 0.75rem 1.25rem;
|
padding: 0.75rem 1.25rem;
|
||||||
background: var(--accent);
|
background: var(--accent);
|
||||||
color: #fff;
|
color: var(--white);
|
||||||
border-radius: 0 0 var(--radius-input) 0;
|
border-radius: 0 0 var(--radius-input) 0;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
@@ -1187,7 +1187,7 @@ body:has(.page-messages) {
|
|||||||
|
|
||||||
.skip-link:focus {
|
.skip-link:focus {
|
||||||
transform: translateY(0);
|
transform: translateY(0);
|
||||||
outline: 2px solid #fff;
|
outline: 2px solid var(--white);
|
||||||
outline-offset: 2px;
|
outline-offset: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -162,7 +162,7 @@
|
|||||||
.cm-form textarea {
|
.cm-form textarea {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 0.5rem 0.65rem;
|
padding: 0.5rem 0.65rem;
|
||||||
background: var(--bg-input, var(--bg-card));
|
background: var(--bg-input);
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
@@ -170,7 +170,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.cm-form textarea {
|
.cm-form textarea {
|
||||||
font-family: var(--font-mono, monospace);
|
font-family: var(--font-mono);
|
||||||
resize: vertical;
|
resize: vertical;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -213,7 +213,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.cm-suggest li.cm-suggest-active {
|
.cm-suggest li.cm-suggest-active {
|
||||||
outline: 2px solid var(--accent, var(--border));
|
outline: 2px solid var(--accent);
|
||||||
outline-offset: -2px;
|
outline-offset: -2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -255,5 +255,5 @@
|
|||||||
.ci-event-time {
|
.ci-event-time {
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
font-family: var(--font-mono, monospace);
|
font-family: var(--font-mono);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,7 +60,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.ds-degraded {
|
.ds-degraded {
|
||||||
border-left: 4px solid var(--warning, #d97706);
|
border-left: 4px solid var(--warning);
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,7 +105,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.ds-form-error {
|
.ds-form-error {
|
||||||
color: var(--danger, #e5484d);
|
color: var(--danger);
|
||||||
margin: var(--space-md) 0 0;
|
margin: var(--space-md) 0 0;
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
}
|
}
|
||||||
@@ -457,7 +457,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.ds-cite {
|
.ds-cite {
|
||||||
color: var(--accent, #2563eb);
|
color: var(--accent);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-size: 0.85em;
|
font-size: 0.85em;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
@@ -482,9 +482,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.ds-sources li:target {
|
.ds-sources li:target {
|
||||||
background: var(--bg-highlight, rgba(37, 99, 235, 0.12));
|
background: var(--accent-light);
|
||||||
border-radius: var(--radius-input);
|
border-radius: var(--radius-input);
|
||||||
outline: 2px solid var(--accent, #2563eb);
|
outline: 2px solid var(--accent);
|
||||||
outline-offset: 2px;
|
outline-offset: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -580,7 +580,7 @@ dp-deepsearch-chat {
|
|||||||
|
|
||||||
.ds-chat-msg.error .ds-chat-bubble {
|
.ds-chat-msg.error .ds-chat-bubble {
|
||||||
background: var(--bg-card-hover);
|
background: var(--bg-card-hover);
|
||||||
color: var(--danger, #e5484d);
|
color: var(--danger);
|
||||||
}
|
}
|
||||||
|
|
||||||
.ds-chat-citations {
|
.ds-chat-citations {
|
||||||
|
|||||||
@@ -104,7 +104,7 @@ dp-docs-chat {
|
|||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
padding: 0.05rem 0.3rem;
|
padding: 0.05rem 0.3rem;
|
||||||
font-family: var(--font-mono, monospace);
|
font-family: var(--font-mono);
|
||||||
font-size: 0.85em;
|
font-size: 0.85em;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -127,7 +127,7 @@ dp-docs-chat {
|
|||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
font-family: var(--font-mono, monospace);
|
font-family: var(--font-mono);
|
||||||
}
|
}
|
||||||
|
|
||||||
.docs-chat-bubble .md-copy {
|
.docs-chat-bubble .md-copy {
|
||||||
|
|||||||
@@ -2,10 +2,10 @@
|
|||||||
|
|
||||||
.docs-layout {
|
.docs-layout {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: var(--sidebar-width, 240px) 1fr;
|
grid-template-columns: var(--sidebar-width) 1fr;
|
||||||
gap: 1.5rem;
|
gap: 1.5rem;
|
||||||
align-items: start;
|
align-items: start;
|
||||||
max-width: var(--max-content, 1200px);
|
max-width: var(--max-content);
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 1.5rem 1rem;
|
padding: 1.5rem 1rem;
|
||||||
}
|
}
|
||||||
@@ -250,7 +250,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.param-allowed-values {
|
.param-allowed-values {
|
||||||
font-family: var(--font-mono, monospace);
|
font-family: var(--font-mono);
|
||||||
}
|
}
|
||||||
|
|
||||||
.param-input {
|
.param-input {
|
||||||
@@ -563,7 +563,7 @@ pre.code-pre > .code-gutter {
|
|||||||
padding: 0.45rem 0.6rem;
|
padding: 0.45rem 0.6rem;
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
background: var(--bg-input, var(--bg-card));
|
background: var(--bg-input);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
--fw-accent: #3fb950;
|
--fw-accent: #3fb950;
|
||||||
--fw-bar-bg: #0b0b0b;
|
--fw-bar-bg: #0b0b0b;
|
||||||
--fw-border: #1c1f24;
|
--fw-border: #1c1f24;
|
||||||
|
--fw-hover-bg: #1b1f25;
|
||||||
--fw-shadow: 0 18px 60px rgba(0, 0, 0, 0.6);
|
--fw-shadow: 0 18px 60px rgba(0, 0, 0, 0.6);
|
||||||
--fw-radius: 10px;
|
--fw-radius: 10px;
|
||||||
--fw-font: "SFMono-Regular", "JetBrains Mono", "Fira Code", Consolas, "Courier New", monospace;
|
--fw-font: "SFMono-Regular", "JetBrains Mono", "Fira Code", Consolas, "Courier New", monospace;
|
||||||
@@ -162,7 +163,7 @@
|
|||||||
|
|
||||||
.fw-host .fw-winctl button:hover {
|
.fw-host .fw-winctl button:hover {
|
||||||
color: var(--fw-fg);
|
color: var(--fw-fg);
|
||||||
background: #1b1f25;
|
background: var(--fw-hover-bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.fw-host .fw-winctl button[data-win="close"]:hover {
|
.fw-host .fw-winctl button[data-win="close"]:hover {
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
/* retoor <retoor@molodetz.nl> */
|
/* retoor <retoor@molodetz.nl> */
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--game-gold-rgb: 245, 197, 24;
|
||||||
|
--game-gold: rgb(var(--game-gold-rgb));
|
||||||
|
}
|
||||||
|
|
||||||
.game-page {
|
.game-page {
|
||||||
max-width: var(--max-content);
|
max-width: var(--max-content);
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
@@ -536,12 +541,12 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.legacy-card {
|
.legacy-card {
|
||||||
border-color: #f5c518;
|
border-color: var(--game-gold);
|
||||||
}
|
}
|
||||||
|
|
||||||
.game-plot-golden {
|
.game-plot-golden {
|
||||||
border-color: #f5c518;
|
border-color: var(--game-gold);
|
||||||
box-shadow: 0 0 0 1px #f5c518 inset, 0 0 12px rgba(245, 197, 24, 0.4);
|
box-shadow: 0 0 0 1px var(--game-gold) inset, 0 0 12px rgba(var(--game-gold-rgb), 0.4);
|
||||||
}
|
}
|
||||||
|
|
||||||
.plot-steal-locked {
|
.plot-steal-locked {
|
||||||
|
|||||||
@@ -43,7 +43,7 @@
|
|||||||
|
|
||||||
.gw-default code,
|
.gw-default code,
|
||||||
.admin-table .gw-code {
|
.admin-table .gw-code {
|
||||||
font-family: var(--font-mono, monospace);
|
font-family: var(--font-mono);
|
||||||
font-size: 0.8125rem;
|
font-size: 0.8125rem;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
word-break: break-all;
|
word-break: break-all;
|
||||||
@@ -125,10 +125,10 @@
|
|||||||
.gw-field select {
|
.gw-field select {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 0.5rem 0.65rem;
|
padding: 0.5rem 0.65rem;
|
||||||
background: var(--bg-input, var(--bg-card));
|
background: var(--bg-input);
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: var(--radius-input, var(--radius));
|
border-radius: var(--radius-input);
|
||||||
font: inherit;
|
font: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -226,15 +226,15 @@
|
|||||||
height: 22px;
|
height: 22px;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: rgba(0, 0, 0, 0.6);
|
background: var(--overlay-dark);
|
||||||
color: #fff;
|
color: var(--white);
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
}
|
}
|
||||||
.attachment-gallery-item .issue-att-delete:hover {
|
.attachment-gallery-item .issue-att-delete:hover {
|
||||||
background: var(--danger, #c0392b);
|
background: var(--danger);
|
||||||
}
|
}
|
||||||
.issue-attach-form {
|
.issue-attach-form {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -191,7 +191,7 @@
|
|||||||
|
|
||||||
@keyframes comment-highlight-fade {
|
@keyframes comment-highlight-fade {
|
||||||
0% {
|
0% {
|
||||||
background-color: var(--accent-soft, rgba(99, 102, 241, 0.18));
|
background-color: var(--accent-light);
|
||||||
}
|
}
|
||||||
100% {
|
100% {
|
||||||
background-color: transparent;
|
background-color: transparent;
|
||||||
|
|||||||
@@ -468,7 +468,7 @@ a.profile-stat-value:hover {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.ai-correction-status[data-state="error"] {
|
.ai-correction-status[data-state="error"] {
|
||||||
color: var(--danger, #e03131);
|
color: var(--danger);
|
||||||
}
|
}
|
||||||
|
|
||||||
.follow-row {
|
.follow-row {
|
||||||
@@ -603,7 +603,7 @@ a.profile-stat-value:hover {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.ai-quota-fill.ai-quota-danger {
|
.ai-quota-fill.ai-quota-danger {
|
||||||
background: var(--danger, #d9534f);
|
background: var(--danger);
|
||||||
}
|
}
|
||||||
|
|
||||||
.ai-quota-meta {
|
.ai-quota-meta {
|
||||||
|
|||||||
@@ -312,7 +312,7 @@
|
|||||||
max-width: 80ch;
|
max-width: 80ch;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
background: var(--surface-2, var(--surface));
|
background: var(--bg-card);
|
||||||
padding: 0.5rem 0.75rem;
|
padding: 0.5rem 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -99,14 +99,14 @@
|
|||||||
|
|
||||||
.statistics-charts {
|
.statistics-charts {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
gap: var(--space-md);
|
gap: var(--space-md);
|
||||||
margin-bottom: var(--space-md);
|
margin-bottom: var(--space-md);
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 768px) {
|
@media (max-width: 767px) {
|
||||||
.statistics-charts {
|
.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}`);
|
const data = await Http.getJson(`/admin/ai-usage/data?hours=${this.hours}`);
|
||||||
this.render(data);
|
this.render(data);
|
||||||
} catch {
|
} catch {
|
||||||
// silently ignore poll errors
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -47,7 +47,6 @@ class BackupMonitor {
|
|||||||
this.targets = data.targets || [];
|
this.targets = data.targets || [];
|
||||||
this.render(data);
|
this.render(data);
|
||||||
} catch {
|
} catch {
|
||||||
// ignore transient poll errors
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,7 +62,6 @@ class BackupMonitor {
|
|||||||
window.app.toast.show("Backup started", { type: "info" });
|
window.app.toast.show("Backup started", { type: "info" });
|
||||||
this.followJob(result.status_url);
|
this.followJob(result.status_url);
|
||||||
} catch {
|
} catch {
|
||||||
// Http.send already surfaced the error
|
|
||||||
} finally {
|
} finally {
|
||||||
button.disabled = false;
|
button.disabled = false;
|
||||||
}
|
}
|
||||||
@@ -103,7 +101,6 @@ class BackupMonitor {
|
|||||||
window.app.toast.show("Schedule saved", { type: "success" });
|
window.app.toast.show("Schedule saved", { type: "success" });
|
||||||
this.poll();
|
this.poll();
|
||||||
} catch {
|
} catch {
|
||||||
// error already surfaced
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -168,7 +165,6 @@ class BackupMonitor {
|
|||||||
await Http.send(urls[action], {});
|
await Http.send(urls[action], {});
|
||||||
this.poll();
|
this.poll();
|
||||||
} catch {
|
} catch {
|
||||||
// error already surfaced
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ export class CodeBlock {
|
|||||||
try {
|
try {
|
||||||
hljs.highlightElement(code);
|
hljs.highlightElement(code);
|
||||||
} catch {
|
} catch {
|
||||||
// leave plain text on failure
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -62,8 +62,6 @@ export class ContainerInstance {
|
|||||||
return span.innerHTML;
|
return span.innerHTML;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------- actions ----------------
|
|
||||||
|
|
||||||
renderActions(status) {
|
renderActions(status) {
|
||||||
if (!this.canManage) {
|
if (!this.canManage) {
|
||||||
const box = this.q("#ci-actions");
|
const box = this.q("#ci-actions");
|
||||||
@@ -107,8 +105,6 @@ export class ContainerInstance {
|
|||||||
} catch (err) { this.toast(err.message, "error"); }
|
} catch (err) { this.toast(err.message, "error"); }
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------- polling ----------------
|
|
||||||
|
|
||||||
startDetailPoll() {
|
startDetailPoll() {
|
||||||
this.detailPoll = new Poller(async () => {
|
this.detailPoll = new Poller(async () => {
|
||||||
const detail = await Http.getJson(`${this.base}/instances/${this.uid}`);
|
const detail = await Http.getJson(`${this.base}/instances/${this.uid}`);
|
||||||
@@ -164,8 +160,6 @@ export class ContainerInstance {
|
|||||||
if (logs) pre.scrollTop = pre.scrollHeight;
|
if (logs) pre.scrollTop = pre.scrollHeight;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------- exec ----------------
|
|
||||||
|
|
||||||
async execOnce() {
|
async execOnce() {
|
||||||
const input = this.q("#ci-exec-input");
|
const input = this.q("#ci-exec-input");
|
||||||
const cmd = input.value.trim();
|
const cmd = input.value.trim();
|
||||||
@@ -203,8 +197,6 @@ export class ContainerInstance {
|
|||||||
.replace(/\x1b[=>]/g, "");
|
.replace(/\x1b[=>]/g, "");
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------- schedules ----------------
|
|
||||||
|
|
||||||
syncScheduleFields(kind) {
|
syncScheduleFields(kind) {
|
||||||
const form = document.getElementById("ci-schedule-form");
|
const form = document.getElementById("ci-schedule-form");
|
||||||
form.querySelectorAll("[data-sched-field]").forEach((field) => {
|
form.querySelectorAll("[data-sched-field]").forEach((field) => {
|
||||||
|
|||||||
@@ -42,7 +42,6 @@ export class DomUtils {
|
|||||||
await navigator.clipboard.writeText(source.textContent);
|
await navigator.clipboard.writeText(source.textContent);
|
||||||
Toast.flash(btn, "Copied!", 2000);
|
Toast.flash(btn, "Copied!", 2000);
|
||||||
} catch {
|
} catch {
|
||||||
// silently fail
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -56,7 +55,6 @@ export class DomUtils {
|
|||||||
await navigator.clipboard.writeText(url);
|
await navigator.clipboard.writeText(url);
|
||||||
Toast.flash(btn, "Copied!", 1000);
|
Toast.flash(btn, "Copied!", 1000);
|
||||||
} catch {
|
} catch {
|
||||||
// silently fail
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -287,10 +287,7 @@ export class GatewayAdmin {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async remove(url) {
|
async remove(url) {
|
||||||
const response = await fetch(url, { method: "DELETE", headers: { Accept: "application/json" } });
|
await Http.sendDelete(url, { silent: true });
|
||||||
if (!response.ok && response.status !== 404) {
|
|
||||||
throw new Error(`Delete failed: ${response.status}`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteProvider(name) {
|
async deleteProvider(name) {
|
||||||
|
|||||||
@@ -105,6 +105,28 @@ export class Http {
|
|||||||
return data;
|
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 = {}) {
|
static async postJson(url, body, options = {}) {
|
||||||
const response = await fetch(url, {
|
const response = await fetch(url, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
|||||||
@@ -46,19 +46,11 @@ export class IssueAttachments {
|
|||||||
});
|
});
|
||||||
if (!confirmed) return;
|
if (!confirmed) return;
|
||||||
try {
|
try {
|
||||||
const response = await fetch(button.dataset.action, {
|
await Http.sendDelete(button.dataset.action, { silent: true });
|
||||||
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);
|
|
||||||
}
|
|
||||||
const item = button.closest(".attachment-gallery-item");
|
const item = button.closest(".attachment-gallery-item");
|
||||||
if (item) item.remove();
|
if (item) item.remove();
|
||||||
} catch (error) {
|
} 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"));
|
this.applyData(await Http.getJson("/admin/services/data"));
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// silently ignore poll errors
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ export class StatisticsCharts {
|
|||||||
try {
|
try {
|
||||||
chart.destroy();
|
chart.destroy();
|
||||||
} catch {
|
} catch {
|
||||||
// ignore destroy errors
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
this.instances = [];
|
this.instances = [];
|
||||||
|
|||||||
@@ -96,7 +96,6 @@ export class AppDocsChat extends Component {
|
|||||||
try {
|
try {
|
||||||
await fetch("/devii/session", { credentials: "same-origin" });
|
await fetch("/devii/session", { credentials: "same-origin" });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Best-effort: ensures the guest cookie exists before the socket opens.
|
|
||||||
}
|
}
|
||||||
this.socket = new DeviiSocket(
|
this.socket = new DeviiSocket(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -364,7 +364,6 @@ export default class ContainerTerminalElement extends FloatingWindow {
|
|||||||
try {
|
try {
|
||||||
if (this.term) this.term.dispose();
|
if (this.term) this.term.dispose();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// already disposed
|
|
||||||
}
|
}
|
||||||
this.remove();
|
this.remove();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
retoor <retoor@molodetz.nl>
|
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
|
## Rules
|
||||||
|
|
||||||
@@ -25,19 +25,19 @@ Every state-changing action in DevPlace records one append-only row through `dev
|
|||||||
| `auth` | 8 |
|
| `auth` | 8 |
|
||||||
| `backup` | 2 |
|
| `backup` | 2 |
|
||||||
| `cli` | 43 |
|
| `cli` | 43 |
|
||||||
| `container` | 36 |
|
| `container` | 41 |
|
||||||
| `content` | 37 |
|
| `content` | 37 |
|
||||||
| `database` | 4 |
|
| `database` | 4 |
|
||||||
| `devii` | 18 |
|
| `devii` | 18 |
|
||||||
| `email` | 6 |
|
| `email` | 6 |
|
||||||
| `engagement` | 23 |
|
| `engagement` | 28 |
|
||||||
| `game` | 6 |
|
| `game` | 19 |
|
||||||
| `ingress` | 1 |
|
| `ingress` | 1 |
|
||||||
| `message` | 2 |
|
| `message` | 2 |
|
||||||
| `moderation` | 12 |
|
| `moderation` | 12 |
|
||||||
| `news` | 9 |
|
| `news` | 9 |
|
||||||
| `notification` | 4 |
|
| `notification` | 4 |
|
||||||
| `project` | 11 |
|
| `project` | 12 |
|
||||||
| `project_files` | 14 |
|
| `project_files` | 14 |
|
||||||
| `pubsub` | 1 |
|
| `pubsub` | 1 |
|
||||||
| `push` | 2 |
|
| `push` | 2 |
|
||||||
@@ -48,7 +48,7 @@ Every state-changing action in DevPlace records one append-only row through `dev
|
|||||||
| `telegram` | 5 |
|
| `telegram` | 5 |
|
||||||
| `tools` | 12 |
|
| `tools` | 12 |
|
||||||
|
|
||||||
**Total: 331 keys.**
|
**Total: 355 keys.**
|
||||||
|
|
||||||
## Account and profile (`account`)
|
## 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.create` | `routers/admin/containers.py`, `routers/projects/containers/instances.py` |
|
||||||
| `container.instance.delete` | `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.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.close` | `routers/projects/containers/instances.py` |
|
||||||
| `container.instance.shell.open` | `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.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.instance.sync` | `routers/admin/containers.py`, `routers/projects/containers/instances.py` |
|
||||||
| `container.reconcile.action` | `services/containers/service.py` |
|
| `container.reconcile.action` | `services/containers/service.py` |
|
||||||
| `container.tunnel.cert.failure` | `services/containers/workspace_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.failure` | `services/containers/workspace_service.py` |
|
||||||
| `container.tunnel.suspend` | `services/containers/workspace_service.py` |
|
| `container.tunnel.suspend` | `services/containers/workspace_service.py` |
|
||||||
| `container.workspace.create` | `routers/projects/containers/workspace.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.editor.update` | `routers/projects/containers/workspace.py`, `routers/admin/workspaces.py` |
|
||||||
| `container.workspace.flag.dismiss` | `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.flag.resolve` | `routers/admin/workspaces.py` |
|
||||||
| `container.workspace.idle.stop` | `services/containers/workspace_service.py` |
|
| `container.workspace.idle.stop` | `services/containers/workspace_service.py` |
|
||||||
| `container.workspace.idle.warn` | `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.block` | `routers/projects/containers/workspace.py` |
|
||||||
| `container.workspace.quota.warn` | `services/containers/workspace_service.py` |
|
| `container.workspace.quota.warn` | `services/containers/workspace_service.py` |
|
||||||
| `container.workspace.restore` | `routers/admin/workspaces.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.retention.warn` | `services/containers/workspace_service.py` |
|
||||||
| `container.workspace.settings.update` | `routers/admin/workspaces.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.suspend` | `routers/admin/workspaces.py` |
|
||||||
| `container.workspace.unsuspend` | `routers/admin/workspaces.py` |
|
| `container.workspace.unsuspend` | `routers/admin/workspaces.py` |
|
||||||
| `container.schedule.create` | `routers/projects/containers/schedules.py`, `services/devii/actions/dispatcher.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 |
|
| Event key | Recorded in |
|
||||||
|---|---|
|
|---|---|
|
||||||
|
| `game.ci.upgrade` | `routers/game/index.py` |
|
||||||
| `game.cosmetic.buy` | `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.downgrade` | `routers/game/index.py` |
|
||||||
| `game.defense.upgrade` | `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.grant.claim` | `routers/game/index.py` |
|
||||||
|
| `game.harvest` | `routers/game/index.py` |
|
||||||
| `game.infrastructure.buy` | `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.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`)
|
## 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>
|
# retoor <retoor@molodetz.nl>
|
||||||
|
|
||||||
|
import json
|
||||||
|
import shutil
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from starlette.websockets import WebSocketDisconnect
|
from starlette.websockets import WebSocketDisconnect
|
||||||
|
|
||||||
|
from devplacepy.config import DBAPI_DIR
|
||||||
from devplacepy.services.manager import service_manager
|
from devplacepy.services.manager import service_manager
|
||||||
|
|
||||||
|
|
||||||
@@ -74,3 +79,73 @@ def test_ws_non_owner_retries(client, auth):
|
|||||||
assert info.value.code == 4013
|
assert info.value.code == 4013
|
||||||
finally:
|
finally:
|
||||||
service_manager.set_lock_owner(True)
|
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():
|
def test_open_workspace_requires_enabled_setting():
|
||||||
set_setting("workspace_enabled", "0")
|
set_setting("workspace_enabled", "0")
|
||||||
assert can_open_workspace(_project(), {"uid": OWNER, "role": "Member"}) is False
|
try:
|
||||||
set_setting("workspace_enabled", "1")
|
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
|
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():
|
def test_workspace_quota_blocks_beyond_limit():
|
||||||
set_setting("workspace_max_per_user", "1")
|
set_setting("workspace_max_per_user", "1")
|
||||||
user = {"uid": OWNER, "username": "owner"}
|
try:
|
||||||
run_async(provision.ensure(_project("p-a"), user))
|
user = {"uid": OWNER, "username": "owner"}
|
||||||
with pytest.raises(WorkspaceError):
|
run_async(provision.ensure(_project("p-a"), user))
|
||||||
run_async(provision.ensure(_project("p-b"), user))
|
with pytest.raises(WorkspaceError):
|
||||||
set_setting("workspace_max_per_user", "2")
|
run_async(provision.ensure(_project("p-b"), user))
|
||||||
|
finally:
|
||||||
|
set_setting("workspace_max_per_user", "2")
|
||||||
|
|
||||||
|
|
||||||
def test_tunnel_revives_rather_than_duplicates():
|
def test_tunnel_revives_rather_than_duplicates():
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
# retoor <retoor@molodetz.nl>
|
# retoor <retoor@molodetz.nl>
|
||||||
|
|
||||||
import time
|
import time
|
||||||
|
import uuid
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
|
|
||||||
from tests.conftest import BASE_URL
|
from tests.conftest import BASE_URL
|
||||||
from devplacepy.database import get_table, refresh_snapshot
|
from devplacepy.database import get_table, refresh_snapshot
|
||||||
|
from devplacepy.services.jobs.isslop import store
|
||||||
|
from devplacepy.utils import generate_uid
|
||||||
|
|
||||||
_counter_isslop = [0]
|
_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
|
assert get_table("isslop_analyses").count(uid=uid) == 1
|
||||||
finally:
|
finally:
|
||||||
_clear_isslop_data()
|
_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():
|
def test_formdata_literal_fields_are_scalar():
|
||||||
"""Literal-typed fields (e.g. target_type in CommentForm) are treated as
|
"""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([
|
fd = FormData([
|
||||||
("content", "Great comment here!"),
|
("content", "Great comment here!"),
|
||||||
("target_type", "post"),
|
("target_type", "post"),
|
||||||
@@ -108,14 +108,14 @@ def test_formdata_literal_form_from_browser():
|
|||||||
|
|
||||||
|
|
||||||
def test_formdata_vote_form():
|
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")])
|
fd = FormData([("value", "1")])
|
||||||
body = _formdata_to_dict(fd, VoteForm)
|
body = _formdata_to_dict(fd, VoteForm)
|
||||||
assert body["value"] == "1" # Pydantic coerces str→int
|
assert body["value"] == "1" # Pydantic coerces str→int
|
||||||
|
|
||||||
|
|
||||||
def test_formdata_issue_status_literal():
|
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")])
|
fd = FormData([("status", "open")])
|
||||||
body = _formdata_to_dict(fd, IssueStatusForm)
|
body = _formdata_to_dict(fd, IssueStatusForm)
|
||||||
assert body["status"] == "open"
|
assert body["status"] == "open"
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ def test_issue_token_returns_expected_fields(local_db):
|
|||||||
|
|
||||||
|
|
||||||
def test_resolve_token_valid(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")
|
uid = _seed_user("resolver", "resolver@test.dev")
|
||||||
user = get_table("users").find_one(uid=uid)
|
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."""
|
"""api_key user wins over access token when the same string matches both."""
|
||||||
shared_key = secrets.token_hex(32) # 64-char hex
|
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)
|
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")
|
uid_b = _seed_user("acctok_loser", "acctokloser@test.dev")
|
||||||
get_table("access_tokens").insert(
|
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})
|
request = _MockRequest(headers={"X-API-KEY": shared_key})
|
||||||
resolved = _resolve_user(request)
|
resolved = _resolve_user(request)
|
||||||
assert resolved is not None
|
assert resolved is not None
|
||||||
assert resolved["uid"] == uid_a
|
assert resolved["uid"] == uid_a
|
||||||
assert resolved["username"] == "apikey_winner"
|
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}"})
|
request2 = _MockRequest(headers={"Authorization": f"Bearer {shared_key}"})
|
||||||
resolved2 = _resolve_user(request2)
|
resolved2 = _resolve_user(request2)
|
||||||
assert resolved2 is not None
|
assert resolved2 is not None
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ def _seed_token(user_uid, key, expire_time):
|
|||||||
# ── resolve_user_by_key tests ────────────────────────────────────────────────
|
# ── resolve_user_by_key tests ────────────────────────────────────────────────
|
||||||
|
|
||||||
def test_resolve_user_by_key_valid(local_db):
|
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")
|
uid = _seed_user("dr_valid", "drvalid@test.dev")
|
||||||
token_key = secrets.token_hex(20)
|
token_key = secrets.token_hex(20)
|
||||||
expire_time = int(datetime.now(timezone.utc).timestamp()) + 86400
|
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):
|
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)
|
random_key = secrets.token_hex(20)
|
||||||
assert resolve_user_by_key(random_key) is None
|
assert resolve_user_by_key(random_key) is None
|
||||||
|
|
||||||
|
|
||||||
def test_resolve_user_by_key_expired(local_db):
|
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")
|
uid = _seed_user("dr_expired", "drexpired@test.dev")
|
||||||
token_key = secrets.token_hex(20)
|
token_key = secrets.token_hex(20)
|
||||||
expire_time = int(datetime.now(timezone.utc).timestamp()) - 86400
|
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):
|
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)
|
uid = _seed_user("dr_inactive", "drinactive@test.dev", is_active=False)
|
||||||
token_key = secrets.token_hex(20)
|
token_key = secrets.token_hex(20)
|
||||||
expire_time = int(datetime.now(timezone.utc).timestamp()) + 86400
|
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."""
|
"""If a key matches both an api_key and a devrant token, the api_key user wins."""
|
||||||
shared_key = secrets.token_hex(20)
|
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)
|
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")
|
uid_b = _seed_user("devrant_owner", "drowner@test.dev")
|
||||||
expire_time = int(datetime.now(timezone.utc).timestamp()) + 86400
|
expire_time = int(datetime.now(timezone.utc).timestamp()) + 86400
|
||||||
_seed_token(uid_b, shared_key, expire_time)
|
_seed_token(uid_b, shared_key, expire_time)
|
||||||
|
|||||||
Reference in New Issue
Block a user