Party Hardy
DevPlace CI / test (push) Failing after 45m54s

This commit is contained in:
2026-10-17 07:33:08 +02:00
parent d84e4cf3d3
commit 7274cc1840
51 changed files with 2738 additions and 2100 deletions
+5 -4
View File
@@ -183,9 +183,9 @@ The AI usage analyzer (`devplace isslop analyze`, the `/tools/isslop` job servic
### Request pipeline
`main.py` mounts `/static`, registers every router with its prefix, installs middlewares (security headers, a per-IP rate limit in an in-process `defaultdict`, a maintenance gate), and registers global 404/500 handlers. Rate limiting reads `rate_limit_per_minute`/`rate_limit_window_seconds` from `site_settings` (default 60/60s) and **applies only to mutating methods** (`POST`/`PUT`/`DELETE`/`PATCH`); reads and the whole `/openai` gateway are exempt. Bucket key is `X-Real-IP` falling back to `request.client.host`; an over-limit request gets `429` with `Retry-After`. The maintenance middleware short-circuits non-admin requests with a 503 when `maintenance_mode="1"`, but always allows `/static`, `/avatar`, `/auth`, `/admin`, and admin users. The outermost middleware is `response_timing`: stamps `request.state.request_start` and sets `X-Response-Time` on every response; the `response_time_ms(request)` Jinja global renders it as a fixed bottom-left badge in `base.html`. This is the single generic timing mechanism - never re-time per route.
`main.py` mounts `/static`, registers every router with its prefix, installs middlewares from `middleware/` (`register()` in addition order: snapshot, corrections, security headers, rate limit, maintenance, terms, presence, visits, timing, tunnel dispatch, gzip), and registers the global handlers from `errors.py` (404/500/`ContentRefused`/`RequestValidationError`). The per-IP rate limit state lives in `middleware/state.py`; app lifespan (locks, `SERVICE_CLASSES`, startup/shutdown) lives in `lifespan.py`; the home route lives in `routers/landing.py`; the static-file classes live in `static_files.py`. Rate limiting reads `rate_limit_per_minute`/`rate_limit_window_seconds` from `site_settings` (default 60/60s) and **applies only to mutating methods** (`POST`/`PUT`/`DELETE`/`PATCH`); reads and the whole `/openai` gateway are exempt. Bucket key is `X-Real-IP` falling back to `request.client.host`; an over-limit request gets `429` with `Retry-After`. The maintenance middleware short-circuits non-admin requests with a 503 when `maintenance_mode="1"`, but always allows `/static`, `/avatar`, `/auth`, `/admin`, and admin users. The outermost middleware is `response_timing`: stamps `request.state.request_start` and sets `X-Response-Time` on every response; the `response_time_ms(request)` Jinja global renders it as a fixed bottom-left badge in `base.html`. This is the single generic timing mechanism - never re-time per route.
`@app.on_event("startup")` calls `init_db()` and, unless `DEVPLACE_DISABLE_SERVICES` is set, registers `NewsService` and kicks off `start_all()`. `GET /` never redirects: guests get the marketing splash, authenticated users get a personalized home, both sharing Latest Posts + Developer News. The feed and Latest Posts enforce **author diversity** by interleaving authors (never dropping posts) via `interleave_by_author`/`paginate_diverse` in `database/`.
`lifespan.py` `lifespan()` calls `init_db()` and, unless `DEVPLACE_DISABLE_SERVICES` is set, registers every service in `SERVICE_CLASSES` and supervises them in the lock owner. `GET /` never redirects: guests get the marketing splash, authenticated users get a personalized home, both sharing Latest Posts + Developer News. The feed and Latest Posts enforce **author diversity** by interleaving authors (never dropping posts) via `interleave_by_author`/`paginate_diverse` in `database/`.
### Routing layout
@@ -201,6 +201,7 @@ Routers in `devplacepy/routers/` are organised as a **directory tree that mirror
| `/messages` | messages.py - see `services/messaging/CLAUDE.md` |
| `/notifications`, `/votes`, `/reactions`, `/bookmarks`, `/polls`, `/avatar`, `/follow`, `/leaderboard` | flat files |
| (none) | relations.py - block/mute |
| `/` | landing.py - home route (guests get the splash, users the dashboard) |
| `/admin`, `/admin/services`, `/admin/containers` | admin/ package |
| `/issues` | issues/ package - see `services/gitea/CLAUDE.md` |
| `/gists`, `/news`, `/uploads`, `/media` | flat files |
@@ -297,7 +298,7 @@ The escape hatch is deliberately two-factor and must never be self-served: after
- **No comments, no docstrings in source.** Code is self-documenting.
- **Forbidden variable name patterns:** `_new`, `_old`, `_temp`, `_v2`, `better_`, `my_`, `the_`.
- **Outbound HTTP goes through the stealth client only.** Never instantiate a bare `httpx.AsyncClient(...)`/`httpx.Client(...)` for outbound traffic - build it via `devplacepy.stealth.stealth_async_client(...)`/`stealth_sync_client(...)`. The async factory routes bytes through **`curl_cffi` (curl-impersonate, BoringSSL)** behind an `httpx` transport adapter, so TLS JA3/JA4 and the HTTP/2 fingerprint match Chrome 146 byte-for-byte; falls back to a pure-httpx Chrome-aligned transport if `curl_cffi` is missing. Applies to news, gateway upstream, Devii/DeepSearch/SEO fetch, gitea, web push, attachments. SSRF-guarded fetches via `net_guard.guarded_async_client(...)` get the fingerprint automatically. Two exceptions keep a plain `httpx.AsyncClient`: (1) the reverse-proxy forwarding core `services/containers/forward.py` (and the three routers that call it - `routers/proxy.py`, `routers/tunnel.py`, the workspace editor route), which relays the user's own headers verbatim - bolting the Chrome identity onto it would overwrite the very headers it exists to forward; (2) the APNs provider (`push/providers/apns.py` `gateway_client` / `delivery_client`) which talks to Apple's HTTP/2 provider API with `httpx.AsyncClient(http2=True, trust_env=False)` - Chrome impersonation, HTTP/2 PRIORITY frames, HPACK indexing of `:path`/`authorization`, extra browser headers, and the outbound proxy all break or starve that API. Web Push stays on stealth. **Load-bearing gotcha:** cleartext `http://` is forced to HTTP/1.1 (`curl_transport.http_version_for`) - the Chrome-impersonation profile defaults to HTTP/2, but a plaintext origin has no TLS/ALPN to negotiate h2 and uvicorn (HTTP/1.1 only) returns `400 "Invalid HTTP request received."` once a cleartext HTTP/2 body crosses ~128KB. Every internal AI consumer calls `INTERNAL_GATEWAY_URL` = `http://localhost:<port>/openai/v1/...`, so without this override a large Devii turn (big system prompt + tool list) fails on every request. `https://` keeps the HTTP/2 default. `stealth_sync_client` stays pure-httpx Chrome-aligned (no curl_cffi sync adapter). **Never bolt a hand-written browser-header dict (UA/`sec-ch-ua`/`Sec-Fetch-*`) onto a `stealth_async_client`/`stealth_sync_client` call** - curl_cffi's impersonation already emits an internally-consistent Chrome 146 identity across TLS, UA, and Client Hints; a caller-supplied header set claiming a different Chrome version or platform creates a UA-vs-TLS-vs-Client-Hints mismatch that is a textbook anti-bot tripwire (this was a real bug in Devii's `fetch_url`/`http_request` tool, fixed by deleting its hardcoded Chrome-131 header block). `stealth.detect_consent_gate(html)` recognizes the common JS-redirect cookie-consent wall pattern (DPG Media's `nu.nl` and sibling sites, and generically any page gated behind a `decodeURIComponent(...)` callback referencing a "privacy gate"/`consent.js`) - reuse it wherever a fetched page's real content sits behind that gate rather than special-casing a domain. `stealth_transport`/`stealth_async_client`/`stealth_sync_client` all accept a `proxy=` override, defaulting to `stealth.configured_proxy_url()` (the admin-editable `outbound_proxy_url` site setting, falling back to `DEVPLACE_OUTBOUND_PROXY_URL` when the setting is unset) - this is the only real fix for a destination that blocks by IP/ASN reputation (e.g. reddit.com returns an identical "blocked by network security" page across every TLS/header fingerprint tried, confirmed by testing curl_cffi's full impersonation list - it is blocking the datacenter ASN outright, not fingerprinting the client).
- **Form validation is Pydantic-native.** Routers declare `data: Annotated[SomeForm, Form()]` (models in `models.py`); the global `RequestValidationError` handler re-renders auth pages with messages (400) or redirects other routes. Read raw `await request.form()` only when also reading an uploaded file alongside a model.
- **Form validation is Pydantic-native.** Routers declare `data: Annotated[SomeForm, Form()]` (models in `models/`, one module per domain, re-exported from `models/__init__.py`); the global `RequestValidationError` handler re-renders auth pages with messages (400) or redirects other routes. Read raw `await request.form()` only when also reading an uploaded file alongside a model.
- **`RedirectResponse(url=..., status_code=302)`** - always pass `status_code` as an integer literal, never `status.HTTP_302_FOUND`.
- **Ownership checks:** compare `resource["user_uid"] == user["uid"]` before edit/delete (use `content.is_owner`). **Deletes additionally allow any admin:** `is_owner(...) or is_admin(user)` on every content delete endpoint, so an administrator may delete any member's content while members are limited to their own. Edits stay owner-only. Deletes are **soft** and cascade under one shared stamp - see `devplacepy/database/CLAUDE.md`.
- **All dates shown to users are DD/MM/YYYY** (European), rendered in the viewer's own timezone client-side. Timestamps are stored/emitted as UTC ISO. Use the `local_dt(iso, mode)`/`dt_ago(iso)` Jinja globals for any user-facing instant - they emit `<time data-dt>` and `static/js/LocalTime.js` reformats to local timezone with a `MutationObserver` for dynamic content. `format_date()`/`time_ago()` stay as plain-text helpers for JSON responses, no-JS fallbacks, and non-timestamp date fields (e.g. project `release_date`) - do NOT wrap those in `local_dt`.
@@ -368,7 +369,7 @@ Every feature in DevPlace is **one data source fanning out into several consumer
A new public read almost always needs all four. The cardinal failure mode is changing one face and forgetting a connected one - a real worked example (followers/following listing) touched nine files across data helpers, output schema, route, view, agent tool, API docs, and the docs trio below for one conceptual feature; that count is the norm, not the exception. If a change touches only one file, confirm none of the four faces were missed. Checklist, ordered by data flow:
1. **Understand.** Read the router, template, matching tests (per the tier naming rule above), and the matching nested `CLAUDE.md`. Trace input model -> router -> data helper -> response (HTML and JSON).
2. **Data layer.** `database/` for query/batch helpers (never inline N+1 loops - use `get_users_by_uids`, `build_pagination`, `_in_clause`; guard raw SQL with `if "table" in db.tables`). `models.py` for the Pydantic `Form` input model. `schemas/` for the `*Out` JSON response model - every context key a JSON route exposes via `respond(..., model=XOut)` MUST exist on `XOut` or it is silently dropped. Schema auto-syncs via `dataset`; add indexes in `init_db()` with `CREATE INDEX IF NOT EXISTS`.
2. **Data layer.** `database/` for query/batch helpers (never inline N+1 loops - use `get_users_by_uids`, `build_pagination`, `_in_clause`; guard raw SQL with `if "table" in db.tables`). `models/` for the Pydantic `Form` input model (add the class to the matching domain module and to the re-export list plus `__all__` in `models/__init__.py`). `schemas/` for the `*Out` JSON response model - every context key a JSON route exposes via `respond(..., model=XOut)` MUST exist on `XOut` or it is silently dropped. Schema auto-syncs via `dataset`; add indexes in `init_db()` with `CREATE INDEX IF NOT EXISTS`.
3. **Server layer.** Handler with the right auth guard (`get_current_user` public read, `require_user` member POST, `require_admin` admin). Specific paths (`/{username}/followers`) declared before catch-alls (`/{username}`). Return HTML+JSON via `respond(request, template, ctx, model=XOut)` or pure JSON via `JSONResponse`. Register any NEW router in `main.py`.
4. **View layer.** Extend `base.html`; page CSS in `{% block extra_head %}`, page JS in `{% block extra_js %}`. Reuse partials and template globals. JS is ES6 modules, one class per file, instantiated on `app`.
5. **Safety layer (skip only when the feature stores nothing and shows nothing).** If the feature adds a surface that carries user-generated content, register it in `database/moderation.py` `REPORTABLE_TARGETS` (or in `UNREPORTABLE_TABLES` with a reason), resolve it in `resolve_object_url`, and include `_report_button.html` in its action bar - the registry test and the e2e coverage test both fail otherwise. If it collects or transmits a new *category* of personal data, or sends anything new to a third party, update `/docs/privacy.html` and the app-store privacy declarations in the same change; that disclosure is part of the feature, not a follow-up.
+4 -1
View File
@@ -42,12 +42,15 @@ sudo apt-get install -y libglib2.0-0 libpango-1.0-0 libpangoft2-1.0-0 libharfbuz
```
devplacepy/
main.py # FastAPI app, router registration
middleware/ # HTTP middlewares + shared request state (package)
errors.py # Global exception handlers
lifespan.py # Startup/shutdown, service registry
config.py # Settings from env vars + .env
database/ # dataset connection, index creation (package)
templating.py # Shared Jinja2 environment + globals
avatar.py # Multiavatar generation, URL builder
utils/ # Password hashing, session mgmt, time_ago, notification hook (package)
models.py # Pydantic schemas
models/ # Pydantic Form models, one module per domain (package)
push/ # Push delivery: provider protocol, Web Push, APNs, registrations
routers/ # One file per domain (auth, feed, posts, push, ...)
templates/ # Jinja2 HTML templates
+154
View File
@@ -0,0 +1,154 @@
# retoor <retoor@molodetz.nl>
import logging
from fastapi import Request
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse, RedirectResponse
from devplacepy import happy404
from devplacepy.responses import json_error, wants_json
from devplacepy.schemas import ValidationErrorOut
from devplacepy.seo import base_seo_context
from devplacepy.services.moderation.screening import ContentRefused
from devplacepy.templating import templates
from devplacepy.utils import safe_next
logger = logging.getLogger(__name__)
async def not_found(request: Request, exc):
if wants_json(request):
return json_error(404, "Not found")
happy_response = happy404.render(request)
if happy_response is not None:
return happy_response
seo_ctx = base_seo_context(
request,
title="Not Found - DevPlace",
description="The page you requested does not exist.",
robots="noindex",
)
return templates.TemplateResponse(
request,
"error.html",
{
**seo_ctx,
"request": request,
"error_code": 404,
"error_message": "Page not found",
},
status_code=404,
)
async def server_error(request: Request, exc):
logger.exception("500 error on %s %s", request.method, request.url.path)
if wants_json(request):
return json_error(500, "Internal server error")
seo_ctx = base_seo_context(
request,
title="Server Error - DevPlace",
description="Something went wrong.",
robots="noindex",
)
return templates.TemplateResponse(
request,
"error.html",
{
**seo_ctx,
"request": request,
"error_code": 500,
"error_message": "Internal server error",
},
status_code=500,
)
async def content_refused(request: Request, exc: ContentRefused):
logger.info("content refused on %s %s: %s", request.method, request.url.path, exc.message)
if wants_json(request):
return json_error(400, exc.message, categories=list(exc.categories))
seo_ctx = base_seo_context(
request,
title="Content not published - DevPlace",
description=exc.message,
robots="noindex",
)
return templates.TemplateResponse(
request,
"error.html",
{
**seo_ctx,
"request": request,
"error_code": 400,
"error_message": exc.message,
},
status_code=400,
)
_AUTH_FORM_PAGES = {
"/auth/signup": ("signup.html", "Join DevPlace"),
"/auth/login": ("login.html", "Sign In"),
"/auth/forgot-password": ("forgot_password.html", "Reset Password"),
}
_FRIENDLY_ERRORS = {
("username", "too_short"): "Username must be between 3 and 32 characters",
("username", "too_long"): "Username must be between 3 and 32 characters",
("password", "too_short"): "Password must be at least 6 characters",
}
def _friendly_error(err):
field = err["loc"][-1] if err.get("loc") else ""
key = (field, err.get("type", "").replace("string_", ""))
if key in _FRIENDLY_ERRORS:
return _FRIENDLY_ERRORS[key]
msg = err.get("msg", "Invalid input")
prefix = "Value error, "
return msg[len(prefix) :] if msg.startswith(prefix) else msg
async def on_validation_error(request: Request, exc: RequestValidationError):
errors = [_friendly_error(e) for e in exc.errors()]
if wants_json(request):
fields: dict = {}
for raw, friendly in zip(exc.errors(), errors):
name = raw["loc"][-1] if raw.get("loc") else "_"
fields.setdefault(str(name), []).append(friendly)
return JSONResponse(
ValidationErrorOut(fields=fields, messages=errors).model_dump(mode="json"),
status_code=422,
)
path = request.url.path
page = _AUTH_FORM_PAGES.get(path)
if page is None and path.startswith("/auth/reset-password/"):
page = ("reset_password.html", "Set New Password")
if page:
template_name, title = page
context = {
**base_seo_context(request, title=title, robots="noindex,nofollow"),
"request": request,
"errors": errors,
}
try:
form = await request.form()
context.update({k: v for k, v in form.items() if isinstance(v, str)})
except Exception:
pass
if "token" in request.path_params:
context["token"] = request.path_params["token"]
return templates.TemplateResponse(
request, template_name, context, status_code=400
)
referer = safe_next(request.headers.get("referer"), "/feed")
return RedirectResponse(url=referer, status_code=303)
def register(app):
app.exception_handler(404)(not_found)
app.exception_handler(500)(server_error)
app.exception_handler(ContentRefused)(content_refused)
app.exception_handler(RequestValidationError)(on_validation_error)
+154
View File
@@ -0,0 +1,154 @@
# retoor <retoor@molodetz.nl>
import fcntl
import logging
import os
from contextlib import asynccontextmanager, contextmanager
from fastapi import FastAPI
from devplacepy.config import (
INIT_LOCK_FILE,
PORT,
SERVICE_LOCK_FILE,
ensure_data_dirs,
)
from devplacepy.database import init_db
from devplacepy.services.acceptance.service import AcceptanceService
from devplacepy.services.audit import AuditService
from devplacepy.services.backup import BackupService
from devplacepy.services.background import background
from devplacepy.services.bot import BotsService
from devplacepy.services.community.service import CommunityService
from devplacepy.services.containers.service import ContainerService
from devplacepy.services.containers.workspace_service import WorkspaceService
from devplacepy.services.dbapi.service import DbApiJobService
from devplacepy.services.devii import DeviiService
from devplacepy.services.gitea.service import IssueTrackerService
from devplacepy.services.jobs.award_service import AwardService
from devplacepy.services.jobs.deepsearch.service import DeepsearchService
from devplacepy.services.jobs.fork_service import ForkService
from devplacepy.services.jobs.issue_create_service import IssueCreateService
from devplacepy.services.jobs.isslop.service import IsslopService
from devplacepy.services.jobs.planning_service import PlanningReportService
from devplacepy.services.jobs.seo.service import SeoService
from devplacepy.services.jobs.seo_meta_service import SeoMetaService
from devplacepy.services.jobs.zip_service import ZipService
from devplacepy.services.live_view_relay import LiveViewRelayService
from devplacepy.services.manager import service_manager
from devplacepy.services.moderation.service import ModerationService
from devplacepy.services.news import NewsService
from devplacepy.services.nodes.service import NodesService
from devplacepy.services.notification_relay import NotificationRelayService
from devplacepy.services.openai_gateway import GatewayService
from devplacepy.services.opinionwar.service import OpinionWarService
from devplacepy.services.presence_relay import PresenceRelayService
from devplacepy.services.pubsub import PubSubService
from devplacepy.services.push import PushService
from devplacepy.services.telegram import TelegramService
from devplacepy.services.telegram.outbox_service import TelegramOutboxService
from devplacepy.services.xmlrpc import XmlrpcService
logger = logging.getLogger(__name__)
SERVICE_CLASSES = [
NewsService,
BotsService,
GatewayService,
DeviiService,
ZipService,
ForkService,
SeoService,
SeoMetaService,
AwardService,
BackupService,
DbApiJobService,
PubSubService,
NotificationRelayService,
OpinionWarService,
LiveViewRelayService,
PresenceRelayService,
DeepsearchService,
IsslopService,
IssueCreateService,
PlanningReportService,
IssueTrackerService,
ContainerService,
WorkspaceService,
XmlrpcService,
AuditService,
ModerationService,
AcceptanceService,
CommunityService,
NodesService,
PushService,
TelegramService,
TelegramOutboxService,
]
_service_lock_handle = None
@contextmanager
def init_lock():
handle = open(INIT_LOCK_FILE, "w")
try:
fcntl.flock(handle, fcntl.LOCK_EX)
yield
finally:
fcntl.flock(handle, fcntl.LOCK_UN)
handle.close()
def acquire_service_lock() -> bool:
global _service_lock_handle
handle = open(SERVICE_LOCK_FILE, "w")
try:
fcntl.flock(handle, fcntl.LOCK_EX | fcntl.LOCK_NB)
except OSError:
handle.close()
return False
_service_lock_handle = handle
return True
@asynccontextmanager
async def lifespan(app: FastAPI):
ensure_data_dirs()
with init_lock():
init_db()
from devplacepy.push import ensure_certificates
ensure_certificates()
from devplacepy.services.openai_gateway import model_health
model_health.seed_from_ledger()
for service_cls in SERVICE_CLASSES:
service_manager.register(service_cls())
if not os.environ.get("DEVPLACE_DISABLE_SERVICES"):
await background.start()
if acquire_service_lock():
service_manager.set_lock_owner(True)
service_manager.supervise()
logger.info(f"Background services started in worker pid {os.getpid()}")
else:
logger.info(
f"Worker pid {os.getpid()} declined service lock; another worker owns background services"
)
from devplacepy.services.statistics.tracking import start_visit_flusher
start_visit_flusher()
logger.info(f"DevPlace started on port {PORT}")
yield
logger.info("Shutting down services...")
from devplacepy.services.statistics.tracking import flush_visits
flush_visits()
await service_manager.shutdown_all()
await background.stop()
from devplacepy.services.containers import forward
await forward.close_client()
from devplacepy import push
await push.shutdown_providers()
+16 -763
View File
@@ -1,54 +1,18 @@
# retoor <retoor@molodetz.nl>
import asyncio
import fcntl
import logging
import os
import time
from collections import defaultdict
from contextlib import asynccontextmanager, contextmanager
from pathlib import Path
from fastapi import FastAPI, Request, WebSocket
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles
from fastapi.exceptions import RequestValidationError
from starlette.middleware.gzip import GZipMiddleware
from fastapi import FastAPI
from devplacepy import errors as error_handlers
from devplacepy.config import (
LOG_LEVEL,
STATIC_DIR,
STATIC_VERSION,
UPLOADS_DIR,
PORT,
SERVICE_LOCK_FILE,
INIT_LOCK_FILE,
LOG_LEVEL,
ensure_data_dirs,
)
from devplacepy.database import (
init_db,
get_table,
db,
refresh_snapshot,
get_users_by_uids,
get_comment_counts_by_post_uids,
get_vote_counts,
get_news_images_by_uids,
get_setting,
get_int_setting,
paginate_diverse,
get_user_post_count,
get_user_stars,
get_blocked_uids,
get_top_authors,
get_trending_topics,
)
from devplacepy.templating import templates, jinja_unread_count
from devplacepy.cache import TTLCache
from devplacepy.responses import respond, wants_json, json_error
from devplacepy import happy404
from devplacepy.schemas import LandingOut, ValidationErrorOut
from fastapi.responses import JSONResponse
from devplacepy.utils import get_current_user, time_ago, safe_next, client_ip
from devplacepy.seo import base_seo_context, site_url, website_schema
from devplacepy.lifespan import lifespan
from devplacepy.middleware import register as register_middleware
from devplacepy.routers import (
auth,
battles,
@@ -96,243 +60,18 @@ from devplacepy.routers import (
pubsub,
game,
workspaces,
landing,
)
from devplacepy.static_files import (
CachedStaticFiles,
FallbackStaticFiles,
UploadStaticFiles,
)
from devplacepy.services.manager import service_manager
from devplacepy.services.background import background
from devplacepy.services.news import NewsService
from devplacepy.services.bot import BotsService
from devplacepy.services.openai_gateway import GatewayService
from devplacepy.services.devii import DeviiService
from devplacepy.services.jobs.zip_service import ZipService
from devplacepy.services.jobs.fork_service import ForkService
from devplacepy.services.jobs.issue_create_service import IssueCreateService
from devplacepy.services.jobs.planning_service import PlanningReportService
from devplacepy.services.jobs.seo.service import SeoService
from devplacepy.services.jobs.seo_meta_service import SeoMetaService
from devplacepy.services.jobs.award_service import AwardService
from devplacepy.services.backup import BackupService
from devplacepy.services.dbapi.service import DbApiJobService
from devplacepy.services.pubsub import PubSubService
from devplacepy.services.notification_relay import NotificationRelayService
from devplacepy.services.opinionwar.service import OpinionWarService
from devplacepy.services.live_view_relay import LiveViewRelayService
from devplacepy.services.presence_relay import PresenceRelayService
from devplacepy.services import presence
from devplacepy.services.correction import PENDING_SCOPE_KEY
from devplacepy.services.jobs.deepsearch.service import DeepsearchService
from devplacepy.services.jobs.isslop.service import IsslopService
from devplacepy.services.gitea.service import IssueTrackerService
from devplacepy.services.containers.service import ContainerService
from devplacepy.services.containers.workspace_service import WorkspaceService
from devplacepy.services.xmlrpc import XmlrpcService
from devplacepy.services.audit import AuditService
from devplacepy.services.moderation.service import ModerationService
from devplacepy.services.moderation.screening import ContentRefused
from devplacepy.services.acceptance.service import AcceptanceService
from devplacepy.services.community.service import CommunityService
from devplacepy.services.nodes.service import NodesService
from devplacepy.services.audit import record as audit
from devplacepy.services.push import PushService
from devplacepy.services.telegram import TelegramService
from devplacepy.services.telegram.outbox_service import TelegramOutboxService
logging.basicConfig(
level=getattr(logging, LOG_LEVEL, logging.INFO),
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
)
logger = logging.getLogger(__name__)
_rate_limit_store = defaultdict(list)
RATE_LIMIT = int(os.environ.get("DEVPLACE_RATE_LIMIT", "60"))
RATE_WINDOW = 60
WEB_WORKERS = max(1, int(os.environ.get("DEVPLACE_WEB_WORKERS", "1")))
RATE_LIMIT_DISABLED = os.environ.get("DEVPLACE_DISABLE_RATE_LIMIT") == "1"
HOT_SETTINGS_TTL = 2.0
_hot_settings_value: dict = {}
_hot_settings_at = 0.0
_last_rate_sweep = 0.0
RATE_SWEEP_INTERVAL = 60.0
def _hot_settings() -> dict:
global _hot_settings_value, _hot_settings_at
now = time.monotonic()
if not _hot_settings_value or now - _hot_settings_at >= HOT_SETTINGS_TTL:
_hot_settings_value = {
"maintenance_mode": get_setting("maintenance_mode", "0"),
"rate_limit_per_minute": max(
1, get_int_setting("rate_limit_per_minute", RATE_LIMIT)
),
"rate_limit_window_seconds": max(
1, get_int_setting("rate_limit_window_seconds", RATE_WINDOW)
),
}
_hot_settings_at = now
return _hot_settings_value
def _sweep_rate_limit_store(window_start: float) -> None:
stale = [
ip
for ip, timestamps in _rate_limit_store.items()
if not timestamps or timestamps[-1] <= window_start
]
for ip in stale:
del _rate_limit_store[ip]
def _worker_rate_limit(limit: int) -> int:
return max(1, -(-limit // WEB_WORKERS))
_service_lock_handle = None
@contextmanager
def init_lock():
handle = open(INIT_LOCK_FILE, "w")
try:
fcntl.flock(handle, fcntl.LOCK_EX)
yield
finally:
fcntl.flock(handle, fcntl.LOCK_UN)
handle.close()
def acquire_service_lock() -> bool:
global _service_lock_handle
handle = open(SERVICE_LOCK_FILE, "w")
try:
fcntl.flock(handle, fcntl.LOCK_EX | fcntl.LOCK_NB)
except OSError:
handle.close()
return False
_service_lock_handle = handle
return True
INLINE_MEDIA_EXTENSIONS = {
".jpg",
".jpeg",
".png",
".gif",
".webp",
".bmp",
".tiff",
".mp4",
".webm",
".ogv",
".mov",
".m4v",
".mp3",
}
class UploadStaticFiles(StaticFiles):
async def get_response(self, path, scope):
response = await super().get_response(path, scope)
disposition = (
"inline"
if Path(path).suffix.lower() in INLINE_MEDIA_EXTENSIONS
else "attachment"
)
response.headers["Content-Disposition"] = disposition
response.headers["Cache-Control"] = "public, max-age=604800"
return response
class CachedStaticFiles(StaticFiles):
async def get_response(self, path, scope):
response = await super().get_response(path, scope)
if Path(path).name == "service-worker.js":
response.headers["Cache-Control"] = "no-cache"
else:
response.headers["Cache-Control"] = "public, max-age=31536000, immutable"
return response
class FallbackStaticFiles(StaticFiles):
async def get_response(self, path, scope):
response = await super().get_response(path, scope)
if Path(path).name == "service-worker.js":
response.headers["Cache-Control"] = "no-cache"
else:
response.headers["Cache-Control"] = "public, max-age=3600"
return response
@asynccontextmanager
async def lifespan(app: FastAPI):
ensure_data_dirs()
with init_lock():
init_db()
from devplacepy.push import ensure_certificates
ensure_certificates()
from devplacepy.services.openai_gateway import model_health
model_health.seed_from_ledger()
service_manager.register(NewsService())
service_manager.register(BotsService())
service_manager.register(GatewayService())
service_manager.register(DeviiService())
service_manager.register(ZipService())
service_manager.register(ForkService())
service_manager.register(SeoService())
service_manager.register(SeoMetaService())
service_manager.register(AwardService())
service_manager.register(BackupService())
service_manager.register(DbApiJobService())
service_manager.register(PubSubService())
service_manager.register(NotificationRelayService())
service_manager.register(OpinionWarService())
service_manager.register(LiveViewRelayService())
service_manager.register(PresenceRelayService())
service_manager.register(DeepsearchService())
service_manager.register(IsslopService())
service_manager.register(IssueCreateService())
service_manager.register(PlanningReportService())
service_manager.register(IssueTrackerService())
service_manager.register(ContainerService())
service_manager.register(WorkspaceService())
service_manager.register(XmlrpcService())
service_manager.register(AuditService())
service_manager.register(ModerationService())
service_manager.register(AcceptanceService())
service_manager.register(CommunityService())
service_manager.register(NodesService())
service_manager.register(PushService())
service_manager.register(TelegramService())
service_manager.register(TelegramOutboxService())
if not os.environ.get("DEVPLACE_DISABLE_SERVICES"):
await background.start()
if acquire_service_lock():
service_manager.set_lock_owner(True)
service_manager.supervise()
logger.info(f"Background services started in worker pid {os.getpid()}")
else:
logger.info(
f"Worker pid {os.getpid()} declined service lock; another worker owns background services"
)
from devplacepy.services.statistics.tracking import start_visit_flusher
start_visit_flusher()
logger.info(f"DevPlace started on port {PORT}")
yield
logger.info("Shutting down services...")
from devplacepy.services.statistics.tracking import flush_visits
flush_visits()
await service_manager.shutdown_all()
await background.stop()
from devplacepy.services.containers import forward
await forward.close_client()
from devplacepy import push
await push.shutdown_providers()
app = FastAPI(
title="DevPlace",
@@ -353,140 +92,7 @@ app.mount(
)
app.mount("/static", FallbackStaticFiles(directory=str(STATIC_DIR)), name="static")
@app.exception_handler(404)
async def not_found(request: Request, exc):
if wants_json(request):
return json_error(404, "Not found")
happy_response = happy404.render(request)
if happy_response is not None:
return happy_response
seo_ctx = base_seo_context(
request,
title="Not Found - DevPlace",
description="The page you requested does not exist.",
robots="noindex",
)
return templates.TemplateResponse(
request,
"error.html",
{
**seo_ctx,
"request": request,
"error_code": 404,
"error_message": "Page not found",
},
status_code=404,
)
@app.exception_handler(500)
async def server_error(request: Request, exc):
logger.exception("500 error on %s %s", request.method, request.url.path)
if wants_json(request):
return json_error(500, "Internal server error")
seo_ctx = base_seo_context(
request,
title="Server Error - DevPlace",
description="Something went wrong.",
robots="noindex",
)
return templates.TemplateResponse(
request,
"error.html",
{
**seo_ctx,
"request": request,
"error_code": 500,
"error_message": "Internal server error",
},
status_code=500,
)
@app.exception_handler(ContentRefused)
async def content_refused(request: Request, exc: ContentRefused):
logger.info("content refused on %s %s: %s", request.method, request.url.path, exc.message)
if wants_json(request):
return json_error(400, exc.message, categories=list(exc.categories))
seo_ctx = base_seo_context(
request,
title="Content not published - DevPlace",
description=exc.message,
robots="noindex",
)
return templates.TemplateResponse(
request,
"error.html",
{
**seo_ctx,
"request": request,
"error_code": 400,
"error_message": exc.message,
},
status_code=400,
)
_AUTH_FORM_PAGES = {
"/auth/signup": ("signup.html", "Join DevPlace"),
"/auth/login": ("login.html", "Sign In"),
"/auth/forgot-password": ("forgot_password.html", "Reset Password"),
}
_FRIENDLY_ERRORS = {
("username", "too_short"): "Username must be between 3 and 32 characters",
("username", "too_long"): "Username must be between 3 and 32 characters",
("password", "too_short"): "Password must be at least 6 characters",
}
def _friendly_error(err):
field = err["loc"][-1] if err.get("loc") else ""
key = (field, err.get("type", "").replace("string_", ""))
if key in _FRIENDLY_ERRORS:
return _FRIENDLY_ERRORS[key]
msg = err.get("msg", "Invalid input")
prefix = "Value error, "
return msg[len(prefix) :] if msg.startswith(prefix) else msg
@app.exception_handler(RequestValidationError)
async def on_validation_error(request: Request, exc: RequestValidationError):
errors = [_friendly_error(e) for e in exc.errors()]
if wants_json(request):
fields: dict = {}
for raw, friendly in zip(exc.errors(), errors):
name = raw["loc"][-1] if raw.get("loc") else "_"
fields.setdefault(str(name), []).append(friendly)
return JSONResponse(
ValidationErrorOut(fields=fields, messages=errors).model_dump(mode="json"),
status_code=422,
)
path = request.url.path
page = _AUTH_FORM_PAGES.get(path)
if page is None and path.startswith("/auth/reset-password/"):
page = ("reset_password.html", "Set New Password")
if page:
template_name, title = page
context = {
**base_seo_context(request, title=title, robots="noindex,nofollow"),
"request": request,
"errors": errors,
}
try:
form = await request.form()
context.update({k: v for k, v in form.items() if isinstance(v, str)})
except Exception:
pass
if "token" in request.path_params:
context["token"] = request.path_params["token"]
return templates.TemplateResponse(
request, template_name, context, status_code=400
)
referer = safe_next(request.headers.get("referer"), "/feed")
return RedirectResponse(url=referer, status_code=303)
error_handlers.register(app)
app.include_router(auth.router, prefix="/auth")
app.include_router(feed.router, prefix="/feed")
@@ -534,359 +140,6 @@ app.include_router(battles.router, prefix="/battles")
app.include_router(nodes.router, prefix="/nodes")
app.include_router(sync.router, prefix="/sync")
app.include_router(workspaces.router, prefix="/workspaces")
app.include_router(landing.router)
@app.middleware("http")
async def refresh_db_snapshot(request: Request, call_next):
if not request.url.path.startswith(("/static", "/avatar")):
refresh_snapshot()
return await call_next(request)
@app.middleware("http")
async def await_pending_corrections(request: Request, call_next):
response = await call_next(request)
pending = request.scope.get(PENDING_SCOPE_KEY)
if pending:
await asyncio.gather(*pending, return_exceptions=True)
return response
def _frame_ancestors() -> str:
from devplacepy.services.containers.workspace import naming
tunnel_domain = naming.domain()
if not tunnel_domain:
return "'self'"
return f"'self' https://*.{tunnel_domain}"
@app.middleware("http")
async def add_security_headers(request: Request, call_next):
response = await call_next(request)
if not response.headers.get("X-Robots-Tag"):
response.headers["X-Robots-Tag"] = "index, follow"
response.headers["X-Content-Type-Options"] = "nosniff"
response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains"
response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
if not request.url.path.startswith("/p/"):
response.headers["Content-Security-Policy"] = (
"object-src 'none'; base-uri 'self'; "
f"frame-ancestors {_frame_ancestors()}; form-action 'self'"
)
if request.url.path.startswith("/admin"):
response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0"
response.headers["Pragma"] = "no-cache"
response.headers["Expires"] = "0"
return response
@app.middleware("http")
async def rate_limit_middleware(request: Request, call_next):
if RATE_LIMIT_DISABLED:
return await call_next(request)
if request.method in (
"POST",
"PUT",
"DELETE",
"PATCH",
) and not request.url.path.startswith(("/openai", "/xmlrpc")):
settings = _hot_settings()
limit = _worker_rate_limit(settings["rate_limit_per_minute"])
window = settings["rate_limit_window_seconds"]
ip = client_ip(request, default="unknown")
now = time.time()
window_start = now - window
global _last_rate_sweep
if now - _last_rate_sweep >= RATE_SWEEP_INTERVAL:
_sweep_rate_limit_store(window_start)
_last_rate_sweep = now
timestamps = [t for t in _rate_limit_store.get(ip, ()) if t > window_start]
if len(timestamps) >= limit:
_rate_limit_store[ip] = timestamps
audit.record(
request,
"security.rate_limit.block",
result="denied",
summary=f"request from {ip} blocked by rate limit",
metadata={"ip": ip, "limit": limit, "window_seconds": window},
)
retry_after = {"Retry-After": str(window)}
if wants_json(request):
response = json_error(429, "Rate limit exceeded. Try again later.")
response.headers["Retry-After"] = str(window)
return response
return HTMLResponse(
"Rate limit exceeded. Try again later.",
status_code=429,
headers=retry_after,
)
timestamps.append(now)
_rate_limit_store[ip] = timestamps
return await call_next(request)
_MAINTENANCE_ALLOWED_PREFIXES = ("/static", "/avatar", "/auth", "/admin", "/openai")
@app.middleware("http")
async def maintenance_middleware(request: Request, call_next):
if _hot_settings()["maintenance_mode"] != "1":
return await call_next(request)
if request.url.path.startswith(_MAINTENANCE_ALLOWED_PREFIXES):
return await call_next(request)
user = get_current_user(request)
if user and user.get("role") == "Admin":
return await call_next(request)
message = get_setting(
"maintenance_message",
"DevPlace is undergoing scheduled maintenance. Please check back shortly.",
)
audit.record(
request,
"security.maintenance.block",
user=user,
result="denied",
summary="non-admin request blocked by maintenance mode",
)
if wants_json(request):
return json_error(503, message)
seo_ctx = base_seo_context(
request, title="Maintenance - DevPlace", description=message, robots="noindex"
)
return templates.TemplateResponse(
request,
"error.html",
{**seo_ctx, "request": request, "error_code": 503, "error_message": message},
status_code=503,
)
_TERMS_ALLOWED_PREFIXES = (
"/static",
"/avatar",
"/auth",
"/docs",
"/reports",
"/block",
"/mute",
"/openai",
)
_TERMS_GATED_METHODS = ("POST", "PUT", "DELETE", "PATCH")
def _terms_exempt(path: str) -> bool:
if path.startswith(_TERMS_ALLOWED_PREFIXES):
return True
return path.startswith("/profile/") and (
path.endswith("/delete") or path.endswith("/consent")
)
@app.middleware("http")
async def terms_acceptance_gate(request: Request, call_next):
if request.method not in _TERMS_GATED_METHODS or _terms_exempt(request.url.path):
return await call_next(request)
from devplacepy.routers.auth.terms import (
TERMS_ACCEPTANCE_CODE,
current_terms_version,
needs_acceptance,
)
user = get_current_user(request)
if not needs_acceptance(user):
return await call_next(request)
message = "Accept the updated Terms of Service to continue."
if wants_json(request):
return json_error(
403,
message,
code=TERMS_ACCEPTANCE_CODE,
redirect="/auth/accept-terms",
terms_version=current_terms_version(),
)
return RedirectResponse(url="/auth/accept-terms", status_code=303)
@app.middleware("http")
async def track_presence(request: Request, call_next):
path = request.url.path
if not path.startswith(("/static", "/avatar")):
user = get_current_user(request)
if user:
presence.touch(user["uid"])
return await call_next(request)
@app.middleware("http")
async def visit_statistics(request: Request, call_next):
from devplacepy.services.statistics.tracking import track_visit
response = await call_next(request)
track_visit(request, response.status_code)
return response
@app.middleware("http")
async def response_timing(request: Request, call_next):
start = time.perf_counter()
request.state.request_start = start
response = await call_next(request)
response.headers["X-Response-Time"] = f"{(time.perf_counter() - start) * 1000:.1f}ms"
return response
class TunnelDispatchMiddleware:
def __init__(self, app):
self.app = app
@staticmethod
def _host(scope) -> str:
for key, value in scope.get("headers") or []:
if key == b"host":
return value.decode("latin-1")
return ""
async def __call__(self, scope, receive, send):
if scope["type"] not in ("http", "websocket"):
await self.app(scope, receive, send)
return
from devplacepy.routers import tunnel as tunnel_router
from devplacepy.services.containers.workspace import naming
try:
if not naming.is_tunnel_host(self._host(scope)):
await self.app(scope, receive, send)
return
except Exception:
await self.app(scope, receive, send)
return
path = (scope.get("path") or "/").lstrip("/")
if scope["type"] == "websocket":
websocket = WebSocket(scope, receive, send)
await tunnel_router.handle_ws(websocket, path)
return
request = Request(scope, receive)
response = await tunnel_router.handle_http(request, path)
await response(scope, receive, send)
app.add_middleware(TunnelDispatchMiddleware)
app.add_middleware(GZipMiddleware, minimum_size=512, compresslevel=5)
_home_cache = TTLCache(ttl=int(os.environ.get("DEVPLACE_HOME_CACHE_TTL", "60")), max_size=4)
def _landing_news():
cached = _home_cache.get("news")
if cached is not None:
return cached
articles = []
if "news" in db.tables:
news_table = get_table("news")
raw = list(
news_table.find(show_on_landing=1, order_by=["-synced_at"], _limit=6)
)
images_by_news = get_news_images_by_uids([a["uid"] for a in raw])
for a in raw:
articles.append(
{
"uid": a["uid"],
"slug": a.get("slug", ""),
"title": a.get("title", ""),
"description": (a.get("description", "") or "")[:250],
"url": a.get("url", ""),
"source_name": a.get("source_name", ""),
"grade": a.get("grade", 0),
"featured": a.get("featured", 0),
"synced_at": a.get("synced_at", "") or "",
"time_ago": time_ago(a["synced_at"]),
"image_url": a.get("image_url", "") or images_by_news.get(a["uid"], ""),
}
)
_home_cache.set("news", articles)
return articles
def _landing_recent_posts(viewer_uid):
blocked = get_blocked_uids(viewer_uid) if viewer_uid else frozenset()
if not blocked:
cached = _home_cache.get("posts")
if cached is not None:
return cached
posts = []
if "posts" in db.tables:
posts_table = get_table("posts")
raw_posts, _ = paginate_diverse(
posts_table, order=["-created_at"], viewer_uid=viewer_uid, limit=6
)
if raw_posts:
post_uids = [p["uid"] for p in raw_posts]
author_uids = [p["user_uid"] for p in raw_posts]
authors = get_users_by_uids(author_uids)
comment_counts = get_comment_counts_by_post_uids(post_uids)
upvotes, downvotes = get_vote_counts(post_uids)
for p in raw_posts:
posts.append(
{
"post": p,
"author": authors.get(p["user_uid"]),
"time_ago": time_ago(p["created_at"]),
"comment_count": comment_counts.get(p["uid"], 0),
"stars": upvotes.get(p["uid"], 0) - downvotes.get(p["uid"], 0),
"slug": p.get("slug", "") or p["uid"],
}
)
if not blocked:
_home_cache.set("posts", posts)
return posts
@app.get("/")
async def landing(request: Request):
user = get_current_user(request)
landing_articles = _landing_news()
viewer_uid = user["uid"] if user else None
blocked = get_blocked_uids(viewer_uid) if viewer_uid else frozenset()
landing_posts = _landing_recent_posts(viewer_uid)
base = site_url(request)
seo_ctx = base_seo_context(
request,
title="DevPlace - The Developer Social Network",
description="Track industry shifts. Discover bold releases. Share what you're building in an open environment built by developers, for developers.",
breadcrumbs=[],
schemas=[website_schema(base)],
)
user_xp = user.get("xp", 0) or 0 if user else 0
user_level = user.get("level", 1) or 1 if user else 1
xp_progress_pct = (user_xp % 100) if user_xp else 0
unread_count = jinja_unread_count(user["uid"]) if user else 0
top_contributors = get_top_authors(5) if not blocked else []
trending_topics = get_trending_topics(6) if not blocked else []
return respond(
request,
"landing.html",
{
**seo_ctx,
"request": request,
"user": user,
"is_authenticated": bool(user),
"user_post_count": get_user_post_count(user["uid"]) if user else 0,
"user_stars": get_user_stars(user["uid"]) if user else 0,
"user_xp": user_xp,
"user_level": user_level,
"xp_progress_pct": xp_progress_pct,
"unread_count": unread_count,
"landing_articles": landing_articles,
"landing_posts": landing_posts,
"top_contributors": top_contributors,
"trending_topics": trending_topics,
},
model=LandingOut,
)
register_middleware(app)
+34
View File
@@ -0,0 +1,34 @@
# retoor <retoor@molodetz.nl>
from starlette.middleware.gzip import GZipMiddleware
from devplacepy.middleware.guards import (
maintenance_middleware,
rate_limit_middleware,
terms_acceptance_gate,
)
from devplacepy.middleware.observe import (
response_timing,
track_presence,
visit_statistics,
)
from devplacepy.middleware.pipeline import (
add_security_headers,
await_pending_corrections,
refresh_db_snapshot,
)
from devplacepy.middleware.tunnel import TunnelDispatchMiddleware
def register(app):
app.middleware("http")(refresh_db_snapshot)
app.middleware("http")(await_pending_corrections)
app.middleware("http")(add_security_headers)
app.middleware("http")(rate_limit_middleware)
app.middleware("http")(maintenance_middleware)
app.middleware("http")(terms_acceptance_gate)
app.middleware("http")(track_presence)
app.middleware("http")(visit_statistics)
app.middleware("http")(response_timing)
app.add_middleware(TunnelDispatchMiddleware)
app.add_middleware(GZipMiddleware, minimum_size=512, compresslevel=5)
+138
View File
@@ -0,0 +1,138 @@
# retoor <retoor@molodetz.nl>
import time
from fastapi import Request
from fastapi.responses import HTMLResponse, RedirectResponse
from devplacepy.database import get_setting
from devplacepy.middleware import state
from devplacepy.responses import json_error, wants_json
from devplacepy.seo import base_seo_context
from devplacepy.services.audit import record as audit
from devplacepy.templating import templates
from devplacepy.utils import client_ip, get_current_user
async def rate_limit_middleware(request: Request, call_next):
if state.RATE_LIMIT_DISABLED:
return await call_next(request)
if request.method in (
"POST",
"PUT",
"DELETE",
"PATCH",
) and not request.url.path.startswith(("/openai", "/xmlrpc")):
settings = state._hot_settings()
limit = state._worker_rate_limit(settings["rate_limit_per_minute"])
window = settings["rate_limit_window_seconds"]
ip = client_ip(request, default="unknown")
now = time.time()
window_start = now - window
if now - state._last_rate_sweep >= state.RATE_SWEEP_INTERVAL:
state._sweep_rate_limit_store(window_start)
state._last_rate_sweep = now
timestamps = [t for t in state._rate_limit_store.get(ip, ()) if t > window_start]
if len(timestamps) >= limit:
state._rate_limit_store[ip] = timestamps
audit.record(
request,
"security.rate_limit.block",
result="denied",
summary=f"request from {ip} blocked by rate limit",
metadata={"ip": ip, "limit": limit, "window_seconds": window},
)
retry_after = {"Retry-After": str(window)}
if wants_json(request):
response = json_error(429, "Rate limit exceeded. Try again later.")
response.headers["Retry-After"] = str(window)
return response
return HTMLResponse(
"Rate limit exceeded. Try again later.",
status_code=429,
headers=retry_after,
)
timestamps.append(now)
state._rate_limit_store[ip] = timestamps
return await call_next(request)
_MAINTENANCE_ALLOWED_PREFIXES = ("/static", "/avatar", "/auth", "/admin", "/openai")
async def maintenance_middleware(request: Request, call_next):
if state._hot_settings()["maintenance_mode"] != "1":
return await call_next(request)
if request.url.path.startswith(_MAINTENANCE_ALLOWED_PREFIXES):
return await call_next(request)
user = get_current_user(request)
if user and user.get("role") == "Admin":
return await call_next(request)
message = get_setting(
"maintenance_message",
"DevPlace is undergoing scheduled maintenance. Please check back shortly.",
)
audit.record(
request,
"security.maintenance.block",
user=user,
result="denied",
summary="non-admin request blocked by maintenance mode",
)
if wants_json(request):
return json_error(503, message)
seo_ctx = base_seo_context(
request, title="Maintenance - DevPlace", description=message, robots="noindex"
)
return templates.TemplateResponse(
request,
"error.html",
{**seo_ctx, "request": request, "error_code": 503, "error_message": message},
status_code=503,
)
_TERMS_ALLOWED_PREFIXES = (
"/static",
"/avatar",
"/auth",
"/docs",
"/reports",
"/block",
"/mute",
"/openai",
)
_TERMS_GATED_METHODS = ("POST", "PUT", "DELETE", "PATCH")
def _terms_exempt(path: str) -> bool:
if path.startswith(_TERMS_ALLOWED_PREFIXES):
return True
return path.startswith("/profile/") and (
path.endswith("/delete") or path.endswith("/consent")
)
async def terms_acceptance_gate(request: Request, call_next):
if request.method not in _TERMS_GATED_METHODS or _terms_exempt(request.url.path):
return await call_next(request)
from devplacepy.routers.auth.terms import (
TERMS_ACCEPTANCE_CODE,
current_terms_version,
needs_acceptance,
)
user = get_current_user(request)
if not needs_acceptance(user):
return await call_next(request)
message = "Accept the updated Terms of Service to continue."
if wants_json(request):
return json_error(
403,
message,
code=TERMS_ACCEPTANCE_CODE,
redirect="/auth/accept-terms",
terms_version=current_terms_version(),
)
return RedirectResponse(url="/auth/accept-terms", status_code=303)
+33
View File
@@ -0,0 +1,33 @@
# retoor <retoor@molodetz.nl>
import time
from fastapi import Request
from devplacepy.services import presence
from devplacepy.utils import get_current_user
async def track_presence(request: Request, call_next):
path = request.url.path
if not path.startswith(("/static", "/avatar")):
user = get_current_user(request)
if user:
presence.touch(user["uid"])
return await call_next(request)
async def visit_statistics(request: Request, call_next):
from devplacepy.services.statistics.tracking import track_visit
response = await call_next(request)
track_visit(request, response.status_code)
return response
async def response_timing(request: Request, call_next):
start = time.perf_counter()
request.state.request_start = start
response = await call_next(request)
response.headers["X-Response-Time"] = f"{(time.perf_counter() - start) * 1000:.1f}ms"
return response
+50
View File
@@ -0,0 +1,50 @@
# retoor <retoor@molodetz.nl>
import asyncio
from fastapi import Request
from devplacepy.database import refresh_snapshot
from devplacepy.services.correction import PENDING_SCOPE_KEY
async def refresh_db_snapshot(request: Request, call_next):
if not request.url.path.startswith(("/static", "/avatar")):
refresh_snapshot()
return await call_next(request)
async def await_pending_corrections(request: Request, call_next):
response = await call_next(request)
pending = request.scope.get(PENDING_SCOPE_KEY)
if pending:
await asyncio.gather(*pending, return_exceptions=True)
return response
def _frame_ancestors() -> str:
from devplacepy.services.containers.workspace import naming
tunnel_domain = naming.domain()
if not tunnel_domain:
return "'self'"
return f"'self' https://*.{tunnel_domain}"
async def add_security_headers(request: Request, call_next):
response = await call_next(request)
if not response.headers.get("X-Robots-Tag"):
response.headers["X-Robots-Tag"] = "index, follow"
response.headers["X-Content-Type-Options"] = "nosniff"
response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains"
response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
if not request.url.path.startswith("/p/"):
response.headers["Content-Security-Policy"] = (
"object-src 'none'; base-uri 'self'; "
f"frame-ancestors {_frame_ancestors()}; form-action 'self'"
)
if request.url.path.startswith("/admin"):
response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0"
response.headers["Pragma"] = "no-cache"
response.headers["Expires"] = "0"
return response
+50
View File
@@ -0,0 +1,50 @@
# retoor <retoor@molodetz.nl>
import os
import time
from collections import defaultdict
from devplacepy.database import get_int_setting, get_setting
_rate_limit_store = defaultdict(list)
RATE_LIMIT = int(os.environ.get("DEVPLACE_RATE_LIMIT", "60"))
RATE_WINDOW = 60
WEB_WORKERS = max(1, int(os.environ.get("DEVPLACE_WEB_WORKERS", "1")))
RATE_LIMIT_DISABLED = os.environ.get("DEVPLACE_DISABLE_RATE_LIMIT") == "1"
HOT_SETTINGS_TTL = 2.0
_hot_settings_value: dict = {}
_hot_settings_at = 0.0
_last_rate_sweep = 0.0
RATE_SWEEP_INTERVAL = 60.0
def _hot_settings() -> dict:
global _hot_settings_value, _hot_settings_at
now = time.monotonic()
if not _hot_settings_value or now - _hot_settings_at >= HOT_SETTINGS_TTL:
_hot_settings_value = {
"maintenance_mode": get_setting("maintenance_mode", "0"),
"rate_limit_per_minute": max(
1, get_int_setting("rate_limit_per_minute", RATE_LIMIT)
),
"rate_limit_window_seconds": max(
1, get_int_setting("rate_limit_window_seconds", RATE_WINDOW)
),
}
_hot_settings_at = now
return _hot_settings_value
def _sweep_rate_limit_store(window_start: float) -> None:
stale = [
ip
for ip, timestamps in _rate_limit_store.items()
if not timestamps or timestamps[-1] <= window_start
]
for ip in stale:
del _rate_limit_store[ip]
def _worker_rate_limit(limit: int) -> int:
return max(1, -(-limit // WEB_WORKERS))
+38
View File
@@ -0,0 +1,38 @@
# retoor <retoor@molodetz.nl>
from fastapi import Request, WebSocket
class TunnelDispatchMiddleware:
def __init__(self, app):
self.app = app
@staticmethod
def _host(scope) -> str:
for key, value in scope.get("headers") or []:
if key == b"host":
return value.decode("latin-1")
return ""
async def __call__(self, scope, receive, send):
if scope["type"] not in ("http", "websocket"):
await self.app(scope, receive, send)
return
from devplacepy.routers import tunnel as tunnel_router
from devplacepy.services.containers.workspace import naming
try:
if not naming.is_tunnel_host(self._host(scope)):
await self.app(scope, receive, send)
return
except Exception:
await self.app(scope, receive, send)
return
path = (scope.get("path") or "/").lstrip("/")
if scope["type"] == "websocket":
websocket = WebSocket(scope, receive, send)
await tunnel_router.handle_ws(websocket, path)
return
request = Request(scope, receive)
response = await tunnel_router.handle_http(request, path)
await response(scope, receive, send)
-1291
View File
File diff suppressed because it is too large Load Diff
+289
View File
@@ -0,0 +1,289 @@
# retoor <retoor@molodetz.nl>
from devplacepy.models.shared import (
normalize_european_date,
normalize_website_url,
normalize_poll_options,
_declared_age,
normalize_index_list,
)
from devplacepy.models.auth import (
SignupForm,
LoginForm,
ForgotPasswordForm,
ResetPasswordForm,
)
from devplacepy.models.content import (
PostForm,
WarJoinForm,
PostEditForm,
CommentForm,
CommentEditForm,
NoteForm,
VoteForm,
ReactionForm,
PollVoteForm,
)
from devplacepy.models.projects import (
ProjectForm,
ProjectEditForm,
ProjectFlagForm,
ProjectScreenshotsForm,
ForkForm,
ProjectFileWriteForm,
ProjectFileMkdirForm,
ProjectFileMoveForm,
ProjectFileDeleteForm,
ProjectFileReplaceLinesForm,
ProjectFileInsertLinesForm,
ProjectFileDeleteLinesForm,
ProjectFileAppendForm,
)
from devplacepy.models.profile import (
CustomizationToggleForm,
AwardGiveForm,
NotificationPrefForm,
AiCorrectionForm,
AiModifierForm,
InteractionsForm,
TelegramPairForm,
ProfileForm,
ConsentForm,
MaturityForm,
MaturePreferenceForm,
AccountDeleteForm,
)
from devplacepy.models.containers import (
ContainerInstanceForm,
ContainerAdminCreateForm,
ContainerEditForm,
ContainerExecForm,
ContainerScheduleForm,
TunnelForm,
EditorPrefsForm,
)
from devplacepy.models.community import (
SkillInstallForm,
SkillEnabledForm,
SkillDeleteForm,
MessageForm,
GistForm,
GistEditForm,
IssueForm,
IssueCommentForm,
IssueAttachmentForm,
IssueStatusForm,
)
from devplacepy.models.uploads import (
UploadUrlForm,
GenerateImageForm,
AttachmentRenameForm,
)
from devplacepy.models.tools import (
PlanningForm,
SeoRunForm,
SEO_URL_PATTERN,
ISSLOP_URL_PATTERN,
ISSLOP_SINGLE_SLASH_PATTERN,
ISSLOP_SCHEME_PATTERN,
IsslopRunForm,
DEEPSEARCH_MIN_DEPTH,
DEEPSEARCH_MAX_DEPTH,
DEEPSEARCH_DEFAULT_DEPTH,
DEEPSEARCH_MIN_PAGES,
DEEPSEARCH_MAX_PAGES,
DEEPSEARCH_DEFAULT_PAGES,
DEEPSEARCH_MIN_QUERY,
DEEPSEARCH_MAX_QUERY,
DEEPSEARCH_MAX_MESSAGE,
DeepsearchRunForm,
DeepsearchChatForm,
)
from devplacepy.models.quizzes import (
QUIZ_KINDS,
QUIZ_OPTION_KINDS,
QuizForm,
QuizQuestionForm,
QuizReorderForm,
QuizAnswerForm,
QuizDocumentOption,
QuizDocumentQuestion,
QuizDocumentSettings,
QuizDocument,
QuizImportForm,
)
from devplacepy.models.game import (
GamePlantForm,
GameSlotForm,
GamePlotStopForm,
GamePerkForm,
GameQuestForm,
GameLegacyForm,
GameInfraForm,
GameCosmeticForm,
GameMasteryForm,
GameEraStartForm,
)
from devplacepy.models.admin import (
CommunityAddForm,
CommunitySchemaForm,
CommunityUpdateForm,
CommunityRunForm,
BackupRunForm,
BackupScheduleForm,
NotificationDefaultForm,
AdminRoleForm,
AdminPasswordForm,
AdminSettingsForm,
WorkspaceQuotaForm,
WorkspaceFlagForm,
WorkspaceSuspendForm,
ReportForm,
ReportStatusForm,
ModerationDecisionForm,
SuspensionForm,
BanForm,
NodeSettingsForm,
NodeFetchForm,
NODE_SUSPEND_MIN_MINUTES,
NODE_SUSPEND_MAX_MINUTES,
NODE_SETTING_FORM_MAX,
AdminNodeKillswitchForm,
AdminNodeSettingsForm,
AdminNodeClassForm,
AdminNodeSuspendForm,
NodeDeviceForm,
)
__all__ = [
"normalize_european_date",
"normalize_website_url",
"normalize_poll_options",
"_declared_age",
"normalize_index_list",
"SignupForm",
"LoginForm",
"ForgotPasswordForm",
"ResetPasswordForm",
"PostForm",
"WarJoinForm",
"PostEditForm",
"CommentForm",
"CommentEditForm",
"NoteForm",
"VoteForm",
"ReactionForm",
"PollVoteForm",
"ProjectForm",
"ProjectEditForm",
"ProjectFlagForm",
"ProjectScreenshotsForm",
"ForkForm",
"ProjectFileWriteForm",
"ProjectFileMkdirForm",
"ProjectFileMoveForm",
"ProjectFileDeleteForm",
"ProjectFileReplaceLinesForm",
"ProjectFileInsertLinesForm",
"ProjectFileDeleteLinesForm",
"ProjectFileAppendForm",
"CustomizationToggleForm",
"AwardGiveForm",
"NotificationPrefForm",
"AiCorrectionForm",
"AiModifierForm",
"InteractionsForm",
"TelegramPairForm",
"ProfileForm",
"ConsentForm",
"MaturityForm",
"MaturePreferenceForm",
"AccountDeleteForm",
"ContainerInstanceForm",
"ContainerAdminCreateForm",
"ContainerEditForm",
"ContainerExecForm",
"ContainerScheduleForm",
"TunnelForm",
"EditorPrefsForm",
"SkillInstallForm",
"SkillEnabledForm",
"SkillDeleteForm",
"MessageForm",
"GistForm",
"GistEditForm",
"IssueForm",
"IssueCommentForm",
"IssueAttachmentForm",
"IssueStatusForm",
"UploadUrlForm",
"GenerateImageForm",
"AttachmentRenameForm",
"PlanningForm",
"SeoRunForm",
"SEO_URL_PATTERN",
"ISSLOP_URL_PATTERN",
"ISSLOP_SINGLE_SLASH_PATTERN",
"ISSLOP_SCHEME_PATTERN",
"IsslopRunForm",
"DEEPSEARCH_MIN_DEPTH",
"DEEPSEARCH_MAX_DEPTH",
"DEEPSEARCH_DEFAULT_DEPTH",
"DEEPSEARCH_MIN_PAGES",
"DEEPSEARCH_MAX_PAGES",
"DEEPSEARCH_DEFAULT_PAGES",
"DEEPSEARCH_MIN_QUERY",
"DEEPSEARCH_MAX_QUERY",
"DEEPSEARCH_MAX_MESSAGE",
"DeepsearchRunForm",
"DeepsearchChatForm",
"QUIZ_KINDS",
"QUIZ_OPTION_KINDS",
"QuizForm",
"QuizQuestionForm",
"QuizReorderForm",
"QuizAnswerForm",
"QuizDocumentOption",
"QuizDocumentQuestion",
"QuizDocumentSettings",
"QuizDocument",
"QuizImportForm",
"GamePlantForm",
"GameSlotForm",
"GamePlotStopForm",
"GamePerkForm",
"GameQuestForm",
"GameLegacyForm",
"GameInfraForm",
"GameCosmeticForm",
"GameMasteryForm",
"GameEraStartForm",
"CommunityAddForm",
"CommunitySchemaForm",
"CommunityUpdateForm",
"CommunityRunForm",
"BackupRunForm",
"BackupScheduleForm",
"NotificationDefaultForm",
"AdminRoleForm",
"AdminPasswordForm",
"AdminSettingsForm",
"WorkspaceQuotaForm",
"WorkspaceFlagForm",
"WorkspaceSuspendForm",
"ReportForm",
"ReportStatusForm",
"ModerationDecisionForm",
"SuspensionForm",
"BanForm",
"NodeSettingsForm",
"NodeFetchForm",
"NODE_SUSPEND_MIN_MINUTES",
"NODE_SUSPEND_MAX_MINUTES",
"NODE_SETTING_FORM_MAX",
"AdminNodeKillswitchForm",
"AdminNodeSettingsForm",
"AdminNodeClassForm",
"AdminNodeSuspendForm",
"NodeDeviceForm",
]
+299
View File
@@ -0,0 +1,299 @@
# retoor <retoor@molodetz.nl>
from typing import Literal, Optional
from urllib.parse import urlsplit
from pydantic import BaseModel, Field, field_validator, model_validator
class CommunityAddForm(BaseModel):
query: str = Field(min_length=2, max_length=200)
login_username: str = Field(default="", max_length=200)
login_email: str = Field(default="", max_length=254)
login_password: str = Field(default="", max_length=500)
login_totp_secret: str = Field(default="", max_length=200)
@field_validator("query")
@classmethod
def trimmed(cls, value):
return value.strip()
class CommunitySchemaForm(BaseModel):
schema_text: str = Field(min_length=2, max_length=300_000, alias="schema")
model_config = {"populate_by_name": True}
class CommunityUpdateForm(BaseModel):
interval_seconds: int = Field(ge=900, le=2592000)
enabled: bool = False
login_username: str = Field(default="", max_length=200)
login_email: str = Field(default="", max_length=254)
login_password: str = Field(default="", max_length=500)
login_totp_secret: str = Field(default="", max_length=200)
clear_login: bool = False
alias: Optional[bool] = None
translate_to: Optional[str] = Field(default=None, max_length=12)
sample_percent: Optional[int] = Field(default=None, ge=1, le=100)
min_comments: Optional[int] = Field(default=None, ge=0, le=1000)
topics: Optional[str] = Field(default=None, max_length=500)
jitter_percent: Optional[int] = Field(default=None, ge=0, le=50)
collect_attachments: Optional[bool] = None
class CommunityRunForm(BaseModel):
dry_run: bool = False
class BackupRunForm(BaseModel):
target: Literal["database", "uploads", "keys", "full"] = "full"
class BackupScheduleForm(BaseModel):
name: str = Field(min_length=1, max_length=120)
target: Literal["database", "uploads", "keys", "full"] = "full"
kind: Literal["interval", "cron"] = "interval"
every_seconds: int = Field(default=86400, ge=60)
cron: str = Field(default="", max_length=120)
keep_last: int = Field(default=7, ge=0, le=1000)
@model_validator(mode="after")
def _validate_schedule(self) -> "BackupScheduleForm":
if self.kind == "cron" and not self.cron.strip():
raise ValueError("A cron schedule requires a cron expression")
return self
class NotificationDefaultForm(BaseModel):
notification_type: str = Field(min_length=1, max_length=40)
channel: Literal["in_app", "push", "telegram"]
value: bool = False
class AdminRoleForm(BaseModel):
role: Literal["member", "admin"]
class AdminPasswordForm(BaseModel):
password: str = Field(min_length=6, max_length=128)
class AdminSettingsForm(BaseModel):
site_name: str = Field(default="", max_length=200)
site_description: str = Field(default="", max_length=500)
site_tagline: str = Field(default="", max_length=500)
site_url: str = Field(default="", max_length=300)
ios_app_url: str = Field(default="", max_length=300)
max_upload_size_mb: str = Field(default="", max_length=10)
allowed_file_types: str = Field(default="", max_length=1000)
max_attachments_per_resource: str = Field(default="", max_length=10)
rate_limit_per_minute: str = Field(default="", max_length=10)
rate_limit_window_seconds: str = Field(default="", max_length=10)
session_max_age_days: str = Field(default="", max_length=10)
session_remember_days: str = Field(default="", max_length=10)
registration_open: str = Field(default="", max_length=1)
maintenance_mode: str = Field(default="", max_length=1)
maintenance_message: str = Field(default="", max_length=300)
happy_404_enabled: str = Field(default="", max_length=1)
docs_search_mode: str = Field(default="", max_length=20)
outbound_proxy_url: str = Field(default="", max_length=500)
moderation_sla_hours: str = Field(default="", max_length=10)
moderation_filter_mode: str = Field(default="", max_length=10)
moderation_minimum_age: str = Field(default="", max_length=3)
moderation_mature_default_hidden: str = Field(default="", max_length=1)
account_deletion_grace_hours: str = Field(default="", max_length=10)
contact_email: str = Field(default="", max_length=200)
contact_phone: str = Field(default="", max_length=60)
contact_address: str = Field(default="", max_length=500)
terms_version: str = Field(default="", max_length=20)
privacy_version: str = Field(default="", max_length=20)
guidelines_version: str = Field(default="", max_length=20)
ai_third_party_provider: str = Field(default="", max_length=120)
correction_model: str = Field(default="", max_length=120)
modifier_model: str = Field(default="", max_length=120)
quiz_grading_model: str = Field(default="", max_length=120)
extra_head: str = Field(default="", max_length=50000)
sync_enabled: str = Field(default="", max_length=1)
sync_origins: str = Field(default="", max_length=1000)
@field_validator("sync_origins")
@classmethod
def validate_sync_origins(cls, value):
from devplacepy.services.sync.normalize import normalize_origin
items = [item for item in value.replace("\n", ",").split(",") if item.strip()]
return ",".join(dict.fromkeys(normalize_origin(item) for item in items))
@field_validator("moderation_filter_mode")
@classmethod
def validate_filter_mode(cls, value):
from devplacepy.services.moderation.rules import FILTER_MODES
if value and value not in FILTER_MODES:
raise ValueError(f"Filter mode must be one of {', '.join(FILTER_MODES)}")
return value
@field_validator("outbound_proxy_url")
@classmethod
def validate_outbound_proxy_url(cls, value):
text = value.strip()
if not text:
return text
parsed = urlsplit(text)
if parsed.scheme not in ("http", "https", "socks5", "socks5h") or not parsed.hostname:
raise ValueError("Proxy URL must be http(s):// or socks5(h):// with a host, e.g. http://user:pass@host:port")
return text
class WorkspaceQuotaForm(BaseModel):
owner_id: str = Field(default="", max_length=36)
label: str = Field(default="", max_length=64)
max_workspaces: int = Field(default=0, ge=0, le=100)
max_tunnels: int = Field(default=0, ge=0, le=100)
disk_quota_mb: int = Field(default=0, ge=0)
egress_quota_mb: int = Field(default=0, ge=0)
idle_stop_minutes: int = Field(default=0, ge=0)
retention_days: int = Field(default=0, ge=0)
cpu_millicores: int = Field(default=0, ge=0, le=64000)
memory_mb: int = Field(default=0, ge=0, le=1048576)
class WorkspaceFlagForm(BaseModel):
kind: str = Field(default="manual", max_length=40)
severity: str = Field(default="warn", max_length=16)
detail: str = Field(default="", max_length=500)
class WorkspaceSuspendForm(BaseModel):
reason: str = Field(default="", max_length=500)
class ReportForm(BaseModel):
reason: str = Field(min_length=1, max_length=40)
detail: str = Field(default="", max_length=2000)
@field_validator("reason")
@classmethod
def known_reason(cls, value):
from devplacepy.database.moderation import REPORT_REASONS
if value not in REPORT_REASONS:
raise ValueError("Unknown report reason")
return value
class ReportStatusForm(BaseModel):
status: str = Field(default="acknowledged", max_length=20)
@field_validator("status")
@classmethod
def known_status(cls, value):
from devplacepy.database.moderation import REPORT_STATUSES
if value not in REPORT_STATUSES:
raise ValueError("Unknown report status")
return value
class ModerationDecisionForm(BaseModel):
action: str = Field(min_length=1, max_length=30)
reason: str = Field(default="", max_length=200)
notes: str = Field(default="", max_length=2000)
duration_hours: int = Field(default=24, ge=1, le=8760)
@field_validator("action")
@classmethod
def known_action(cls, value):
from devplacepy.database.moderation import MODERATION_ACTIONS
if value not in MODERATION_ACTIONS:
raise ValueError("Unknown moderation action")
return value
class SuspensionForm(BaseModel):
reason: str = Field(default="", max_length=200)
duration_hours: int = Field(default=24, ge=1, le=8760)
class BanForm(BaseModel):
reason: str = Field(default="", max_length=200)
class NodeSettingsForm(BaseModel):
enabled: Optional[bool] = None
bandwidth_mb_day: Optional[int] = Field(default=None, ge=1, le=20 * 1024)
max_concurrent: Optional[int] = Field(default=None, ge=1, le=16)
active_from: Optional[str] = Field(default=None, max_length=5)
active_to: Optional[str] = Field(default=None, max_length=5)
timezone: Optional[str] = Field(default=None, max_length=64)
allowed_platforms: Optional[list[str]] = Field(default=None, max_length=16)
pause_on_battery: Optional[bool] = None
public_display: Optional[bool] = None
@field_validator("allowed_platforms", mode="before")
@classmethod
def split_platforms(cls, value):
if isinstance(value, str):
return [item.strip() for item in value.split(",") if item.strip()]
return value
class NodeFetchForm(BaseModel):
url: str = Field(max_length=2048)
cache: bool = True
timeout: int = Field(default=15, ge=1, le=60)
NODE_SUSPEND_MIN_MINUTES = 1
NODE_SUSPEND_MAX_MINUTES = 10080
NODE_SETTING_FORM_MAX = 1_000_000
class AdminNodeKillswitchForm(BaseModel):
enabled: Optional[bool] = None
class AdminNodeSettingsForm(BaseModel):
nodes_max_total: Optional[int] = Field(default=None, ge=0, le=NODE_SETTING_FORM_MAX)
nodes_daily_quota_user: Optional[int] = Field(default=None, ge=0, le=NODE_SETTING_FORM_MAX)
nodes_daily_quota_guest: Optional[int] = Field(default=None, ge=0, le=NODE_SETTING_FORM_MAX)
nodes_cache_ttl_seconds: Optional[int] = Field(default=None, ge=0, le=NODE_SETTING_FORM_MAX)
nodes_social_via_nodes: Optional[Literal["0", "1"]] = None
nodes_daily_social_user: Optional[int] = Field(default=None, ge=0, le=NODE_SETTING_FORM_MAX)
nodes_allow_unknown_class: Optional[Literal["0", "1"]] = None
nodes_redundancy_percent: Optional[int] = Field(default=None, ge=0, le=NODE_SETTING_FORM_MAX)
class AdminNodeClassForm(BaseModel):
value: Literal["allow", "clear"]
class AdminNodeSuspendForm(BaseModel):
minutes: int = Field(ge=NODE_SUSPEND_MIN_MINUTES, le=NODE_SUSPEND_MAX_MINUTES)
reason: str = Field(default="", max_length=500)
@field_validator("reason")
@classmethod
def trimmed(cls, value):
return value.strip()
class NodeDeviceForm(BaseModel):
label: str = Field(default="", max_length=60)
kind: str = Field(default="native", max_length=20)
platform: str = Field(default="", max_length=40)
client_version: str = Field(default="", max_length=40)
@field_validator("kind")
@classmethod
def known_kind(cls, value):
from devplacepy.services.nodes.spec import DEVICE_KINDS
if value not in DEVICE_KINDS:
raise ValueError("Unknown device kind")
return value
+95
View File
@@ -0,0 +1,95 @@
# retoor <retoor@molodetz.nl>
from pydantic import BaseModel, Field, field_validator, model_validator
from devplacepy.models.shared import _declared_age
class SignupForm(BaseModel):
username: str = Field(min_length=3, max_length=32)
email: str = Field(min_length=1, max_length=255)
password: str = Field(min_length=6, max_length=128)
confirm_password: str = Field(min_length=1, max_length=128)
birth_date: str = Field(min_length=1, max_length=20)
accept_terms: str = Field(default="")
@property
def age_band(self) -> str:
from devplacepy.database.moderation import age_band_for
return age_band_for(_declared_age(self.birth_date))
@field_validator("birth_date")
@classmethod
def old_enough(cls, value):
from devplacepy.database import minimum_age
minimum = minimum_age()
if _declared_age(value) < minimum:
raise ValueError(f"You must be at least {minimum} years old to join")
return value
@field_validator("accept_terms")
@classmethod
def terms_accepted(cls, value):
if value.strip().lower() not in ("1", "on", "true", "yes"):
raise ValueError(
"You must accept the Terms of Service and Community Guidelines"
)
return value
@field_validator("username")
@classmethod
def username_chars(cls, value):
if not value.isascii() or not all(
c.isalnum() or c in ("-", "_") for c in value
):
raise ValueError(
"Username can only contain letters, numbers, hyphens, and underscores"
)
from devplacepy.services.moderation.filter import classify
if classify(value).verdict == "block":
raise ValueError("That username breaks the community guidelines")
return value
@field_validator("email")
@classmethod
def email_has_at(cls, value):
if "@" not in value:
raise ValueError("Valid email is required")
return value
@model_validator(mode="after")
def passwords_match(self):
if self.password != self.confirm_password:
raise ValueError("Passwords do not match")
return self
class LoginForm(BaseModel):
email: str = Field(min_length=1, max_length=255)
password: str = Field(min_length=1, max_length=128)
remember_me: str = ""
next: str = ""
class ForgotPasswordForm(BaseModel):
email: str = Field(min_length=1, max_length=255)
@field_validator("email")
@classmethod
def email_has_at(cls, value):
if "@" not in value:
raise ValueError("Valid email is required")
return value
class ResetPasswordForm(BaseModel):
password: str = Field(min_length=6, max_length=128)
confirm_password: str = Field(min_length=1, max_length=128)
@model_validator(mode="after")
def passwords_match(self):
if self.password != self.confirm_password:
raise ValueError("Passwords do not match")
return self
+57
View File
@@ -0,0 +1,57 @@
# retoor <retoor@molodetz.nl>
from typing import Literal, Optional
from pydantic import BaseModel, Field
class SkillInstallForm(BaseModel):
url: str = Field(min_length=1, max_length=2000)
class SkillEnabledForm(BaseModel):
enabled: Optional[bool] = None
class SkillDeleteForm(BaseModel):
owner: str = Field(default="", max_length=32)
class MessageForm(BaseModel):
content: str = Field(min_length=0, max_length=2000)
receiver_uid: str = Field(min_length=1, max_length=36)
attachment_uids: list[str] = []
client_id: Optional[str] = Field(default=None, max_length=64)
class GistForm(BaseModel):
title: str = Field(min_length=1, max_length=200)
description: str = Field(default="", max_length=5000)
source_code: str = Field(min_length=1, max_length=400000)
language: str = Field(default="plaintext", max_length=50)
attachment_uids: list[str] = []
class GistEditForm(BaseModel):
title: str = Field(min_length=1, max_length=200)
description: str = Field(default="", max_length=5000)
source_code: str = Field(min_length=1, max_length=400000)
language: str = Field(default="plaintext", max_length=50)
class IssueForm(BaseModel):
title: str = Field(min_length=1, max_length=200)
description: str = Field(min_length=1, max_length=5000)
attachment_uids: list[str] = []
class IssueCommentForm(BaseModel):
body: str = Field(min_length=1, max_length=5000)
attachment_uids: list[str] = []
class IssueAttachmentForm(BaseModel):
attachment_uids: list[str] = []
class IssueStatusForm(BaseModel):
status: Literal["open", "closed"]
+78
View File
@@ -0,0 +1,78 @@
# retoor <retoor@molodetz.nl>
from typing import Optional
from pydantic import BaseModel, Field, field_validator
class ContainerInstanceForm(BaseModel):
name: str = Field(min_length=1, max_length=64)
boot_command: str = Field(default="", max_length=500)
boot_language: str = Field(default="none", max_length=10)
boot_script: str = Field(default="", max_length=100000)
run_as_uid: str = Field(default="", max_length=36)
start_on_boot: bool = False
env: str = Field(default="", max_length=10000)
cpu_limit: str = Field(default="", max_length=16)
mem_limit: str = Field(default="", max_length=16)
ports: str = Field(default="", max_length=500)
volumes: str = Field(default="", max_length=2000)
restart_policy: str = Field(default="never", max_length=20)
autostart: bool = True
ingress_slug: str = Field(default="", max_length=64)
ingress_port: Optional[int] = Field(default=None, ge=1, le=65535)
@field_validator("ingress_port", mode="before")
@classmethod
def _blank_ingress_port(cls, value):
if value is None or (isinstance(value, str) and not value.strip()):
return None
return value
class ContainerAdminCreateForm(ContainerInstanceForm):
project_slug: str = Field(min_length=1, max_length=128)
class ContainerEditForm(BaseModel):
run_as_uid: str = Field(default="", max_length=36)
boot_language: str = Field(default="none", max_length=10)
boot_script: str = Field(default="", max_length=100000)
boot_command: str = Field(default="", max_length=500)
restart_policy: str = Field(default="never", max_length=20)
start_on_boot: bool = False
cpu_limit: str = Field(default="", max_length=16)
mem_limit: str = Field(default="", max_length=16)
class ContainerExecForm(BaseModel):
command: str = Field(min_length=1, max_length=2000)
class ContainerScheduleForm(BaseModel):
action: str = Field(max_length=10)
kind: str = Field(max_length=10)
cron: str = Field(default="", max_length=120)
run_at: str = Field(default="", max_length=40)
delay_seconds: Optional[int] = Field(default=None, ge=1)
every_seconds: Optional[int] = Field(default=None, ge=1)
max_runs: Optional[int] = Field(default=None, ge=1)
class TunnelForm(BaseModel):
label: str = Field(default="", max_length=64)
container_port: int = Field(default=0, ge=0, le=65535)
class EditorPrefsForm(BaseModel):
font_size: int = Field(default=0, ge=0, le=48)
terminal_font_size: int = Field(default=0, ge=0, le=48)
zoom_level: int = Field(default=-99, ge=-99, le=5)
theme: str = Field(default="", max_length=32)
layout: str = Field(default="", max_length=32)
panel_preset: str = Field(default="", max_length=32)
window_mode: str = Field(default="", max_length=32)
window_width: int = Field(default=0, ge=0, le=7680)
window_height: int = Field(default=0, ge=0, le=4320)
boot_agent: str = Field(default="", max_length=32)
boot_shell: int = Field(default=-1, ge=-1, le=1)
reset: bool = False
+128
View File
@@ -0,0 +1,128 @@
# retoor <retoor@molodetz.nl>
from typing import Literal
from pydantic import BaseModel, Field, field_validator, model_validator
from devplacepy.constants import TOPICS
from devplacepy.rendering import is_single_emoji
from devplacepy.models.shared import normalize_poll_options
class PostForm(BaseModel):
content: str = Field(min_length=10, max_length=125000)
title: str = Field(default="", max_length=500)
topic: str = "random"
project_uid: str = Field(default="", max_length=36)
attachment_uids: list[str] = []
poll_question: str = Field(default="", max_length=200)
poll_options: list[str] = []
war_faction_a: str = Field(default="", max_length=30)
war_faction_b: str = Field(default="", max_length=30)
@field_validator("poll_options")
@classmethod
def poll_options_max_length(cls, value):
if value is None:
return value
for opt in value:
if isinstance(opt, str) and len(opt) > 200:
raise ValueError("Each poll option must be 200 characters or fewer")
return value
@field_validator("topic")
@classmethod
def valid_topic(cls, value):
return value if value in TOPICS else "random"
@field_validator("poll_options", mode="before")
@classmethod
def split_poll_options(cls, value):
return normalize_poll_options(value)
class WarJoinForm(BaseModel):
faction: str
@field_validator("faction")
@classmethod
def valid_faction(cls, value):
if value not in ("a", "b"):
raise ValueError("Faction must be a or b")
return value
class PostEditForm(BaseModel):
content: str = Field(min_length=10, max_length=125000)
title: str = Field(default="", max_length=500)
topic: str = "random"
poll_question: str = Field(default="", max_length=200)
poll_options: list[str] = []
@field_validator("poll_options")
@classmethod
def poll_options_max_length(cls, value):
if value is None:
return value
for opt in value:
if isinstance(opt, str) and len(opt) > 200:
raise ValueError("Each poll option must be 200 characters or fewer")
return value
@field_validator("topic")
@classmethod
def valid_topic(cls, value):
return value if value in TOPICS else "random"
@field_validator("poll_options", mode="before")
@classmethod
def split_poll_options(cls, value):
return normalize_poll_options(value)
class CommentForm(BaseModel):
content: str = Field(min_length=3, max_length=125000)
target_uid: str = Field(default="", max_length=36)
post_uid: str = Field(default="", max_length=36)
target_type: Literal["post", "project", "news", "issue", "gist", "quiz"] = "post"
parent_uid: str = Field(default="", max_length=36)
attachment_uids: list[str] = []
@model_validator(mode="after")
def require_target(self):
if not (self.target_uid or self.post_uid):
raise ValueError("A target is required")
return self
class CommentEditForm(BaseModel):
content: str = Field(min_length=3, max_length=125000)
class NoteForm(BaseModel):
content: str = Field(min_length=1, max_length=4000)
class VoteForm(BaseModel):
value: int
@field_validator("value")
@classmethod
def valid_value(cls, value):
if value not in (1, -1, 0):
raise ValueError("value must be 1, -1 or 0")
return value
class ReactionForm(BaseModel):
emoji: str = Field(min_length=1, max_length=16)
@field_validator("emoji")
@classmethod
def valid_emoji(cls, value):
reaction = (value or "").strip()
if not is_single_emoji(reaction):
raise ValueError("Reaction must be a single emoji")
return reaction
class PollVoteForm(BaseModel):
option_uid: str = Field(min_length=1, max_length=36)
+47
View File
@@ -0,0 +1,47 @@
# retoor <retoor@molodetz.nl>
from pydantic import BaseModel, Field
class GamePlantForm(BaseModel):
slot: int = Field(ge=0, le=64)
crop: str = Field(min_length=1, max_length=40)
class GameSlotForm(BaseModel):
slot: int = Field(ge=0, le=64)
class GamePlotStopForm(BaseModel):
slot: int = Field(ge=0, le=64)
stop: bool
class GamePerkForm(BaseModel):
perk: str = Field(min_length=1, max_length=40)
class GameQuestForm(BaseModel):
quest: str = Field(min_length=1, max_length=40)
scope: str = Field(default="daily", min_length=1, max_length=10)
class GameLegacyForm(BaseModel):
key: str = Field(min_length=1, max_length=40)
class GameInfraForm(BaseModel):
key: str = Field(min_length=1, max_length=40)
class GameCosmeticForm(BaseModel):
key: str = Field(min_length=1, max_length=40)
class GameMasteryForm(BaseModel):
key: str = Field(min_length=1, max_length=40)
class GameEraStartForm(BaseModel):
name: str = Field(min_length=1, max_length=60)
duration_days: int = Field(default=28, ge=1, le=180)
+86
View File
@@ -0,0 +1,86 @@
# retoor <retoor@molodetz.nl>
from typing import Literal
from pydantic import BaseModel, Field, field_validator
from devplacepy.config import (
DEFAULT_CORRECTION_PROMPT,
DEFAULT_MODIFIER_PROMPT,
)
class CustomizationToggleForm(BaseModel):
value: bool = False
class AwardGiveForm(BaseModel):
description: str = Field(min_length=1, max_length=125)
class NotificationPrefForm(BaseModel):
notification_type: str = Field(min_length=1, max_length=40)
channel: Literal["in_app", "push", "telegram"]
value: bool = False
class AiCorrectionForm(BaseModel):
enabled: bool = False
sync: bool = False
prompt: str = Field(default=DEFAULT_CORRECTION_PROMPT, max_length=20000)
class AiModifierForm(BaseModel):
enabled: bool = False
sync: bool = False
prompt: str = Field(default=DEFAULT_MODIFIER_PROMPT, max_length=20000)
class InteractionsForm(BaseModel):
enabled: bool = True
reset: bool = False
class TelegramPairForm(BaseModel):
action: str = Field(default="request", max_length=16)
class ProfileForm(BaseModel):
bio: str = Field(default="", max_length=500)
location: str = Field(default="", max_length=200)
git_link: str = Field(default="", max_length=500)
website: str = Field(default="", max_length=500)
class ConsentForm(BaseModel):
kind: str = Field(min_length=1, max_length=40)
granted: str = Field(default="0", max_length=5)
@field_validator("kind")
@classmethod
def known_kind(cls, value):
from devplacepy.database.moderation import CONSENT_KINDS
if value not in CONSENT_KINDS:
raise ValueError("Unknown consent kind")
return value
class MaturityForm(BaseModel):
level: str = Field(default="general", max_length=20)
@field_validator("level")
@classmethod
def known_level(cls, value):
from devplacepy.database.moderation import MATURITY_LEVELS
if value not in MATURITY_LEVELS:
raise ValueError("Unknown maturity level")
return value
class MaturePreferenceForm(BaseModel):
mature_opt_in: str = Field(default="0", max_length=5)
class AccountDeleteForm(BaseModel):
password: str = Field(min_length=1, max_length=128)
confirm_text: str = Field(default="", max_length=40)
+113
View File
@@ -0,0 +1,113 @@
# retoor <retoor@molodetz.nl>
from typing import Literal
from pydantic import BaseModel, Field, field_validator
from devplacepy.models.shared import normalize_european_date, normalize_website_url
class ProjectForm(BaseModel):
title: str = Field(min_length=1, max_length=200)
description: str = Field(min_length=1, max_length=5000)
release_date: str = ""
demo_date: str = ""
project_type: Literal["game", "game_asset", "software", "mobile_app", "website"] = (
"software"
)
platforms: str = Field(default="", max_length=500)
status: str = Field(default="In Development", max_length=100)
website_url: str = Field(default="", max_length=500)
repo_url: str = Field(default="", max_length=500)
cover_attachment_uid: str = Field(default="", max_length=64)
logo_attachment_uid: str = Field(default="", max_length=64)
is_private: bool = False
attachment_uids: list[str] = []
@field_validator("release_date", "demo_date", mode="before")
@classmethod
def normalize_dates(cls, value):
return normalize_european_date(value)
@field_validator("website_url", "repo_url")
@classmethod
def valid_link_url(cls, value):
return normalize_website_url(value)
class ProjectEditForm(BaseModel):
title: str = Field(min_length=1, max_length=200)
description: str = Field(min_length=1, max_length=5000)
release_date: str = ""
demo_date: str = ""
project_type: Literal["game", "game_asset", "software", "mobile_app", "website"] = (
"software"
)
platforms: str = Field(default="", max_length=500)
status: str = Field(default="In Development", max_length=100)
website_url: str = Field(default="", max_length=500)
repo_url: str = Field(default="", max_length=500)
cover_attachment_uid: str = Field(default="", max_length=64)
logo_attachment_uid: str = Field(default="", max_length=64)
@field_validator("release_date", "demo_date", mode="before")
@classmethod
def normalize_dates(cls, value):
return normalize_european_date(value)
@field_validator("website_url", "repo_url")
@classmethod
def valid_link_url(cls, value):
return normalize_website_url(value)
class ProjectFlagForm(BaseModel):
value: bool = False
class ProjectScreenshotsForm(BaseModel):
attachment_uids: list[str] = []
class ForkForm(BaseModel):
title: str = Field(min_length=1, max_length=200)
class ProjectFileWriteForm(BaseModel):
path: str = Field(min_length=1, max_length=1024)
content: str = Field(default="", max_length=400000)
class ProjectFileMkdirForm(BaseModel):
path: str = Field(min_length=1, max_length=1024)
class ProjectFileMoveForm(BaseModel):
from_path: str = Field(min_length=1, max_length=1024)
to_path: str = Field(min_length=1, max_length=1024)
class ProjectFileDeleteForm(BaseModel):
path: str = Field(min_length=1, max_length=1024)
class ProjectFileReplaceLinesForm(BaseModel):
path: str = Field(min_length=1, max_length=1024)
start: int = Field(ge=1)
end: int = Field(ge=0)
content: str = Field(default="", max_length=400000)
class ProjectFileInsertLinesForm(BaseModel):
path: str = Field(min_length=1, max_length=1024)
at: int = Field(ge=1)
content: str = Field(default="", max_length=400000)
class ProjectFileDeleteLinesForm(BaseModel):
path: str = Field(min_length=1, max_length=1024)
start: int = Field(ge=1)
end: int = Field(ge=1)
class ProjectFileAppendForm(BaseModel):
path: str = Field(min_length=1, max_length=1024)
content: str = Field(default="", max_length=400000)
+247
View File
@@ -0,0 +1,247 @@
# retoor <retoor@molodetz.nl>
import json
from typing import Literal
from pydantic import BaseModel, Field, field_validator, model_validator
from devplacepy.config import (
QUIZ_ANSWER_MAX_CHARS,
QUIZ_MAX_OPTIONS,
QUIZ_MAX_QUESTIONS,
QUIZ_MAX_TIME_LIMIT_SECONDS,
)
from devplacepy.models.shared import normalize_poll_options, normalize_index_list
QUIZ_KINDS = (
"single_choice",
"multiple_choice",
"true_false",
"free_text",
"fill_blank",
"numeric",
"ordering",
"matching",
)
QUIZ_OPTION_KINDS = frozenset(
{"single_choice", "multiple_choice", "fill_blank", "ordering", "matching"}
)
class QuizForm(BaseModel):
title: str = Field(min_length=3, max_length=200)
description: str = Field(default="", max_length=5000)
shuffle_questions: bool = False
shuffle_options: bool = False
reveal_answers: bool = False
allow_review: bool = True
time_limit_seconds: int = Field(default=0, ge=0, le=QUIZ_MAX_TIME_LIMIT_SECONDS)
pass_percent: int = Field(default=0, ge=0, le=100)
class QuizQuestionForm(BaseModel):
kind: Literal[QUIZ_KINDS]
prompt: str = Field(min_length=1, max_length=2000)
explanation: str = Field(default="", max_length=2000)
points: int = Field(default=1, ge=1, le=100)
media_attachment_uid: str = Field(default="", max_length=36)
correct_boolean: bool = False
expected_answer: str = Field(default="", max_length=2000)
grading_criteria: str = Field(default="", max_length=2000)
numeric_value: float = 0.0
numeric_tolerance: float = Field(default=0.0, ge=0.0)
case_sensitive: bool = False
options: list[str] = []
match_values: list[str] = []
correct_indexes: list[int] = []
@field_validator("options", "match_values", mode="before")
@classmethod
def split_lists(cls, value):
return normalize_poll_options(value)
@field_validator("correct_indexes", mode="before")
@classmethod
def split_indexes(cls, value):
return normalize_index_list(value)
@field_validator("options", "match_values")
@classmethod
def bounded_options(cls, value):
if len(value) > QUIZ_MAX_OPTIONS:
raise ValueError(f"A question takes at most {QUIZ_MAX_OPTIONS} options")
for entry in value:
if len(entry) > 500:
raise ValueError("Each option must be 500 characters or fewer")
return value
@model_validator(mode="after")
def kind_requirements(self):
options = [option for option in self.options if option.strip()]
if self.kind in QUIZ_OPTION_KINDS and not options:
raise ValueError("This question type needs at least one option")
if self.kind in ("single_choice", "multiple_choice") and len(options) < 2:
raise ValueError("Choice questions need at least two options")
if self.kind == "single_choice" and len(self.correct_indexes) != 1:
raise ValueError("A single choice question needs exactly one correct option")
if self.kind == "multiple_choice" and not self.correct_indexes:
raise ValueError("A multiple choice question needs at least one correct option")
if self.kind in ("fill_blank", "matching") and len(self.match_values) < len(options):
raise ValueError("Every option needs an accepted answer")
if self.kind == "matching" and len(options) < 2:
raise ValueError("A matching question needs at least two pairs")
if self.kind == "ordering" and len(options) < 2:
raise ValueError("An ordering question needs at least two items")
if self.kind == "free_text" and not (
self.expected_answer.strip() or self.grading_criteria.strip()
):
raise ValueError("A free text question needs a reference answer or grading criteria")
return self
def option_rows(self) -> list[dict]:
rows = []
for index, label in enumerate(self.options):
if not label.strip():
continue
match_value = (
self.match_values[index] if index < len(self.match_values) else ""
)
rows.append(
{
"label": label.strip(),
"match_value": match_value.strip(),
"is_correct": index in set(self.correct_indexes),
}
)
return rows
class QuizReorderForm(BaseModel):
order: list[str] = []
@field_validator("order", mode="before")
@classmethod
def split_order(cls, value):
return normalize_poll_options(value)
@model_validator(mode="after")
def require_order(self):
if not self.order:
raise ValueError("The new question order is required")
return self
class QuizAnswerForm(BaseModel):
question_uid: str = Field(min_length=1, max_length=36)
answer_text: str = Field(default="", max_length=QUIZ_ANSWER_MAX_CHARS)
option_uids: list[str] = []
blanks: list[str] = []
matches: list[str] = []
@field_validator("option_uids", "blanks", "matches", mode="before")
@classmethod
def split_lists(cls, value):
return normalize_poll_options(value)
def submission(self) -> dict:
if self.blanks:
return {
"answer_text": json.dumps(self.blanks, ensure_ascii=False),
"option_uids": self.option_uids,
}
if self.matches:
pairs = dict(zip(self.option_uids, self.matches))
return {
"answer_text": json.dumps(pairs, ensure_ascii=False),
"option_uids": self.option_uids,
}
return {"answer_text": self.answer_text, "option_uids": self.option_uids}
class QuizDocumentOption(BaseModel):
label: str = Field(default="", max_length=500)
match_value: str = Field(default="", max_length=500)
is_correct: bool = False
class QuizDocumentQuestion(BaseModel):
kind: Literal[QUIZ_KINDS]
prompt: str = Field(min_length=1, max_length=2000)
explanation: str = Field(default="", max_length=2000)
points: int = Field(default=1, ge=1, le=100)
media_attachment_uid: str = Field(default="", max_length=36)
correct_boolean: bool = False
expected_answer: str = Field(default="", max_length=2000)
grading_criteria: str = Field(default="", max_length=2000)
numeric_value: float = 0.0
numeric_tolerance: float = Field(default=0.0, ge=0.0)
case_sensitive: bool = False
options: list[QuizDocumentOption] = Field(default_factory=list, max_length=QUIZ_MAX_OPTIONS)
@model_validator(mode="after")
def kind_requirements(self):
labelled = [option for option in self.options if option.label.strip()]
if self.kind in ("single_choice", "multiple_choice") and len(labelled) < 2:
raise ValueError("Choice questions need at least two options")
if self.kind == "single_choice" and sum(
1 for option in labelled if option.is_correct
) != 1:
raise ValueError("A single choice question needs exactly one correct option")
if self.kind == "multiple_choice" and not any(
option.is_correct for option in labelled
):
raise ValueError("A multiple choice question needs at least one correct option")
if self.kind in ("ordering", "matching") and len(labelled) < 2:
raise ValueError("This question type needs at least two entries")
if self.kind == "matching" and any(
not option.match_value.strip() for option in labelled
):
raise ValueError("Every matching pair needs a right-hand value")
if self.kind == "fill_blank" and any(
not option.match_value.strip() for option in labelled
):
raise ValueError("Every blank needs an accepted answer")
if self.kind == "free_text" and not (
self.expected_answer.strip() or self.grading_criteria.strip()
):
raise ValueError("A free text question needs a reference answer or grading criteria")
return self
class QuizDocumentSettings(BaseModel):
shuffle_questions: bool = False
shuffle_options: bool = False
reveal_answers: bool = False
allow_review: bool = True
time_limit_seconds: int = Field(default=0, ge=0, le=QUIZ_MAX_TIME_LIMIT_SECONDS)
pass_percent: int = Field(default=0, ge=0, le=100)
class QuizDocument(BaseModel):
title: str = Field(min_length=3, max_length=200)
description: str = Field(default="", max_length=5000)
settings: QuizDocumentSettings = Field(default_factory=QuizDocumentSettings)
questions: list[QuizDocumentQuestion] = Field(
default_factory=list, max_length=QUIZ_MAX_QUESTIONS
)
@model_validator(mode="after")
def require_questions(self):
if not self.questions:
raise ValueError("A quiz document needs at least one question")
return self
class QuizImportForm(BaseModel):
document: QuizDocument
@field_validator("document", mode="before")
@classmethod
def parse_document(cls, value):
if isinstance(value, str):
try:
return json.loads(value)
except ValueError as exc:
raise ValueError("document must be valid JSON") from exc
return value
+75
View File
@@ -0,0 +1,75 @@
# retoor <retoor@molodetz.nl>
from datetime import datetime
from urllib.parse import urlparse
def normalize_european_date(value):
if not value:
return ""
text = str(value).strip()
if not text:
return ""
for fmt in ("%d/%m/%Y", "%Y-%m-%d"):
try:
return datetime.strptime(text, fmt).strftime("%Y-%m-%d")
except ValueError:
continue
raise ValueError("Date must be in DD/MM/YYYY format")
def normalize_website_url(value):
if not value:
return ""
text = str(value).strip()
if not text:
return ""
if not text.lower().startswith(("http://", "https://")):
text = f"https://{text}"
parsed = urlparse(text)
if parsed.scheme not in ("http", "https") or not parsed.hostname or "." not in parsed.hostname:
raise ValueError("Website must be a valid http(s) URL")
return text
def normalize_poll_options(value):
if value is None:
return []
if isinstance(value, str):
value = [value]
if not isinstance(value, list):
return value
if len(value) == 1 and isinstance(value[0], str):
single = value[0]
separator = "\n" if "\n" in single else ("," if "," in single else "")
if separator:
return [part.strip() for part in single.split(separator) if part.strip()]
return value
def _declared_age(birth_date: str) -> int:
from datetime import date
from devplacepy.database.moderation import years_between
normalized = normalize_european_date(birth_date)
if not normalized:
raise ValueError("Date of birth is required")
born = date.fromisoformat(normalized)
today = date.today()
if born > today:
raise ValueError("Date of birth cannot be in the future")
return years_between(born, today)
def normalize_index_list(value):
parts = normalize_poll_options(value)
if not isinstance(parts, list):
return []
indexes = []
for part in parts:
try:
indexes.append(int(str(part).strip()))
except (TypeError, ValueError):
continue
return indexes
+119
View File
@@ -0,0 +1,119 @@
# retoor <retoor@molodetz.nl>
import re
from typing import Literal
from pydantic import BaseModel, Field, field_validator
class PlanningForm(BaseModel):
numbers: str = Field(default="", max_length=4096)
class SeoRunForm(BaseModel):
url: str = Field(min_length=3, max_length=2000)
mode: Literal["url", "sitemap"] = "url"
max_pages: int = Field(default=10, ge=1, le=50)
@field_validator("url")
@classmethod
def url_scheme(cls, value):
text = value.strip()
if not text:
raise ValueError("A URL is required")
if "://" in text:
scheme = text.split("://", 1)[0]
if scheme not in ("http", "https"):
raise ValueError(f"Only http and https URLs are allowed; got '{scheme}://'")
else:
text = f"https://{text}"
if not SEO_URL_PATTERN.match(text):
raise ValueError("URL must be a valid http or https source location")
return text
SEO_URL_PATTERN = re.compile(r"^https?://[a-zA-Z0-9][\w./:@~^?&#%=;-]*$")
ISSLOP_URL_PATTERN = re.compile(r"^(https?://|git://|ssh://|git@)[\w./:@~^-]+$", re.IGNORECASE)
ISSLOP_SINGLE_SLASH_PATTERN = re.compile(r"^(https?|git|ssh):/(?!/)", re.IGNORECASE)
ISSLOP_SCHEME_PATTERN = re.compile(r"^[a-z][a-z0-9+.-]*://", re.IGNORECASE)
class IsslopRunForm(BaseModel):
url: str = Field(min_length=4, max_length=2048)
@field_validator("url")
@classmethod
def url_scheme(cls, value):
text = value.strip()
text = ISSLOP_SINGLE_SLASH_PATTERN.sub(lambda match: f"{match.group(1)}://", text)
if not ISSLOP_SCHEME_PATTERN.match(text) and not text.startswith("git@"):
text = f"https://{text}"
if not ISSLOP_URL_PATTERN.match(text):
raise ValueError("URL must be an http(s), git or ssh source location")
return text
DEEPSEARCH_MIN_DEPTH = 1
DEEPSEARCH_MAX_DEPTH = 4
DEEPSEARCH_DEFAULT_DEPTH = 2
DEEPSEARCH_MIN_PAGES = 1
DEEPSEARCH_MAX_PAGES = 30
DEEPSEARCH_DEFAULT_PAGES = 12
DEEPSEARCH_MIN_QUERY = 3
DEEPSEARCH_MAX_QUERY = 500
DEEPSEARCH_MAX_MESSAGE = 2000
class DeepsearchRunForm(BaseModel):
query: str = Field(min_length=DEEPSEARCH_MIN_QUERY, max_length=DEEPSEARCH_MAX_QUERY)
depth: int = Field(
default=DEEPSEARCH_DEFAULT_DEPTH,
ge=DEEPSEARCH_MIN_DEPTH,
le=DEEPSEARCH_MAX_DEPTH,
)
max_pages: int = Field(
default=DEEPSEARCH_DEFAULT_PAGES,
ge=DEEPSEARCH_MIN_PAGES,
le=DEEPSEARCH_MAX_PAGES,
)
@field_validator("query")
@classmethod
def query_present(cls, value):
text = value.strip()
if not text:
raise ValueError("A research question is required")
return text
class DeepsearchChatForm(BaseModel):
message: str = Field(min_length=1, max_length=DEEPSEARCH_MAX_MESSAGE)
@field_validator("message")
@classmethod
def message_present(cls, value):
text = value.strip()
if not text:
raise ValueError("A message is required")
return text
+18
View File
@@ -0,0 +1,18 @@
# retoor <retoor@molodetz.nl>
from typing import Optional
from pydantic import BaseModel, Field
class UploadUrlForm(BaseModel):
url: str = Field(min_length=1, max_length=2048)
filename: Optional[str] = Field(default=None, max_length=255)
class GenerateImageForm(BaseModel):
prompt: str = Field(min_length=1, max_length=2000)
size: Optional[str] = Field(default=None, max_length=16)
class AttachmentRenameForm(BaseModel):
filename: str = Field(min_length=1, max_length=255)
+5 -5
View File
@@ -74,7 +74,7 @@ Response models live in `devplacepy/schemas.py` (Pydantic v2, `extra="ignore"`,
## FastAPI patterns
- **All routes are async.** Form data is validated via a typed Pydantic body param: `data: Annotated[SomeForm, Form()]` (models in `models.py`). Read raw `await request.form()` only when also handling an uploaded file (a separate `File()` param would embed the model under its parameter name).
- **All routes are async.** Form data is validated via a typed Pydantic body param: `data: Annotated[SomeForm, Form()]` (models in `models/`, one module per domain). Read raw `await request.form()` only when also handling an uploaded file (a separate `File()` param would embed the model under its parameter name).
- **Return `RedirectResponse(url=..., status_code=302)`** for redirects - always pass `status_code` as an integer literal, never `status.HTTP_302_FOUND`.
- **Return `templates.TemplateResponse("name.html", {...})`** from `devplacepy.templating` to render.
- **Never create your own `Jinja2Templates` instance.** Import the shared one: `from devplacepy.templating import templates`.
@@ -167,7 +167,7 @@ A dedicated `/gists` page for sharing code snippets. Uses `gists` table (auto-cr
### Politics category
The `politics` topic is available as a feed filter sidebar item (icon `&#x1F3DB;`) and post topic. It defines the CSS variable `--topic-politics: #00bcd4` in `variables.css` and the `.badge-politics` class in `base.css`. It is validated like every topic via the canonical `TOPICS` list in `constants.py` (consumed by `models.py` `valid_topic`, `templating.py` `TOPICS` global, `routers/posts.py`, `docs_api`). Unlike the former `signals` topic, `politics` is also part of the bot fleet's rotation - it is in `services/bot/config.py` `CATEGORIES`/`FEED_TOPICS`, carries a modest per-persona weight in `PERSONA_CATEGORY_WEIGHTS`, and has a writing instruction in `services/bot/llm.py` `category_extras`.
The `politics` topic is available as a feed filter sidebar item (icon `&#x1F3DB;`) and post topic. It defines the CSS variable `--topic-politics: #00bcd4` in `variables.css` and the `.badge-politics` class in `base.css`. It is validated like every topic via the canonical `TOPICS` list in `constants.py` (consumed by `models/` `valid_topic`, `templating.py` `TOPICS` global, `routers/posts.py`, `docs_api`). Unlike the former `signals` topic, `politics` is also part of the bot fleet's rotation - it is in `services/bot/config.py` `CATEGORIES`/`FEED_TOPICS`, carries a modest per-persona weight in `PERSONA_CATEGORY_WEIGHTS`, and has a writing instruction in `services/bot/llm.py` `category_extras`.
### Date format
@@ -200,7 +200,7 @@ News articles have an internal detail page at `/news/{slug}` with full comment s
### Home page (`GET /`)
The home route (`main.py` `landing()`) never redirects - it renders `templates/landing.html` for everyone, branching on `user`:
The home route (`routers/landing.py` `landing()`) never redirects - it renders `templates/landing.html` for everyone, branching on `user`:
- **Guests** get the marketing hero (`Join DevPlace Free` CTA + features grid).
- **Signed-in users** get a personalized dashboard hero (`.dashboard-welcome`): avatar, "Welcome back, {username}", quicklink buttons (`.dashboard-btn`, with `New Post` -> `/feed` as `.dashboard-btn-primary`, plus Code Farm/Projects/Gists), and a Posts/Stars/Level stat strip (`.dashboard-stats`, `user_post_count` + the user dict's `stars`/`level`). Styles live in the `.dashboard-*` classes in `static/css/landing.css`.
@@ -239,7 +239,7 @@ Every public listing card that shows the last few comments must render them with
Articles can be toggled to appear on the landing page via `/admin/news/{uid}/landing`:
- **`show_on_landing`** field on `news` table
- Landing route (`main.py` `GET /`) fetches up to 6 articles with `show_on_landing=1`
- Landing route (`routers/landing.py` `GET /`) fetches up to 6 articles with `show_on_landing=1`
- Rendered as a 3-column card grid with image, source, title, date (responsive -> 1 column on mobile)
- Toggleable individually from the admin news table
@@ -333,7 +333,7 @@ All three columns are populated by `routers/posts.py` `post_page_context()`, the
### Polls
- A poll rides on a post (one `polls` row keyed by `post_uid`, options in `poll_options`, one-per-user votes in `poll_votes`). Created in `posts.py:create_poll` when `poll_question` plus >= 2 non-empty `poll_options` are submitted (capped at 6). Both `create_post` and `edit_post` accept the poll fields; `edit_post` only attaches a poll when the post has **none** yet (it never replaces an existing poll). The builders live in the create-post modal (`feed.html`) and the edit-post modal (`post.html`, rendered only when the post has no poll) using `data-poll-toggle` / `data-poll-add-option`.
- `poll_options` accepts either repeated form fields (the web builders, which preserve commas inside an option label) **or** a single newline- or comma-separated string (the API/Devii path). `models.py:normalize_poll_options` splits a lone delimited element - applied as a `mode="before"` validator on `PostForm`/`PostEditForm` - so the documented "one per line or comma separated" agent format actually produces a multi-option poll instead of a single dropped option.
- `poll_options` accepts either repeated form fields (the web builders, which preserve commas inside an option label) **or** a single newline- or comma-separated string (the API/Devii path). `models/shared.py:normalize_poll_options` splits a lone delimited element - applied as a `mode="before"` validator on `PostForm`/`PostEditForm` - so the documented "one per line or comma separated" agent format actually produces a multi-option poll instead of a single dropped option.
- `POST /polls/{poll_uid}/vote` toggles the voter's choice and returns the full poll dict: voting on a new option switches, voting the **same** option again **retracts** (one vote per user, like reactions). Results (bars + `%`) are **always shown** to everyone; the chosen option gets `.chosen` + a checkmark. Guests see read-only bars with a "Log in to vote" hint (options `disabled`). Batch reads via `get_polls_by_post_uids(post_uids, user)` / `get_poll_for_post(post_uid, user)`.
- Composer poll builder (`feed.html`, `PollManager.js`): poll inputs are `disabled` until "Add poll" is opened (so a never-opened or "Remove poll"-collapsed builder submits **nothing**); a capture-phase `submit` listener blocks an incomplete poll (question + >= 2 options) with an inline error instead of silently dropping it - capture phase + `stopImmediatePropagation()` is required so it pre-empts `FormManager`'s bubble-phase submit handlers.
+143
View File
@@ -0,0 +1,143 @@
# retoor <retoor@molodetz.nl>
import os
from fastapi import APIRouter, Request
from devplacepy.cache import TTLCache
from devplacepy.database import (
db,
get_blocked_uids,
get_comment_counts_by_post_uids,
get_news_images_by_uids,
get_table,
get_top_authors,
get_trending_topics,
get_user_post_count,
get_user_stars,
get_users_by_uids,
get_vote_counts,
paginate_diverse,
)
from devplacepy.responses import respond
from devplacepy.schemas import LandingOut
from devplacepy.seo import base_seo_context, site_url, website_schema
from devplacepy.templating import jinja_unread_count
from devplacepy.utils import get_current_user, time_ago
router = APIRouter()
_home_cache = TTLCache(ttl=int(os.environ.get("DEVPLACE_HOME_CACHE_TTL", "60")), max_size=4)
def _landing_news():
cached = _home_cache.get("news")
if cached is not None:
return cached
articles = []
if "news" in db.tables:
news_table = get_table("news")
raw = list(
news_table.find(show_on_landing=1, order_by=["-synced_at"], _limit=6)
)
images_by_news = get_news_images_by_uids([a["uid"] for a in raw])
for a in raw:
articles.append(
{
"uid": a["uid"],
"slug": a.get("slug", ""),
"title": a.get("title", ""),
"description": (a.get("description", "") or "")[:250],
"url": a.get("url", ""),
"source_name": a.get("source_name", ""),
"grade": a.get("grade", 0),
"featured": a.get("featured", 0),
"synced_at": a.get("synced_at", "") or "",
"time_ago": time_ago(a["synced_at"]),
"image_url": a.get("image_url", "") or images_by_news.get(a["uid"], ""),
}
)
_home_cache.set("news", articles)
return articles
def _landing_recent_posts(viewer_uid):
blocked = get_blocked_uids(viewer_uid) if viewer_uid else frozenset()
if not blocked:
cached = _home_cache.get("posts")
if cached is not None:
return cached
posts = []
if "posts" in db.tables:
posts_table = get_table("posts")
raw_posts, _ = paginate_diverse(
posts_table, order=["-created_at"], viewer_uid=viewer_uid, limit=6
)
if raw_posts:
post_uids = [p["uid"] for p in raw_posts]
author_uids = [p["user_uid"] for p in raw_posts]
authors = get_users_by_uids(author_uids)
comment_counts = get_comment_counts_by_post_uids(post_uids)
upvotes, downvotes = get_vote_counts(post_uids)
for p in raw_posts:
posts.append(
{
"post": p,
"author": authors.get(p["user_uid"]),
"time_ago": time_ago(p["created_at"]),
"comment_count": comment_counts.get(p["uid"], 0),
"stars": upvotes.get(p["uid"], 0) - downvotes.get(p["uid"], 0),
"slug": p.get("slug", "") or p["uid"],
}
)
if not blocked:
_home_cache.set("posts", posts)
return posts
@router.get("/")
async def landing(request: Request):
user = get_current_user(request)
landing_articles = _landing_news()
viewer_uid = user["uid"] if user else None
blocked = get_blocked_uids(viewer_uid) if viewer_uid else frozenset()
landing_posts = _landing_recent_posts(viewer_uid)
base = site_url(request)
seo_ctx = base_seo_context(
request,
title="DevPlace - The Developer Social Network",
description="Track industry shifts. Discover bold releases. Share what you're building in an open environment built by developers, for developers.",
breadcrumbs=[],
schemas=[website_schema(base)],
)
user_xp = user.get("xp", 0) or 0 if user else 0
user_level = user.get("level", 1) or 1 if user else 1
xp_progress_pct = (user_xp % 100) if user_xp else 0
unread_count = jinja_unread_count(user["uid"]) if user else 0
top_contributors = get_top_authors(5) if not blocked else []
trending_topics = get_trending_topics(6) if not blocked else []
return respond(
request,
"landing.html",
{
**seo_ctx,
"request": request,
"user": user,
"is_authenticated": bool(user),
"user_post_count": get_user_post_count(user["uid"]) if user else 0,
"user_stars": get_user_stars(user["uid"]) if user else 0,
"user_xp": user_xp,
"user_level": user_level,
"xp_progress_pct": xp_progress_pct,
"unread_count": unread_count,
"landing_articles": landing_articles,
"landing_posts": landing_posts,
"top_contributors": top_contributors,
"trending_topics": trending_topics,
},
model=LandingOut,
)
+3 -3
View File
@@ -4,9 +4,9 @@ This file documents the service files that live directly in devplacepy/services/
Generic, lightweight, **fire-and-forget** offload for non-critical side-effects so they leave the request path. The singleton `background` (`from devplacepy.services.background import background`) wraps one in-process `asyncio.Queue` drained by a single consumer task. The whole API is one call: `background.submit(fn, *args, **kwargs)` enqueues a **synchronous** callable and returns immediately (`put_nowait`). The consumer runs each callable inside its own `try/except`, so a failing task is logged and never kills the loop; FIFO order is preserved.
- **Per-worker, not lock-gated (the reason it is NOT a `JobService`/`BaseService`).** Producers run in every uvicorn worker, and process memory is not shared, so the drain must run wherever requests are handled. `main.py` `startup()` calls `await background.start()` for every worker (inside the `if not DEVPLACE_DISABLE_SERVICES` guard, **outside** the `acquire_service_lock()` branch); `shutdown()` calls `await background.stop()`. A `BaseService` supervisor only runs in the lock owner, which would strand records produced by the other worker. The `JobService` queue is also wrong here: it is DB-backed, so deferring a tiny audit insert would *add* writes instead of removing them.
- **Per-worker, not lock-gated (the reason it is NOT a `JobService`/`BaseService`).** Producers run in every uvicorn worker, and process memory is not shared, so the drain must run wherever requests are handled. `lifespan.py` `lifespan()` calls `await background.start()` for every worker (inside the `if not DEVPLACE_DISABLE_SERVICES` guard, **outside** the `acquire_service_lock()` branch); shutdown calls `await background.stop()`. A `BaseService` supervisor only runs in the lock owner, which would strand records produced by the other worker. The `JobService` queue is also wrong here: it is DB-backed, so deferring a tiny audit insert would *add* writes instead of removing them.
- **Inline fallback (load-bearing).** When the consumer is not running - tests with `DEVPLACE_DISABLE_SERVICES=1`, unit tests, request-less bootstrap, or a full queue - `submit` runs `fn` **inline and synchronously**. This keeps audit/notification/XP writes deterministic and immediately visible to the test suite (which asserts audit rows right after an action) while production defers them. No test changes are needed.
- **Best-effort durability (intentional).** In-memory only; `stop()` drains whatever remains synchronously so a **graceful** shutdown loses nothing, but a hard crash drops unflushed items. This matches audit/notifications already being best-effort (the recorder never raises). Do not put response-critical or money-touching work on it.
- **Best-effort durability (intentional).** In-memory only; `stop()` posts the stop marker with `put_nowait` (a full queue logs and lets the worker keep draining instead of hanging shutdown) and then drains at most `SHUTDOWN_DRAIN_MAX`=1000 leftovers inline - anything beyond that is dropped with a loud warning and counted in `stats()["dropped"]`, so a **graceful** shutdown still loses nothing in the normal case but can never stall on a stuck worker. A hard crash drops unflushed items. This matches audit/notifications already being best-effort (the recorder never raises). Do not put response-critical or money-touching work on it.
- **Capture plain data, never the `Request`.** Its lifecycle ends with the response - build the row/payload synchronously on the request thread and submit only the resulting dict/scalars.
- **Consumers today** (deferred at their canonical choke points, so callers need no change):
- **Audit** - `services/audit/record.py` `_write` builds the row + links synchronously, generates the `uid`/`created_at` eagerly so `record()` still returns the real uid, then `background.submit(_persist, row, links)` does the two `store` inserts off-thread.
@@ -129,7 +129,7 @@ devplace devii reset-quota --all # Reset every quota (users and guests)
- **In-process caches are never authoritative across workers.** Coordinate invalidation through the `cache_state` version table in `database.py`: call `bump_cache_version(name)` at every write path and `sync_local_cache(name, cache)` before every read path. Existing names: `auth` (guards `_user_cache`; bumped by `clear_user_cache`/`clear_session_cache` on logout, ban, role/password change - a version bump makes EVERY worker clear its WHOLE `_user_cache`, so display-only refreshes (XP/level in `award_xp`, AI-corrected bio) call `clear_user_cache(uid, propagate=False)` for a local pop without the global bump; identity/authz changes (logout, ban, role, password, api-key/token revoke) MUST keep the default propagate=True), `settings` (guards `_settings_cache`; bumped by `set_setting`/`clear_settings_cache`), `relations` (`_relations_cache`), `customizations` (`_customizations_cache`), `notif_prefs` (`_notification_prefs_cache`), `gateway_routing` (`_ROUTING_CACHE`), and **`admins`** (guards `_admins_cache`, which memoizes `get_admin_uids()` / `get_primary_admin_uid()`). The `_cache_version_cache` (`ttl=1`) caches the version reads themselves, so the bump is seen by other workers within ~1s on their next read (the originating worker is immediate); the version read/bump fail open (logged, never raise). When you add a per-process cache whose staleness matters, give it a name and wire both calls - do not invent a second invalidation mechanism.
- **Admin-set cache invariant (load-bearing).** `_admins_cache` makes `get_admin_uids()` / `get_primary_admin_uid()` (hit on the projects-listing visibility filter, `_owner_is_admin`, and every primary-admin gate: `/dbapi`, backup-archive download) a dict lookup instead of a per-call `SELECT`. It is NOT the authorization gate - `is_admin(user)`/`require_admin` read the role off the user object (independently invalidated via `clear_user_cache`), so a stale admin set cannot grant access. But **any code that writes `users.role` MUST call `database.invalidate_admins_cache()`** (clears local + bumps the `admins` version), exactly like the soft-delete and `with db:` rules. Current role-write sites all do: `routers/admin/users.py` (role change), `cli.py` (`role set`), and `utils._create_account` (first user -> Admin). Omitting the call leaves the admin set stale for up to the 300s TTL.
- **Deterministic / eventual caches skip version-sync on purpose.** Two caches need no `cache_state` name because correctness does not depend on cross-worker freshness: (1) `docs_prose._render_markdown` is an `@lru_cache` on the **static** prose source string (pure function of template content, so identical on every worker; changes only on deploy), and (2) `main._home_cache` (`TTLCache ttl=60`) memoizes the guest `/` blocks - the featured-news block (identical for everyone) and the latest-posts block **only for the no-block case**; a viewer with a non-empty block set bypasses the cache and is computed fresh, so the original block-filter semantics are preserved byte-for-byte and there is zero cross-user leakage. Worst case is a soft-deleted/edited public post lingering on `/` for <=60s. Use a plain `TTLCache`/`lru_cache` (no version name) ONLY when the value is deterministic or its staleness is purely cosmetic; anything whose staleness affects correctness or permissions MUST use the version-sync pattern above.
- **Deterministic / eventual caches skip version-sync on purpose.** Two caches need no `cache_state` name because correctness does not depend on cross-worker freshness: (1) `docs_prose._render_markdown` is an `@lru_cache` on the **static** prose source string (pure function of template content, so identical on every worker; changes only on deploy), and (2) `routers/landing.py` `_home_cache` (`TTLCache ttl=60`) memoizes the guest `/` blocks - the featured-news block (identical for everyone) and the latest-posts block **only for the no-block case**; a viewer with a non-empty block set bypasses the cache and is computed fresh, so the original block-filter semantics are preserved byte-for-byte and there is zero cross-user leakage. Worst case is a soft-deleted/edited public post lingering on `/` for <=60s. Use a plain `TTLCache`/`lru_cache` (no version name) ONLY when the value is deterministic or its staleness is purely cosmetic; anything whose staleness affects correctness or permissions MUST use the version-sync pattern above.
- **Authoritative state lives in the DB**, memory is only a short-TTL accelerator (sessions, the Devii 24h cap via `devii_usage_ledger`, cost counters).
- **Global rates/quotas are worker-count-aware or DB-backed.** The rate limiter enforces `ceil(limit / DEVPLACE_WEB_WORKERS)` per process so the aggregate matches the configured value with no per-request write. `DEVPLACE_WEB_WORKERS` MUST equal the real `--workers` (set in `make prod`=2 and the Dockerfile=2; default 1 for dev). Update it whenever you change worker count.
- **First-boot side effects are flock-serialized and idempotent.** `init_db()` runs under a blocking `flock` on `devplace-init.lock` (the seed-if-missing inserts would otherwise race to duplicate rows); `ensure_certificates()` early-returns when keys exist and otherwise generates under `.vapid.lock`. Any new run-once-at-startup work follows the same pattern (flock + exists-check / `INSERT OR IGNORE`).
+1 -1
View File
@@ -10,7 +10,7 @@ It is off by default and it is never appropriate on a real production host.
## The two load-bearing ideas
**The application must not know.** There is no request-path branch, no schema, no route, no template, no Jinja global, no Devii tool and no environment flag. The only import of this package anywhere is the one registration line in `main.py`, and `tests/unit/services/acceptance/isolation.py` fails the suite if a second one appears. An environment flag would be exactly the knowledge the application is not allowed to have, which is why there is none.
**The application must not know.** There is no request-path branch, no schema, no route, no template, no Jinja global, no Devii tool and no environment flag. The only import of this package anywhere is the one entry in `lifespan.py` `SERVICE_CLASSES`, and `tests/unit/services/acceptance/isolation.py` fails the suite if a second one appears. An environment flag would be exactly the knowledge the application is not allowed to have, which is why there is none.
**The decline register needs no storage.** `user_consents` is append-only in effect, so the latest live row for a `(user, kind)` pair already is the register. The service only ever writes `granted`; it follows that any `withdrawn` row in the ledger was written by a human, and the service never touches that pair again. No provenance column, no marker, no flag, and nothing for the application to observe.
+23 -2
View File
@@ -11,6 +11,7 @@ logger = logging.getLogger(__name__)
QUEUE_MAXSIZE = 10000
JOIN_TIMEOUT_SECONDS = 10
GET_TIMEOUT_SECONDS = 1
SHUTDOWN_DRAIN_MAX = 1000
class _Stop:
@@ -30,6 +31,7 @@ class BackgroundQueue:
self._processed = 0
self._failed = 0
self._inline = 0
self._dropped = 0
@property
def running(self) -> bool:
@@ -90,17 +92,23 @@ class BackgroundQueue:
self._thread = None
self._loop = None
if thread is not None:
self._queue.put(_STOP)
try:
self._queue.put_nowait(_STOP)
except queue.Full:
logger.warning("background queue full at shutdown; worker keeps draining")
await asyncio.to_thread(thread.join, JOIN_TIMEOUT_SECONDS)
self._flush_remaining()
logger.info(
"background task queue stopped processed=%s failed=%s inline=%s",
"background task queue stopped processed=%s failed=%s inline=%s dropped=%s",
self._processed,
self._failed,
self._inline,
self._dropped,
)
def _flush_remaining(self) -> None:
drained = 0
dropped = 0
while True:
try:
item = self._queue.get_nowait()
@@ -108,8 +116,20 @@ class BackgroundQueue:
break
if item is _STOP:
continue
if drained >= SHUTDOWN_DRAIN_MAX:
dropped += 1
continue
fn, args, kwargs = item
self._execute(fn, args, kwargs)
drained += 1
if dropped:
with self._counters_lock:
self._dropped += dropped
logger.warning(
"background queue dropped %s tasks at shutdown after draining %s",
dropped,
drained,
)
def _worker(self) -> None:
while True:
@@ -130,6 +150,7 @@ class BackgroundQueue:
"processed": self._processed,
"failed": self._failed,
"inline": self._inline,
"dropped": self._dropped,
}
+1 -1
View File
@@ -94,7 +94,7 @@ Devii: `container_create_instance` takes the boot/run-as/start_on_boot args; `co
## Host port allocation
Host ports are auto-allocated and globally unique. `parse_ports` accepts a bare container port (`8899`, host side `0` = auto) or a pinned `host:container`. `api.assign_host_ports` (called in `create_instance`) resolves every `host=0` to the lowest free port in `[HOST_PORT_MIN=20001, 65535]` that is neither in `used_host_ports()` (the union of every instance's published host ports from `ports_json`) nor currently bound on the host (`_host_port_free` test-binds `0.0.0.0:port`). A pinned host port already published by another instance is rejected up front. This guarantees no two instances ever collide on a host port - previously a silent `docker run` "port is already allocated" failure. The resolved host port is what gets stored in `ports_json` and what the ingress proxy reads.
Host ports are auto-allocated and globally unique. `parse_ports` accepts a bare container port (`8899`, host side `0` = auto) or a pinned `host:container`, and rejects more than `MAX_PORTS_PER_INSTANCE=32` mappings per instance. `api.assign_host_ports` (called in `create_instance`) resolves every `host=0` to the lowest free port in `[HOST_PORT_MIN=20001, 65535]` that is neither in `used_host_ports()` (the union of every instance's published host ports from `ports_json`) nor currently bound on the host (`_host_port_free` test-binds `0.0.0.0:port`). The ascending scan keeps lowest-free semantics but stops after `MAX_PORT_PROBES=4096` failed bind probes instead of sweeping all 45k ports. A pinned host port already published by another instance is rejected up front. This guarantees no two instances ever collide on a host port - previously a silent `docker run` "port is already allocated" failure. The resolved host port is what gets stored in `ports_json` and what the ingress proxy reads.
## Floating terminals (interactive shell)
+12
View File
@@ -33,6 +33,8 @@ INSTANCE_LABEL = "devplace.instance"
PROJECT_LABEL = "devplace.project"
HOST_PORT_MIN = 20001
HOST_PORT_MAX = 65535
MAX_PORT_PROBES = 4096
MAX_PORTS_PER_INSTANCE = 32
BOOT_LANGUAGES = ("none", "python", "bash")
BOOT_SCRIPT_FILES = {"python": ".devplace_boot.py", "bash": ".devplace_boot.sh"}
BOOT_SCRIPT_RUNNERS = {"python": "python", "bash": "bash"}
@@ -114,6 +116,10 @@ def parse_ports(value) -> list:
f"port '{item}' must be numeric host:container or a bare container port"
)
ports.append(PortMapping(int(host), int(container), proto.strip() or "tcp"))
if len(ports) > MAX_PORTS_PER_INSTANCE:
raise ContainerError(
f"at most {MAX_PORTS_PER_INSTANCE} port mappings per instance"
)
return ports
@@ -138,11 +144,17 @@ def _host_port_free(port: int) -> bool:
def allocate_host_port(reserved: set) -> int:
probes = 0
for port in range(HOST_PORT_MIN, HOST_PORT_MAX + 1):
if port in reserved:
continue
if _host_port_free(port):
return port
probes += 1
if probes >= MAX_PORT_PROBES:
raise ContainerError(
f"no free host port found after probing {MAX_PORT_PROBES} ports"
)
raise ContainerError(
f"no free host port available in range {HOST_PORT_MIN}-{HOST_PORT_MAX}"
)
+1 -1
View File
@@ -142,7 +142,7 @@ Devii's project filesystem tools include surgical **line-range editing** (`proje
## Web fetch and arbitrary HTTP (`handler="fetch"`)
`services/devii/fetch/FetchController` (a standalone `httpx.AsyncClient`, not `PlatformClient`) backs two tools: `fetch_url` (read a page as readable text, `requires_auth=False`) and `http_request` (`requires_auth=True`) - the general external HTTP client. `http_request` takes `method` (GET/POST/PUT/PATCH/DELETE/HEAD/OPTIONS), `headers` (object), and one body of `json` (object/array, Content-Type set automatically), `form` (url-encoded), or `body` (raw string), and returns `{http_status, headers, content_type, content}`; it does **not** raise on non-2xx so the model can read API errors. Both run through one `_stream()` (`fetch_url` via `_download` with `raise_5xx=True`; `http_request` with `raise_5xx=False`) sharing the SSRF `_guard` (refuses private/loopback unless `DEVII_FETCH_ALLOW_PRIVATE`), `fetch_max_bytes` cap, timeout, and decoding. The `object`-typed params required adding an `object` branch to `Action.tool_schema()` in `spec.py` (emits a valid open-object schema). The `SYSTEM_PROMPT` "WEB REQUESTS AND EXTERNAL APIS" rule tells the agent to call `http_request` DIRECTLY for an API and never wrap it in a self-invoking user-defined tool (a virtual tool only re-runs the agent and cannot do I/O, so wrapping the call recurses into the `MAX_EVAL_DEPTH` guard - the exact failure this fixed). To attach a remote file see `attach_url` / `store_attachment_from_url` under Attachments and media.
`services/devii/fetch/FetchController` (a standalone `httpx.AsyncClient`, not `PlatformClient`) backs two tools: `fetch_url` (read a page as readable text, `requires_auth=False`) and `http_request` (`requires_auth=True`) - the general external HTTP client. `http_request` takes `method` (GET/POST/PUT/PATCH/DELETE/HEAD/OPTIONS), `headers` (object), and one body of `json` (object/array, Content-Type set automatically), `form` (url-encoded), or `body` (raw string), and returns `{http_status, headers, content_type, content}`; it does **not** raise on non-2xx so the model can read API errors. Both run through one `_stream()` (`fetch_url` via `_download` with `raise_5xx=True`; `http_request` with `raise_5xx=False`) sharing the SSRF `_guard` (refuses private/loopback unless `DEVII_FETCH_ALLOW_PRIVATE`), `fetch_max_bytes` cap, timeout, and decoding. Returned content is clamped by `_optional_cap` to `settings.fetch_max_chars` (default 200000, `DEVII_FETCH_MAX_CHARS`) - the model's `max_chars` argument can only narrow below that ceiling, never exceed it, and a missing or invalid value falls back to the ceiling. The `object`-typed params required adding an `object` branch to `Action.tool_schema()` in `spec.py` (emits a valid open-object schema). The `SYSTEM_PROMPT` "WEB REQUESTS AND EXTERNAL APIS" rule tells the agent to call `http_request` DIRECTLY for an API and never wrap it in a self-invoking user-defined tool (a virtual tool only re-runs the agent and cannot do I/O, so wrapping the call recurses into the `MAX_EVAL_DEPTH` guard - the exact failure this fixed). To attach a remote file see `attach_url` / `store_attachment_from_url` under Attachments and media.
## Remote web tools (rsearch, non-platform)
@@ -43,12 +43,13 @@ class FetchController:
raise ToolInputError(f"Unknown fetch tool: {name}")
def _optional_cap(self, requested: Any, content: str) -> str:
ceiling = max(MIN_FETCH_CHARS, self._settings.fetch_max_chars)
if requested is None:
return content
return content[:ceiling]
try:
cap = max(MIN_FETCH_CHARS, int(requested))
cap = min(max(MIN_FETCH_CHARS, int(requested)), ceiling)
except (TypeError, ValueError):
return content
return content[:ceiling]
return content[:cap]
async def _fetch_url(self, arguments: dict[str, Any]) -> str:
+1 -1
View File
@@ -45,4 +45,4 @@ Note: for Gitea to accept every file type the instance `app.ini` `[attachment] A
### Schemas, forms, and tools
Schemas: `IssueItemOut`/`IssueCommentOut`/`IssueDetailOut`/`IssueAttachmentsOut`/`IssuesOut`/`IssueJobOut` (`schemas.py`). Forms: `IssueForm`/`IssueCommentForm`/`IssueAttachmentForm`/`IssueStatusForm` (`models.py`). Devii tools: `list_issues`, `create_issue`, `view_issue`, `comment_issue`, `set_issue_status` (admin), plus the attachment tools above. Docs: the `issues` API group in `docs_api.py`. Footer link in `base.html` under `.site-footer`.
Schemas: `IssueItemOut`/`IssueCommentOut`/`IssueDetailOut`/`IssueAttachmentsOut`/`IssuesOut`/`IssueJobOut` (`schemas.py`). Forms: `IssueForm`/`IssueCommentForm`/`IssueAttachmentForm`/`IssueStatusForm` (`models/community.py`). Devii tools: `list_issues`, `create_issue`, `view_issue`, `comment_issue`, `set_issue_status` (admin), plus the attachment tools above. Docs: the `issues` API group in `docs_api.py`. Footer link in `base.html` under `.site-footer`.
+1 -1
View File
@@ -47,7 +47,7 @@ Callers may send an optional `X-App-Reference` header to tag gateway calls by ap
A dedicated per-IP throttle protects `/openai/v1/*` against unauthenticated probing WITHOUT IP-allowlisting the endpoint (which would break legitimate external API consumers using `gateway_access_key`/`gateway_allow_users`). It is scoped ONLY to failed authentication attempts, never to general traffic - the existing global rate limiter already exempts `/openai` entirely (`main.py`), and this throttle does not change that exemption.
- **Config (`gateway_auth_throttle_enabled` default on, `gateway_auth_throttle_max_failures` default 10, `gateway_auth_throttle_window_seconds` default 60, all group "Access").** An admin can retune or disable the throttle with no code change, same as every other gateway setting.
- **Counter.** `auth_throttle.py` is a small in-process sliding-window store (`dict[ip] -> list[float]` timestamps), the same shape as the global rate limiter's `_rate_limit_store` in `main.py`, but a separate, dedicated store local to the gateway rather than reusing that one (the two throttles key on different events - all mutating traffic vs. failed gateway auth - and `/openai` is deliberately exempt from the general limiter). `record_failure(ip, window)` appends a timestamp and returns the running in-window count; `is_throttled(ip, threshold, window)` checks without recording. Per-worker, like every other in-process cache in this codebase - not divided by worker count, so the effective aggregate threshold across `N` workers is up to `N x max_failures`; this is an accepted tradeoff (it still closes the probe, just at a slightly higher aggregate bound) rather than importing `main.WEB_WORKERS` into a leaf service module.
- **Counter.** `auth_throttle.py` is a small in-process sliding-window store (`dict[ip] -> list[float]` timestamps), the same shape as the global rate limiter's `_rate_limit_store` in `middleware/state.py`, but a separate, dedicated store local to the gateway rather than reusing that one (the two throttles key on different events - all mutating traffic vs. failed gateway auth - and `/openai` is deliberately exempt from the general limiter). `record_failure(ip, window)` appends a timestamp and returns the running in-window count; `is_throttled(ip, threshold, window)` checks without recording. Per-worker, like every other in-process cache in this codebase - not divided by worker count, so the effective aggregate threshold across `N` workers is up to `N x max_failures`; this is an accepted tradeoff (it still closes the probe, just at a slightly higher aggregate bound) rather than importing `state.WEB_WORKERS` into a leaf service module.
- **`GatewayService.authorize()` records a failure on every deny path** (a cheap static-key/internal-key match still short-circuits first and is never counted), so a wrong or garbage key counts exactly like presenting nothing. **The fast 429 short-circuit fires only when the request carries no credential at all** (no `X-API-KEY`, no `Authorization` header of any scheme, no `session` cookie) AND the IP is already over threshold - this is the "skip the rest of `authorize()`" defense against wasted CPU on a blind flood. A request presenting ANY credential - even one that turns out invalid - always runs the full check, so it can never be blocked by another caller's failures sharing its IP (a NAT/proxy caller with its own valid key is never punished for a neighbor's failed probes); only a request presenting nothing is fast-denied once its IP has tripped.
- **Audit.** Tripping the throttle (the exact request whose failure brings the window count to the configured threshold) records `ai.gateway_auth_throttle.tripped` (category `ai`, `result="denied"`, metadata `ip`/`failed_attempts`/`threshold`/`window_seconds`) via `audit.record(request, ...)`. Every subsequent fast-denied request in the same trip is silent (no audit spam under an actual flood) - the trip itself is the visible signal for admins.
- **Internal traffic cannot trip this by construction.** Every internal consumer (Devii guests, AI correction/modifier, quiz grading, SEO metadata generation, news, bots) authenticates with `database.internal_gateway_key()`, which is the exact same value `authorize()` compares against as `gateway_internal_key` - that comparison is the FIRST check in `authorize()`, before the throttle is even consulted, so internal self-dial calls always succeed there and never reach the failure-recording branch.
+54
View File
@@ -0,0 +1,54 @@
# retoor <retoor@molodetz.nl>
from pathlib import Path
from fastapi.staticfiles import StaticFiles
INLINE_MEDIA_EXTENSIONS = {
".jpg",
".jpeg",
".png",
".gif",
".webp",
".bmp",
".tiff",
".mp4",
".webm",
".ogv",
".mov",
".m4v",
".mp3",
}
class UploadStaticFiles(StaticFiles):
async def get_response(self, path, scope):
response = await super().get_response(path, scope)
disposition = (
"inline"
if Path(path).suffix.lower() in INLINE_MEDIA_EXTENSIONS
else "attachment"
)
response.headers["Content-Disposition"] = disposition
response.headers["Cache-Control"] = "public, max-age=604800"
return response
class CachedStaticFiles(StaticFiles):
async def get_response(self, path, scope):
response = await super().get_response(path, scope)
if Path(path).name == "service-worker.js":
response.headers["Cache-Control"] = "no-cache"
else:
response.headers["Cache-Control"] = "public, max-age=31536000, immutable"
return response
class FallbackStaticFiles(StaticFiles):
async def get_response(self, path, scope):
response = await super().get_response(path, scope)
if Path(path).name == "service-worker.js":
response.headers["Cache-Control"] = "no-cache"
else:
response.headers["Cache-Control"] = "public, max-age=3600"
return response
@@ -7,7 +7,7 @@ How a request flows through middleware to a router, how the database layer is bu
## Application setup
`main.py` builds the FastAPI app, mounts `/static`, registers every router with its prefix, installs the middlewares, and registers global 404, 500, and `RequestValidationError` handlers that render `error.html` through the shared `templates` instance.
`main.py` builds the FastAPI app, mounts `/static`, registers every router with its prefix, installs the middlewares from `middleware/` in addition order, and registers the global handlers from `errors.py` (404, 500, `ContentRefused`, `RequestValidationError`) that render `error.html` through the shared `templates` instance. Startup and shutdown live in `lifespan.py`.
### Happy 404
@@ -78,7 +78,7 @@ In-process TTL caches accelerate hot reads but are **never authoritative**: `_se
## Models, schemas, and responses
- **`models.py`** holds the Pydantic `Form` input models. Routers declare `data: Annotated[SomeForm, Form()]`; FastAPI validates, and the global `RequestValidationError` handler re-renders auth pages with messages (400) or redirects other routes.
- **`models/`** holds the Pydantic `Form` input models (one module per domain, re-exported from `models/__init__.py`). Routers declare `data: Annotated[SomeForm, Form()]`; FastAPI validates, and the global `RequestValidationError` handler re-renders auth pages with messages (400) or redirects other routes.
- **`schemas/`** (a package) holds the `*Out` response projections. They ignore extra keys, so the full context dict can be passed through, but only declared fields reach the JSON. `*Out` models never expose `password_hash`, `email`, or `api_key`.
- **`responses.py`** provides content negotiation. `wants_json(request)` is true for `Accept: application/json` or `Content-Type: application/json`. Page GETs return `respond(request, "x.html", ctx, model=XOut)` (HTML or validated JSON); action POSTs return `action_result(request, url, data=...)` (a 302 redirect or `{ok, redirect, data}` JSON).
@@ -88,5 +88,5 @@ Session cookies named `session` hold a 64-char hex token from `secrets.token_hex
## Services framework
`BaseService` provides the async run loop, typed `ConfigField` definitions, state persisted to `service_state` (status, last/next run, log deque, metrics), and admin commands (run, clear). `ServiceManager` is a singleton that registers, supervises, and stops services and tracks lock ownership. New services extend `BaseService`, override `async def run_once(self)`, and register in `main.py`; they then appear on `/admin/services`. `GatewayService` is the single source of truth for AI: every other AI consumer routes through it, so usage is attributed and limitable per owner.
`BaseService` provides the async run loop, typed `ConfigField` definitions, state persisted to `service_state` (status, last/next run, log deque, metrics), and admin commands (run, clear). `ServiceManager` is a singleton that registers, supervises, and stops services and tracks lock ownership. New services extend `BaseService`, override `async def run_once(self)`, and register in `lifespan.py` `SERVICE_CLASSES`; they then appear on `/admin/services`. `GatewayService` is the single source of truth for AI: every other AI consumer routes through it, so usage is attributed and limitable per owner.
</div>
@@ -46,7 +46,7 @@ The `comments` table keys on `(target_type, target_uid)`, so the same comment se
## Validation and redirects
- **Form validation is Pydantic-native.** Declare a typed body param `data: Annotated[SomeForm, Form()]` (models live in `models.py`); the global `RequestValidationError` handler re-renders auth pages with messages or redirects other routes.
- **Form validation is Pydantic-native.** Declare a typed body param `data: Annotated[SomeForm, Form()]` (models live in `models/`, one module per domain); the global `RequestValidationError` handler re-renders auth pages with messages or redirects other routes.
- **`RedirectResponse(url=..., status_code=302)`** always passes `status_code` as an integer literal, never `status.HTTP_302_FOUND`.
## SEO
@@ -9,7 +9,7 @@ How a change moves from understanding to shipped. The workflow is ordered by dat
## The feature workflow
1. **Understand.** Read the router for the area (a flat `routers/{area}.py`, or the leaf inside the `routers/{area}/` package that owns the endpoint), the template, the test file, and the matching nested `CLAUDE.md` before writing. Trace the existing input model, router, data helper, and response.
2. **Data layer.** Add query and batch helpers in the `database/` package (never inline N+1). Add the Pydantic `Form` model to `models.py` and the `*Out` response model to the `schemas/` package. Schema auto-syncs via `dataset`; add indexes in `init_db()` with `CREATE INDEX IF NOT EXISTS`.
2. **Data layer.** Add query and batch helpers in the `database/` package (never inline N+1). Add the Pydantic `Form` model to the matching `models/` domain module (and its re-export in `models/__init__.py`) and the `*Out` response model to the `schemas/` package. Schema auto-syncs via `dataset`; add indexes in `init_db()` with `CREATE INDEX IF NOT EXISTS`.
3. **Server layer.** Write the handler with the right guard (`get_current_user` for public reads, `require_user` for member POSTs, `require_admin` for admin). Declare specific routes before catch-alls. Return HTML and JSON from one handler via `respond(..., model=XOut)`. Register any new router in `main.py`.
4. **View layer.** Import the shared `templates`, extend `base.html`, put page CSS in `{% block extra_head %}` and page JS in `{% block extra_js %}`, and reuse partials and globals.
5. **Agent and docs layer (the most-forgotten step).** If the route is something a user could ask the assistant to do, add an `Action` to the Devii catalog. Add an `endpoint()` entry to the `docs_api/` package, and a prose page to `DOCS_PAGES` where relevant. Set SEO context for any new public page.
+3 -1
View File
@@ -28,9 +28,11 @@ The recurring theme is deliberate minimalism: no JavaScript framework, no NPM, n
```
devplacepy/
main.py app, middleware, router registration, lifecycle
middleware/ HTTP middlewares + shared request state (package)
errors.py global exception handlers lifespan.py startup/shutdown
config.py paths, env vars, constants
database/ SQLite/dataset, init_db, batch helpers, caching (package)
models.py Pydantic Form input models
models/ Pydantic Form input models (package)
schemas/ Pydantic *Out response projections (package)
responses.py respond() / action_result() / wants_json()
utils/ auth guards, slugs, dates, gamification (package)
@@ -22,11 +22,13 @@ Open `http://localhost:10500`. The first account you register becomes the admini
```
devplacepy/ the application package
main.py mounts routers, middleware, startup/shutdown
main.py mounts routers, middleware, error handlers, lifespan
middleware/ HTTP middlewares + shared request state
errors.py global exception handlers lifespan.py startup/shutdown
routers/ one directory per URL domain (mirrors the endpoint tree)
templates/ Jinja2 pages; static/ holds CSS and ES6 modules
database/ SQLite via dataset; query and batch helpers
models.py Pydantic Form models schemas/ *Out JSON models
models/ Pydantic Form models schemas/ *Out JSON models
services/ background + async-job services, Devii, containers
tests/ unit / api / e2e, mirroring the route or source path
```
@@ -38,7 +40,7 @@ as HTML, served as JSON, called by the Devii assistant as a tool, and described
docs. The usual failure is changing one face and forgetting a connected one. Work in this
order so nothing is dropped:
1. **Data** - query helpers in `database.py`, a `Form` model in `models.py`, an `*Out`
1. **Data** - query helpers in `database.py`, a `Form` model in `models/`, an `*Out`
model in `schemas.py`.
2. **Server** - the handler in `routers/`, with the right guard (`get_current_user` for
public reads, `require_user`, `require_admin`); return both faces via
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "devplacepy"
version = "1.1.7"
version = "1.1.8"
description = "DevPlace - The Developer Social Network"
requires-python = ">=3.12"
dependencies = [
+4 -3
View File
@@ -156,18 +156,19 @@ def test_rate_limit_block_recorded(monkeypatch):
# limiter re-enabled for itself - the same approach as test_ratelimit.py - and
# asserts the block emits the security.rate_limit.block audit row.
import devplacepy.main as m
import devplacepy.middleware.state as st
from starlette.testclient import TestClient
monkeypatch.setattr(m, "RATE_LIMIT_DISABLED", False)
monkeypatch.setattr(st, "RATE_LIMIT_DISABLED", False)
monkeypatch.setattr(
m,
st,
"get_int_setting",
lambda key, default: {
"rate_limit_per_minute": 1,
"rate_limit_window_seconds": 60,
}.get(key, default),
)
m._rate_limit_store.clear()
st._rate_limit_store.clear()
ip = "203.0.113.77"
client = TestClient(m.app)
codes = [
+4 -3
View File
@@ -1,6 +1,7 @@
# retoor <retoor@molodetz.nl>
import devplacepy.main as m
import devplacepy.middleware.state as st
from starlette.testclient import TestClient
def _patch_limits(monkeypatch, limit, window=60):
def fake_get_int_setting(key, default):
@@ -9,10 +10,10 @@ def _patch_limits(monkeypatch, limit, window=60):
"rate_limit_window_seconds": window,
}.get(key, default)
monkeypatch.setattr(m, "get_int_setting", fake_get_int_setting)
monkeypatch.setattr(st, "get_int_setting", fake_get_int_setting)
# conftest disables the limiter suite-wide; re-enable it for these tests only.
monkeypatch.setattr(m, "RATE_LIMIT_DISABLED", False)
m._rate_limit_store.clear()
monkeypatch.setattr(st, "RATE_LIMIT_DISABLED", False)
st._rate_limit_store.clear()
import requests
from tests.conftest import BASE_URL
def _seed_news_seo():
+6 -5
View File
@@ -3,6 +3,7 @@
import re
import devplacepy.main as m
import devplacepy.middleware.state as st
from starlette.testclient import TestClient
def _patch_limits(monkeypatch, limit, window=60):
def fake_get_int_setting(key, default):
@@ -11,14 +12,14 @@ def _patch_limits(monkeypatch, limit, window=60):
"rate_limit_window_seconds": window,
}.get(key, default)
monkeypatch.setattr(m, "get_int_setting", fake_get_int_setting)
monkeypatch.setattr(st, "get_int_setting", fake_get_int_setting)
# conftest disables the limiter suite-wide; re-enable it for these tests only.
monkeypatch.setattr(m, "RATE_LIMIT_DISABLED", False)
m._rate_limit_store.clear()
monkeypatch.setattr(st, "RATE_LIMIT_DISABLED", False)
st._rate_limit_store.clear()
# _hot_settings caches the limit for HOT_SETTINGS_TTL; force a re-read of the
# patched get_int_setting so a stale value from a prior test cannot leak in.
m._hot_settings_value = None
m._hot_settings_at = 0.0
st._hot_settings_value = None
st._hot_settings_at = 0.0
def test_rate_limit_blocks_excess(monkeypatch):
+2 -2
View File
@@ -27,13 +27,13 @@ def references(path):
return any(needle in body for needle in NEEDLES)
def test_only_main_imports_the_acceptance_package():
def test_only_lifespan_imports_the_acceptance_package():
importers = [
path.relative_to(PACKAGE_ROOT).as_posix()
for path in source_files({".py"})
if references(path)
]
assert importers == ["main.py"]
assert importers == ["lifespan.py"]
def test_no_template_or_static_asset_mentions_the_package():
+54
View File
@@ -0,0 +1,54 @@
# retoor <retoor@molodetz.nl>
import queue
import threading
from tests.conftest import run_async
from devplacepy.services.background import BackgroundQueue
def _fill(direct_queue, count, executed):
for _ in range(count):
direct_queue.put_nowait((executed.append, ("x",), {}))
def test_stop_without_start_returns_cleanly():
direct = BackgroundQueue()
run_async(direct.stop())
assert direct.stats()["running"] is False
def test_stop_full_queue_does_not_block():
direct = BackgroundQueue(maxsize=4)
dead = threading.Thread(target=lambda: None)
dead.start()
dead.join()
direct._thread = dead
executed = []
_fill(direct._queue, 4, executed)
run_async(direct.stop())
assert executed == ["x"] * 4
def test_stop_bounds_shutdown_drain_and_counts_dropped():
from devplacepy.services.background import SHUTDOWN_DRAIN_MAX
direct = BackgroundQueue(maxsize=5000)
executed = []
_fill(direct._queue, SHUTDOWN_DRAIN_MAX + 200, executed)
run_async(direct.stop())
stats = direct.stats()
assert len(executed) == SHUTDOWN_DRAIN_MAX
assert stats["dropped"] == 200
def test_start_stop_roundtrip_executes_submitted_task():
direct = BackgroundQueue()
run_async(direct.start())
try:
executed = []
direct.submit(executed.append, "x")
finally:
run_async(direct.stop())
assert executed == ["x"]
assert direct.stats()["running"] is False
+36
View File
@@ -793,3 +793,39 @@ def test_tunnel_target_is_empty_without_a_route_to_the_port():
instance = {"ports_json": "[]", "container_gateway": "172.17.0.1"}
assert api.tunnel_target(instance, 3000) == (None, None)
assert api.tunnel_target({"container_ip": "172.17.0.9"}, 0) == (None, None)
def test_allocate_host_port_returns_lowest_free(monkeypatch):
monkeypatch.setattr(api, "_host_port_free", lambda port: True)
assert api.allocate_host_port(set()) == api.HOST_PORT_MIN
assert api.allocate_host_port({20001, 20002}) == 20003
def test_allocate_host_port_skips_bound_ports(monkeypatch):
monkeypatch.setattr(api, "_host_port_free", lambda port: port != 20001)
assert api.allocate_host_port(set()) == 20002
def test_allocate_host_port_probe_budget_exhausted(monkeypatch):
from devplacepy.services.containers.api import ContainerError
calls = []
monkeypatch.setattr(
api, "_host_port_free", lambda port: calls.append(port) or False
)
with pytest.raises(ContainerError, match="probing"):
api.allocate_host_port(set())
assert len(calls) == api.MAX_PORT_PROBES
def test_parse_ports_accepts_mapping_cap():
value = "\n".join(str(8000 + i) for i in range(api.MAX_PORTS_PER_INSTANCE))
assert len(api.parse_ports(value)) == api.MAX_PORTS_PER_INSTANCE
def test_parse_ports_rejects_too_many_mappings():
from devplacepy.services.containers.api import ContainerError
value = "\n".join(str(8000 + i) for i in range(api.MAX_PORTS_PER_INSTANCE + 1))
with pytest.raises(ContainerError, match="at most"):
api.parse_ports(value)
@@ -0,0 +1 @@
# retoor <retoor@molodetz.nl>
@@ -0,0 +1,41 @@
# retoor <retoor@molodetz.nl>
from dataclasses import replace
from devplacepy.services.devii.config import load_settings
from devplacepy.services.devii.fetch.controller import MIN_FETCH_CHARS, FetchController
def _controller(ceiling):
return FetchController(replace(load_settings(), fetch_max_chars=ceiling))
def test_optional_cap_defaults_to_ceiling():
controller = _controller(2000)
assert controller._optional_cap(None, "a" * 5000) == "a" * 2000
def test_optional_cap_clamps_large_request_to_ceiling():
controller = _controller(2000)
assert controller._optional_cap(10**9, "b" * 5000) == "b" * 2000
def test_optional_cap_honors_smaller_request():
controller = _controller(2000)
assert controller._optional_cap(1500, "c" * 5000) == "c" * 1500
def test_optional_cap_floors_tiny_request():
controller = _controller(2000)
assert controller._optional_cap(10, "d" * 5000) == "d" * MIN_FETCH_CHARS
def test_optional_cap_invalid_request_falls_back_to_ceiling():
controller = _controller(2000)
assert controller._optional_cap("many", "e" * 5000) == "e" * 2000
def test_optional_cap_leaves_short_content_alone():
controller = _controller(2000)
assert controller._optional_cap(None, "short") == "short"
assert controller._optional_cap(1500, "short") == "short"