forked from retoor/devplacepy
Compare commits
56
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
85bd8fad47 | ||
|
|
6b9c48661a | ||
|
|
78023d36ab | ||
|
|
d9ff99c4a0 | ||
|
|
54f06a957d | ||
|
|
67c85e4184 | ||
|
|
4a13415b43 | ||
|
|
cc969aa187 | ||
|
|
dcd90cc907 | ||
|
|
3d07a478c3 | ||
|
|
70ceb3cf81 | ||
|
|
be77672c3e | ||
|
|
a693a6f4d8 | ||
|
|
8ae3f628c7 | ||
|
|
81d6aa68b6 | ||
|
|
7d32ef17dd | ||
|
|
57087536e5 | ||
|
|
9d7b3db314 | ||
|
|
572e022584 | ||
|
|
afb4799869 | ||
|
|
3ca9285646 | ||
|
|
be4da6dc5c | ||
|
|
0036ea2204 | ||
|
|
317a04f1b4 | ||
|
|
055c7bcd07 | ||
|
|
50baf9d6f1 | ||
|
|
80956ce0f4 | ||
|
|
2f26dbb1e7 | ||
|
|
516219513a | ||
|
|
8856c38b4d | ||
|
|
08d370b020 | ||
|
|
076f55f380 | ||
|
|
e0672f896d | ||
|
|
c9802440d7 | ||
|
|
15f04e0d13 | ||
|
|
551d540bc7 | ||
|
|
fd409ceea7 | ||
|
|
d7d489681a | ||
|
|
62910b0726 | ||
|
|
8db0efff29 | ||
|
|
fa8751a4ca | ||
|
|
682be0861f | ||
|
|
72e11db185 | ||
|
|
37d23e8581 | ||
|
|
b97b5a7854 | ||
|
|
45ad8e79ed | ||
|
|
11c0cc66cc | ||
|
|
6514261730 | ||
|
|
ecb22f2b2d | ||
|
|
265cb781f9 | ||
|
|
72e088c160 | ||
|
|
782bcec5bc | ||
|
|
f3b91ac75b | ||
|
|
6cac64a3f6 | ||
|
|
2bdcf6528f | ||
|
|
7e37122f9f |
@@ -0,0 +1,154 @@
|
||||
#!/usr/bin/env python3
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
CONFIRMATION_TOKEN = "I-HAVE-CONFIRMED-PRODUCTION-DB-ACCESS"
|
||||
|
||||
PRODUCTION_PATHS = re.compile(
|
||||
r"data/(devplace\.db|devii_tasks\.db|devii_lessons\.db|keys|uploads"
|
||||
r"|attachments|project_files|backups)\b"
|
||||
)
|
||||
PRODUCTION_DB_FILE = re.compile(r"\bdevplace\.db\b")
|
||||
MANAGEMENT_CLI = re.compile(r"(?:^|[;&|(]\s*|\s)(?:[\w./-]*/)?devplace\s+(?!-)")
|
||||
PYTHON_INVOCATION = re.compile(r"(?:^|[;&|(\s])(?:[\w./-]*/)?python[0-9.]*(?:\s|$)")
|
||||
DATABASE_OVERRIDE = re.compile(r"DEVPLACE_DATABASE_URL\s*=\s*[\"']?(\S+?)[\"']?(?:\s|$)")
|
||||
DATABASE_ASSIGNMENT = re.compile(r"DEVPLACE_DATABASE_URL[\"'\]\s]*[=,]")
|
||||
MODULE_INVOCATION = re.compile(r"-m\s+devplacepy")
|
||||
INLINE_CODE = re.compile(r"-c\s+(?P<quote>[\"'])(?P<code>.*?)(?P=quote)", re.DOTALL)
|
||||
SCRIPT_PATH = re.compile(r"(?:^|\s)(?P<path>[\w./~-]+\.py)(?:\s|$)")
|
||||
IMPORT_GATE = re.compile(
|
||||
r"^from devplacepy\.main import app\s*;?\s*(?:print\([^)]*\)\s*;?\s*)?$"
|
||||
)
|
||||
TEST_RUNNER = re.compile(r"\bpytest\b|\bmake\s+(test|test-[\w-]+)\b")
|
||||
SERVER_TARGET = re.compile(r"\bmake\s+(dev|prod|docker-[\w-]+|ppy)\b")
|
||||
|
||||
|
||||
APPLICATION_IMPORT = re.compile(
|
||||
r"(?:^|[\s;])(?:from|import)\s+devplacepy\b"
|
||||
r"|import_module\s*\(\s*[\"']devplacepy",
|
||||
re.MULTILINE,
|
||||
)
|
||||
|
||||
|
||||
def reaches_application(text: str) -> bool:
|
||||
return bool(APPLICATION_IMPORT.search(text))
|
||||
|
||||
|
||||
def overrides_the_database(text: str) -> bool:
|
||||
match = DATABASE_OVERRIDE.search(text)
|
||||
if not match:
|
||||
return False
|
||||
return "data/devplace.db" not in match.group(1)
|
||||
|
||||
|
||||
def source_targets_a_scratch_database(source: str) -> bool:
|
||||
if PRODUCTION_DB_FILE.search(source):
|
||||
return False
|
||||
return bool(DATABASE_ASSIGNMENT.search(source))
|
||||
|
||||
|
||||
def script_is_safe(command: str) -> bool | None:
|
||||
match = SCRIPT_PATH.search(command)
|
||||
if not match:
|
||||
return None
|
||||
path = Path(match.group("path")).expanduser()
|
||||
try:
|
||||
body = path.read_text(errors="replace")
|
||||
except OSError:
|
||||
return None
|
||||
if not reaches_application(body):
|
||||
return True
|
||||
return source_targets_a_scratch_database(body)
|
||||
|
||||
|
||||
def hazard_in(command: str) -> str:
|
||||
if CONFIRMATION_TOKEN in command:
|
||||
return ""
|
||||
if TEST_RUNNER.search(command) or SERVER_TARGET.search(command):
|
||||
return ""
|
||||
if PRODUCTION_DB_FILE.search(command) or PRODUCTION_PATHS.search(command):
|
||||
return "it names the production database or a production data directory"
|
||||
if MANAGEMENT_CLI.search(command):
|
||||
return "the devplace management CLI operates on the production database"
|
||||
if not PYTHON_INVOCATION.search(command):
|
||||
return ""
|
||||
if overrides_the_database(command):
|
||||
return ""
|
||||
if MODULE_INVOCATION.search(command):
|
||||
return "it runs a devplacepy module with no DEVPLACE_DATABASE_URL override"
|
||||
inline = INLINE_CODE.search(command)
|
||||
if inline:
|
||||
code = inline.group("code").strip()
|
||||
if not reaches_application(code):
|
||||
return ""
|
||||
if IMPORT_GATE.match(code):
|
||||
return ""
|
||||
return "it imports devplacepy inline with no DEVPLACE_DATABASE_URL override"
|
||||
safe = script_is_safe(command)
|
||||
if safe is None:
|
||||
return ""
|
||||
if safe:
|
||||
return ""
|
||||
return "the script imports devplacepy with no DEVPLACE_DATABASE_URL override"
|
||||
|
||||
|
||||
def refuse(reason: str) -> dict:
|
||||
return {
|
||||
"hookSpecificOutput": {
|
||||
"hookEventName": "PreToolUse",
|
||||
"permissionDecision": "deny",
|
||||
"permissionDecisionReason": (
|
||||
f"Blocked: this command reaches the production database because {reason}. "
|
||||
"The production database is never touched without the user's explicit, "
|
||||
"stated confirmation. Stop, tell the user exactly what the command would "
|
||||
"read or write, and ask them to confirm in their own words. Only after "
|
||||
f"they have done so may the command carry the literal token "
|
||||
f"{CONFIRMATION_TOKEN}, which still raises a permission prompt they must "
|
||||
"approve. Never add that token on your own initiative. Alternatives that "
|
||||
"need no confirmation: set DEVPLACE_DATABASE_URL to a scratch database, "
|
||||
"or run the test suite."
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def confirm(reason: str) -> dict:
|
||||
return {
|
||||
"hookSpecificOutput": {
|
||||
"hookEventName": "PreToolUse",
|
||||
"permissionDecision": "ask",
|
||||
"permissionDecisionReason": (
|
||||
"This command carries the production-database confirmation token and "
|
||||
f"reaches the production database because {reason}. Approve only if you "
|
||||
"asked for this."
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def main() -> None:
|
||||
try:
|
||||
payload = json.load(sys.stdin)
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
sys.exit(0)
|
||||
command = (payload.get("tool_input") or {}).get("command") or ""
|
||||
if not command:
|
||||
sys.exit(0)
|
||||
if CONFIRMATION_TOKEN in command:
|
||||
stripped = command.replace(CONFIRMATION_TOKEN, "")
|
||||
reason = hazard_in(stripped)
|
||||
if reason:
|
||||
print(json.dumps(confirm(reason)))
|
||||
sys.exit(0)
|
||||
reason = hazard_in(command)
|
||||
if reason:
|
||||
print(json.dumps(refuse(reason)))
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/claude-code-settings.json",
|
||||
"permissions": {
|
||||
"deny": [
|
||||
"Bash(devplace *)",
|
||||
"Write(data/**)",
|
||||
"Edit(data/**)"
|
||||
]
|
||||
},
|
||||
"hooks": {
|
||||
"PreToolUse": [
|
||||
{
|
||||
"matcher": "Bash",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "python3 \"$CLAUDE_PROJECT_DIR/.claude/hooks/guard_production_db.py\"",
|
||||
"timeout": 10,
|
||||
"statusMessage": "Checking for production database access"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
[run]
|
||||
source = devplacepy
|
||||
parallel = true
|
||||
parallel = false
|
||||
sigterm = true
|
||||
omit =
|
||||
tests/*
|
||||
|
||||
+8
-1
@@ -29,9 +29,16 @@ SECRET_KEY=change-me
|
||||
# from the request.
|
||||
DEVPLACE_SITE_URL=
|
||||
|
||||
# Host port the nginx front door binds.
|
||||
# Host port the nginx front door binds (Docker only - the app container's own
|
||||
# internal port stays fixed).
|
||||
PORT=10500
|
||||
|
||||
# Port the uvicorn process itself binds to for `make dev`/`make prod` (bare
|
||||
# metal, no Docker/nginx in front). Also what the app calls itself on
|
||||
# internally (DEVII_BASE_URL default, INTERNAL_GATEWAY_URL). Unrelated to
|
||||
# PORT above - leave unset unless running bare metal on a non-default port.
|
||||
# DEVPLACE_PORT=10500
|
||||
|
||||
# nginx upload ceiling. Must be >= the admin-configurable max_upload_size_mb.
|
||||
NGINX_MAX_BODY_SIZE=50m
|
||||
|
||||
|
||||
Executable
+51
@@ -0,0 +1,51 @@
|
||||
#!/usr/bin/env python3
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(
|
||||
subprocess.run(
|
||||
["git", "rev-parse", "--show-toplevel"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
).stdout.strip()
|
||||
)
|
||||
PYPROJECT = REPO_ROOT / "pyproject.toml"
|
||||
VERSION_LINE = re.compile(r'^version = "(\d+)\.(\d+)\.(\d+)"$', re.MULTILINE)
|
||||
STAGED_VERSION_CHANGE = re.compile(r'^[+-]version = "\d+\.\d+\.\d+"$', re.MULTILINE)
|
||||
|
||||
|
||||
def staged_diff(path: Path) -> str:
|
||||
result = subprocess.run(
|
||||
["git", "diff", "--cached", "--unified=0", "--", str(path)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd=REPO_ROOT,
|
||||
)
|
||||
return result.stdout
|
||||
|
||||
|
||||
def main() -> int:
|
||||
if (REPO_ROOT / ".git" / "MERGE_HEAD").exists():
|
||||
return 0
|
||||
if not PYPROJECT.is_file():
|
||||
return 0
|
||||
if STAGED_VERSION_CHANGE.search(staged_diff(PYPROJECT)):
|
||||
return 0
|
||||
text = PYPROJECT.read_text()
|
||||
match = VERSION_LINE.search(text)
|
||||
if not match:
|
||||
return 0
|
||||
major, minor, patch = (int(part) for part in match.groups())
|
||||
bumped = f'version = "{major}.{minor}.{patch + 1}"'
|
||||
PYPROJECT.write_text(VERSION_LINE.sub(bumped, text, count=1))
|
||||
subprocess.run(["git", "add", str(PYPROJECT)], cwd=REPO_ROOT, check=True)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -1,3 +1,6 @@
|
||||
.dpc
|
||||
.devplace
|
||||
dpc.log
|
||||
.cache
|
||||
.local
|
||||
.devplace_bots/
|
||||
@@ -13,6 +16,10 @@ devplace-init.lock
|
||||
notification-private.pem
|
||||
notification-private.pkcs8.pem
|
||||
notification-public.pem
|
||||
# HOME=/app in the Docker app container, so an rclone.conf created via
|
||||
# `rclone config` or DEVPLACE_RCLONE_CONFIG's default lands here on the
|
||||
# bind-mounted host tree - it holds live remote-storage credentials.
|
||||
/.config/
|
||||
.pytest_cache/
|
||||
.ruff_cache/
|
||||
.opencode
|
||||
|
||||
@@ -19,7 +19,7 @@ DevPlace is a server-rendered social network for developers. FastAPI backend ser
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
make install # pip install -e . + playwright install chromium
|
||||
make install # create .venv if needed, pip install -e ".[dev]", playwright chromium
|
||||
make ppy # build the single shared container image (ppy:latest); run once before launching instances
|
||||
make dev # uvicorn --reload on port 10500, backlog 4096
|
||||
make prod # uvicorn --workers $(WEB_WORKERS) (defaults to nproc), port 10500 (backlog 8192)
|
||||
@@ -33,6 +33,8 @@ make locust # Locust load test, interactive web UI
|
||||
make locust-headless # Locust CLI mode for CI
|
||||
```
|
||||
|
||||
Every Python make target (`install`, `dev`, `prod`, `test*`, `coverage*`, `locust*`) uses `.venv/bin/python`. If `.venv` is missing, make creates it from `python3`, installs `-e ".[dev]"`, and installs Playwright Chromium before running the target. `make install` refreshes that environment. `make clean` removes `.venv` as well as stray bytecode.
|
||||
|
||||
The Makefile exports `PYTHONDONTWRITEBYTECODE=1` for every recipe, so no `.pyc` files or `__pycache__` directories are written by any `make` target. Keep it that way - do not add a target that re-enables bytecode writing. `make clean` removes any stray bytecode left from running Python outside make.
|
||||
|
||||
Preliminary validation: confirm `python -c "from devplacepy.main import app"` imports clean and check each touched language manually (Python compiles/imports, JS parses, CSS braces and HTML tags balance). These checks are gates on the way to the real validation, never a substitute for it: **every change ends with the full test suite (`make test` - all three tiers, every test) and it must pass.**
|
||||
@@ -114,6 +116,7 @@ Tests run on port 10501 with a tempfile SQLite DB and a dedicated `DEVPLACE_DATA
|
||||
| Var | Default | Purpose |
|
||||
|-----|---------|---------|
|
||||
| `DEVPLACE_DATABASE_URL` | `sqlite:///<repo>/data/devplace.db` | Override DB path (tests use this) |
|
||||
| `DEVPLACE_PORT` | `10500` | Port the app itself binds to bare-metal (`make dev`/`make prod`, wired to uvicorn's `--port`; tests use their own `DEVPLACE_TEST_PORT`, default `10501`). Also `config.PORT`'s source, so `DEVPLACE_INTERNAL_BASE_URL`'s default and Devii's self-dial URL (`DEFAULT_BASE_URL`/`INSTANCE_ORIGIN_DEFAULT`) follow it automatically. The Docker app container pins it to `10500` in `docker-compose.yml` regardless of `.env` - Docker's externally reachable port is the unrelated `PORT` var (nginx's host mapping), never this one. |
|
||||
| `SECRET_KEY` | hardcoded fallback | Session signing |
|
||||
| `DEVPLACE_DISABLE_SERVICES` | unset | When `1`, NewsService and other background services skip start (set by test conftest) |
|
||||
| `PLAYWRIGHT_HEADLESS` | `1` in tests | Toggle headed mode |
|
||||
@@ -141,8 +144,9 @@ Nested `CLAUDE.md` files (loaded automatically by Claude Code only when a file i
|
||||
| `devplacepy/services/openai_gateway/CLAUDE.md` | AI gateway: `/openai/v1/*`, usage ledger, provider/model routing |
|
||||
| `devplacepy/services/jobs/CLAUDE.md` | Async job services: zip, fork, SEO diagnostics, SEO metadata, DeepSearch, AI Usage Analyzer |
|
||||
| `devplacepy/services/moderation/CLAUDE.md` | Trust and safety: the reportable-target registry, the content filter and its five choke points, the report queue and its atomic resolution, enforcement, consent, maturity, account deletion |
|
||||
| `devplacepy/services/acceptance/CLAUDE.md` | Acceptance convergence: the opt-in service that grants every policy agreement to every account that has not declined it, its invisibility contract and the ledger-as-decline-register rule |
|
||||
| `devplacepy/services/audit/CLAUDE.md` | Audit log: recorders, categories, retention |
|
||||
| `devplacepy/services/backup/CLAUDE.md` | Backup service: targets, worker, schedules, primary-admin-only download |
|
||||
| `devplacepy/services/backup/CLAUDE.md` | Backup service: targets, worker, schedules, primary-admin-only download, remote offload to Hetzner Storage Box |
|
||||
| `devplacepy/services/telegram/CLAUDE.md` | Telegram bot bridge |
|
||||
| `devplacepy/services/email/CLAUDE.md` | Devii IMAP/SMTP email tools |
|
||||
| `devplacepy/services/gitea/CLAUDE.md` | Issue tracker (Gitea-backed, no local issue store) |
|
||||
@@ -154,6 +158,7 @@ Nested `CLAUDE.md` files (loaded automatically by Claude Code only when a file i
|
||||
| `devplacepy/services/pubsub/CLAUDE.md` | Database-free pub/sub bus |
|
||||
| `devplacepy/push/CLAUDE.md` | Push notification providers: the `PushProvider` protocol, the registry, Web Push and APNs, registration storage |
|
||||
| `devplacepy/services/game/CLAUDE.md` | Code Farm idle game (economy invariants, raids, the one-pure-function rule) |
|
||||
| `devplacepy/services/opinionwar/CLAUDE.md` | Opinion Wars (week-long faction battles on posts: atomic fight/resolve transitions, cooldown-before-coins compensation, event seq allocation, relay-on-lock-owner) |
|
||||
| `devplacepy/services/quiz/CLAUDE.md` | Quizzes (the terminal publish lock, attempt atomicity, answer-key withholding, AI free-text grading, the best-attempt scoreboard) |
|
||||
| `devplacepy/services/CLAUDE.md` | Background task queue, AI correction/modifier, presence, live view relay, `BaseService`/`ServiceManager` |
|
||||
| `devplacepy/database/CLAUDE.md` | Dataset rules, indexing conventions, soft delete, tables, site settings |
|
||||
@@ -181,6 +186,7 @@ Routers in `devplacepy/routers/` are organised as a **directory tree that mirror
|
||||
|--------|--------|
|
||||
| `/auth` | auth/ package |
|
||||
| `/feed`, `/posts`, `/comments` | flat files |
|
||||
| `/topics` | topics.py - crawlable per-topic category index pages (`/topics` hub, `/topics/{topic}` listing) |
|
||||
| `/projects`, `/projects/{slug}/files`, `/projects/{slug}/containers` | projects/ package - see `routers/projects/CLAUDE.md` |
|
||||
| `/profile` | profile/ package (customization, notifications, ai-correction, ai-modifier, interactions, telegram, usage) |
|
||||
| `/messages` | messages.py - see `services/messaging/CLAUDE.md` |
|
||||
@@ -200,6 +206,7 @@ Routers in `devplacepy/routers/` are organised as a **directory tree that mirror
|
||||
| `/game` | game/ package - see `services/game/CLAUDE.md` |
|
||||
| `/reports`, `/admin/moderation`, `/workspaces` | reports.py, admin/moderation.py, workspaces.py - see `services/moderation/CLAUDE.md` |
|
||||
| `/quizzes` | quizzes/ package - see `services/quiz/CLAUDE.md` |
|
||||
| `/battles` | battles.py - see `services/opinionwar/CLAUDE.md` |
|
||||
| (none) | push.py (web push/PWA), docs.py (see `routers/docs/CLAUDE.md`) |
|
||||
| `/pubsub` | pubsub.py - see `services/pubsub/CLAUDE.md` |
|
||||
| (none) | seo.py - `/robots.txt`, `/sitemap.xml` |
|
||||
@@ -255,7 +262,7 @@ Users and guests inject their own CSS and JS, scoped to a page type or globally,
|
||||
|
||||
### Container manager, Devii assistant, AI gateway, async jobs, audit log
|
||||
|
||||
DevPlace ships a Docker-backed **Container manager** (admin-only, one shared `ppy:latest` image, security-hardened rootless workflow via a `sudo` superclone and `aptroot`), the **Devii** in-platform agentic assistant (WebSocket terminal, per-owner-channel sessions, scheduler/reminders, self-learning lessons, user-defined virtual tools, self-configured behavior, browser-automation client tools), the **AI gateway** (`/openai/v1/*`, single point of truth for every AI call, per-user cost attribution, provider/model routing overlay), **async job services** (`JobService` base pattern: zip, fork, SEO diagnostics, SEO metadata generation, DeepSearch, AI Usage Analyzer), and an admin-only append-only **audit log** (`record`/`record_system` entrypoints, `events.md` catalogue of 288 keys, never raises into the caller). These are among the largest subsystems in the codebase - read their dedicated nested `CLAUDE.md` files (see Subsystem map) before working in any of them; do not assume prior knowledge from this summary.
|
||||
DevPlace ships a Docker-backed **Container manager** (admin-only, one shared `ppy:latest` image, security-hardened rootless workflow via a `sudo` superclone and `aptroot`), the **Devii** in-platform agentic assistant (WebSocket terminal, per-owner-channel sessions, scheduler/reminders, self-learning lessons, user-defined virtual tools, self-configured behavior, browser-automation client tools), the **AI gateway** (`/openai/v1/*`, single point of truth for every AI call, per-user cost attribution, provider/model routing overlay), **async job services** (`JobService` base pattern: zip, fork, SEO diagnostics, SEO metadata generation, DeepSearch, AI Usage Analyzer), and an admin-only append-only **audit log** (`record`/`record_system` entrypoints, `events.md` catalogue of every recorded event key, never raises into the caller). These are among the largest subsystems in the codebase - read their dedicated nested `CLAUDE.md` files (see Subsystem map) before working in any of them; do not assume prior knowledge from this summary.
|
||||
|
||||
### Telegram bot, email, devRant compatibility API, issue tracker
|
||||
|
||||
@@ -265,11 +272,21 @@ Devii is also reachable over **Telegram** (one supervised long-poller subprocess
|
||||
|
||||
`devplacepy/seo.py` generates JSON-LD schemas (WebSite, BreadcrumbList, DiscussionForumPosting, ProfilePage, SoftwareApplication). Every router builds context via `base_seo_context(request, ...)`. Auth/messages/notifications are `noindex,nofollow`; profiles with fewer than 2 posts are `noindex,follow`. `/robots.txt` and `/sitemap.xml` are served by `routers/seo.py`. Full implementation map (template layer, heading hierarchy, slugs, related posts, performance, default OG image, SEO tests) is in `devplacepy/routers/CLAUDE.md`.
|
||||
|
||||
## The production database is never touched without explicit confirmation (hard rule)
|
||||
|
||||
`data/devplace.db` is the live production database, and `make dev`, `make prod` and the Docker stack all share it (see "Production deployment"). No agent-initiated command may read or write it, or anything else under `data/`, without the user's explicit, stated confirmation - not a one-click approval, a confirmation they wrote themselves after being told exactly what the command would do.
|
||||
|
||||
This is enforced, not remembered. `.claude/hooks/guard_production_db.py` runs as a `PreToolUse` hook on every Bash call and **denies** the command outright when it reaches production, naming the reason. The interesting case is the one that motivated the rule: a script that never mentions a path at all but imports `devplacepy` and therefore resolves `config.DATA_DIR` to the real database. The hook reads the script and decides on its content, so a scratch-database script passes and an unguarded one does not.
|
||||
|
||||
What the guard blocks: any command naming `data/devplace.db` or a production data directory, the `devplace` management CLI, `python -m devplacepy...`, and any inline `-c` or script file that imports `devplacepy` without a `DEVPLACE_DATABASE_URL` override. What stays free: `make test` and `pytest` (the suite runs on its own temp database), `make dev`/`make prod`/`make docker-*`, the mandated import gate `python -c "from devplacepy.main import app"`, and anything that sets `DEVPLACE_DATABASE_URL` to a scratch file. `permissions.deny` in `.claude/settings.json` additionally refuses `Write`/`Edit` anywhere under `data/`, which the Bash hook cannot see.
|
||||
|
||||
The escape hatch is deliberately two-factor and must never be self-served: after the user has confirmed in their own words, the command may carry the literal token `I-HAVE-CONFIRMED-PRODUCTION-DB-ACCESS`, which downgrades the denial to a permission prompt the user still has to approve. **Never add that token on your own initiative.** Write disposable scripts against a temp database via `DEVPLACE_DATABASE_URL`/`DEVPLACE_DATA_DIR` instead, exactly as "Rigorous correctness verification" already requires.
|
||||
|
||||
## Conventions (project-specific)
|
||||
|
||||
- **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, push, attachments. SSRF-guarded fetches via `net_guard.guarded_async_client(...)` get the fingerprint automatically. Sole exception: 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 and therefore keeps a plain `httpx.AsyncClient`; bolting the Chrome identity onto it would overwrite the very headers it exists to forward. **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).
|
||||
- **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.
|
||||
- **`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`.
|
||||
@@ -335,7 +352,7 @@ Every feature in DevPlace is **one data source fanning out into several consumer
|
||||
|
||||
1. **HTML** - `respond()` returns a rendered template for browsers.
|
||||
2. **JSON** - the SAME `respond(..., model=XOut)` returns JSON when `Accept: application/json`. The `*Out` schema is the gate: a context key not declared on `*Out` is silently dropped from JSON even though the template still sees it.
|
||||
3. **Agent tool** - `services/devii/actions/catalog.py` exposes the route as an `Action` so Devii can call it on the user's behalf. `requires_auth` mirrors the route guard.
|
||||
3. **Agent tool** - `services/devii/actions/catalog/` (the relevant module inside the package, e.g. `posts.py`, `admin.py`) exposes the route as an `Action` so Devii can call it on the user's behalf. `requires_auth` mirrors the route guard.
|
||||
4. **Documented endpoint** - `docs_api.py` `endpoint()` describes it (params, auth, `sample_response`) in the right group, rendered at `/docs/{group}.html`.
|
||||
|
||||
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:
|
||||
@@ -345,7 +362,7 @@ A new public read almost always needs all four. The cardinal failure mode is cha
|
||||
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.
|
||||
6. **Agent + docs layer (the most-forgotten step - do not skip).** `services/devii/actions/catalog.py` - add an `Action` tool if a user could ask Devii to do this (`requires_auth=False` for public reads). `docs_api.py` - every public/auth endpoint gets an `endpoint()` entry with params and a `sample_response`. `routers/docs/pages.py` `DOCS_PAGES` for prose pages. SEO (`seo.py`) - `base_seo_context` and any JSON-LD for a new public page; add to `routers/seo.py` sitemap if indexable.
|
||||
6. **Agent + docs layer (the most-forgotten step - do not skip).** `services/devii/actions/catalog/` (the relevant module inside the package) - add an `Action` tool if a user could ask Devii to do this (`requires_auth=False` for public reads). `docs_api.py` - every public/auth endpoint gets an `endpoint()` entry with params and a `sample_response`. `routers/docs/pages.py` `DOCS_PAGES` for prose pages. SEO (`seo.py`) - `base_seo_context` and any JSON-LD for a new public page; add to `routers/seo.py` sitemap if indexable.
|
||||
7. **Document.** `README.md` (product-facing, routes/config/dependencies/user-visible features), the relevant nested `CLAUDE.md` (any new mechanic, helper, pitfall, or cross-layer wiring), this root `CLAUDE.md` only when a NEW architectural rule or workflow step is introduced (not per feature).
|
||||
8. **Validate.** Confirm a clean import (`python -c "from devplacepy.main import app"`), check each touched language manually, grep for em-dash in touched files (neither the character nor its HTML entity - use a hyphen), and run `pyflakes`/`ruff check` on every touched file. **If the feature touches a spendable resource, a bounded state machine, or a read-then-write mutation reachable from more than one request path, also run the four-layer procedure in "Rigorous correctness verification" above** - it is the default for that shape of feature, not an optional extra. **Finish by running the full persisted test suite (`make test` - every tier, every test) and fixing any failure before the change is done.** Write new tests in the matching tier/path, following the required patterns above.
|
||||
|
||||
@@ -355,6 +372,59 @@ Failures at any implementation step block the workflow - never skip a failed ste
|
||||
|
||||
Gitea Actions workflow at `.gitea/workflows/test.yaml` runs on every push/PR to `master`: installs dependencies + Playwright Chromium, runs the full suite serially under coverage, publishes coverage HTML as an artifact, uploads failure screenshots. CI must be green before merging. Changes move through DTAP: Development (`make dev`) -> Test (CI suite + coverage on `master`) -> Acceptance (`master` to `production` promotion via `make deploy`) -> Production (Docker Compose stack). Only CI-green `master` commits are promoted to `production`.
|
||||
|
||||
## Version bumping
|
||||
|
||||
`pyproject.toml` `version` is bumped automatically, by a real git `pre-commit` hook, not by an agent remembering to edit it. The hook lives at `.githooks/pre-commit` (tracked in the repo, plain stdlib Python) and `make install` points git at it with `git config core.hooksPath .githooks` - run `make install` once per clone (or that one `git config` line by hand) to activate it; a clone that has never run `make install` simply gets no auto-bump, which is a safe, backwards-compatible no-op, never a broken commit.
|
||||
|
||||
On every commit the hook increments the patch component (`1.0.0` -> `1.0.1`) and stages the change, so the bump rides in the same commit with no extra step. It defers to a deliberate version edit already staged in that same commit (a hand-set major/minor bump in `pyproject.toml` is left exactly as written, never incremented further) and does nothing during a merge (`.git/MERGE_HEAD` present) or when `pyproject.toml` does not exist. It never blocks a commit - a missing or unparsable version line is a silent no-op, not a failure.
|
||||
|
||||
## Diagnosing a production failure (the order that finds it fastest)
|
||||
|
||||
This procedure exists because a single "the editor is down" report turned out to be **three unrelated faults stacked on each other** (a stale URL, a firewalled network leg, and a corrupt database), and the investigation wasted hours by guessing before measuring. Work the layers outward from the browser; each step is cheap and each one eliminates a whole class of cause. **Never skip to a hypothesis, and never repair anything before the layer above it is proven healthy.**
|
||||
|
||||
**Layer 0 - is the request even arriving here?** Fetch the hostname over the public internet exactly as it resolves (`curl -sS -o /dev/null -w '%{http_code} %{remote_ip}' https://host/`). Compare the answering IP against this machine's own addresses (`ip -6 addr`, `curl https://api.ipify.org`). Two hostnames serve this platform by different routes - see the topology section above. **Never use `curl --resolve` to force a hostname onto an IP it does not resolve to**; that fabricates a path that no real traffic takes and produces confident, wrong conclusions.
|
||||
|
||||
**Layer 1 - which edge answered?** The error body identifies it. `application/problem+json` with `No site configured for host` is molohttp. A DevPlace HTML error page is the application. An nginx error page is the nginx container. A browser `ERR_*` with no body means nothing well-formed was returned at all.
|
||||
|
||||
**Layer 2 - same failure on both hostnames?** Run the identical authenticated request against `pravda.education` and `devplace.net`. Failing on **both** means the application or the database; failing on **one** means that host's edge. This single comparison is the highest-value measurement available and costs one command.
|
||||
|
||||
**Layer 3 - the application log, before any theory.** `docker logs --since 5m devplace-app-1`. Count error classes rather than reading prose (`grep -c malformed`). A recurring service-loop error is a systemic fault even when it looks unrelated to the symptom.
|
||||
|
||||
**Layer 4 - reproduce the failing hop in isolation.** Point the real code at the real upstream from a scratch harness rather than reasoning about it. Running `forward.proxy_http` against a live code-server is what exposed the duplicate `Date` header; reading the function had not. Use a scratch database (`DEVPLACE_DATABASE_URL`) so the harness never reaches production.
|
||||
|
||||
**Layer 5 - test from where the code actually runs.** The app runs **inside a container**; `127.0.0.1` there is not the host. `docker exec devplace-app-1 curl ...` is the only honest reachability test for a container-to-container hop. A hang with zero bytes means a packet was **DROPped** (firewall), a refusal means nothing is listening, and a slow error means the upstream answered badly - three different causes with three different fixes.
|
||||
|
||||
**Layer 6 - confirm the object exists before blaming the plumbing.** A 404 from a guard is not a proxy failure. Resolve the identifier through the application's own read surface (the workspace page, an admin JSON endpoint) with the affected account's session. A stale instance uid in a bookmarked URL looks exactly like an outage.
|
||||
|
||||
### Rules learned the hard way
|
||||
|
||||
- **State what a command will read or write before running it against production, and keep production access read-only until the diagnosis is complete.** The one write in a repair is the final swap, and it comes after verification, not before.
|
||||
- **Copy before repairing, and copy the whole set.** A WAL-mode SQLite database is `.db` **plus** `-wal` **plus** `-shm`; a `.db`-only copy silently discards every transaction still in the WAL. Stop writes first, or the snapshot is inconsistent. Never leave a stale `-wal` beside a recovered file - SQLite will replay it and re-corrupt the result.
|
||||
- **Repair on a copy, verify on the copy, and prove what was preserved.** `PRAGMA integrity_check` names the damaged objects; index damage is derived data and costs nothing (`REINDEX`, or `.recover`), while a table b-tree fault is the only kind that can lose rows. Diff row counts table by table between the original and the recovered file and report the delta - "it says ok" is not evidence that data survived.
|
||||
- **Verify the fix through the user's own path, with their account, in a real browser.** A green unit test and a 200 from `curl` did not prove the editor worked; driving Playwright through login, the code-server password prompt and a `.monaco-workbench` selector did.
|
||||
- **A measurement recorded in these files can go stale.** `services/containers/CLAUDE.md` recorded that `container_ip:port` times out from the app container while `gateway:published_host_port` connects. A later change (`make docker-attach`) inverted it, and a host firewall closed the documented leg entirely. Re-measure before trusting a recorded measurement, and update the record when it turns out to be false.
|
||||
- **Report each fault separately and correct yourself explicitly.** Three stacked faults produce a symptom that no single explanation covers, and an early wrong theory is worse than no theory once it is repeated as fact.
|
||||
|
||||
## Production hostnames and the devplace.net SSH tunnel (verified topology, do not re-derive)
|
||||
|
||||
**The platform answers on two public hostnames, and they reach the same application by two completely different paths.** This has already cost one debugging session; the failure mode is that a `curl --resolve devplace.net:443:<production ip>` "test" reports `No site configured for host: devplace.net` and looks like a total outage, when in fact devplace.net never touches the production edge at all.
|
||||
|
||||
| | `pravda.education` | `devplace.net` |
|
||||
|---|---|---|
|
||||
| DNS | `95.216.15.238`, `2a01:4f9:2a:100e::2` | `88.198.21.243`, `2a01:4f8:222:2c45::2` |
|
||||
| Machine | the production host itself | a separate front host (Hetzner, PTR `static.88-198-21-243.clients.your-server.de`) |
|
||||
| Path in | molohttp on `:443` -> `127.0.0.1:10500` | its own proxy -> **SSH tunnel** -> `127.0.0.1:10500` on production |
|
||||
| Reaches molohttp | yes | **no, never** |
|
||||
|
||||
**`devplace.net` is a front host that forwards over SSH.** It holds a persistent SSH session into the production host (visible there as an established inbound connection from `88.198.21.243` to port 22) and forwards through it to `127.0.0.1:10500`, which is the `docker-proxy` for the `devplace-nginx` container. The listening socket lives on the **front** host (an `ssh -L` style local forward), so the production host shows **no** sshd-owned listener - that absence is expected and is not evidence against the tunnel.
|
||||
|
||||
Two consequences that must not be forgotten:
|
||||
|
||||
- **molohttp has no `devplace.net` site, and that is correct.** Its site list is `mail`/`smtp`/`imap.molodetz.nl`, `pravda.education` and `*.tunnel.pravda.education`. devplace.net traffic enters below molohttp, straight into `127.0.0.1:10500`, so it needs no site. **Never "fix" this by adding a devplace.net site to molohttp** - devplace.net does not resolve to the production host, so such a site could never match, and its absence is not a bug.
|
||||
- **Both hostnames land on the same nginx and the same app**, so a request that fails on both is failing in the application, not in either edge. That comparison is the fastest triage available here: run the same authenticated request against both hostnames. Same failure on both means look at the app or the database; a failure only on devplace.net means look at the front host's proxy (WebSocket `Upgrade` headers are the usual culprit, exactly as for the production nginx locations below).
|
||||
|
||||
**Testing rule.** Never point a hostname at an IP it does not resolve to in order to "test" it. Fetch each hostname over the public internet as it really resolves (`curl https://devplace.net/...` and `curl https://pravda.education/...`), because forcing devplace.net onto the production IP tests molohttp with a `Host` it deliberately does not serve and proves nothing about the real path.
|
||||
|
||||
## Production deployment
|
||||
|
||||
Single-host Docker Compose (`docker-compose.yml`): an **app** container (Uvicorn, 2 workers) behind an **nginx** container. Full operator reference is the admin-only `Production` docs section (`templates/docs/production*.html`); conventions that must not regress:
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@ FROM python:3.13-slim
|
||||
WORKDIR /app
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
curl ca-certificates \
|
||||
curl ca-certificates rclone \
|
||||
libglib2.0-0 libpango-1.0-0 libpangoft2-1.0-0 libharfbuzz0b libfontconfig1 \
|
||||
fonts-dejavu-core \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
@@ -8,21 +8,42 @@ LOCUST_RUN_TIME ?= 120s
|
||||
LOCUST_WEB_WORKERS ?= 4
|
||||
WEB_WORKERS ?= $(shell nproc 2>/dev/null || echo 2)
|
||||
DEVPLACE_RATE_LIMIT ?= 1000000
|
||||
DEVPLACE_PORT ?= 10500
|
||||
|
||||
VENV ?= $(CURDIR)/.venv
|
||||
PYTHON := $(VENV)/bin/python
|
||||
VENV_STAMP := $(VENV)/.installed
|
||||
BOOTSTRAP_PYTHON := $(shell command -v python3 2>/dev/null || command -v python 2>/dev/null)
|
||||
|
||||
PYTHONDONTWRITEBYTECODE := 1
|
||||
export PYTHONDONTWRITEBYTECODE
|
||||
|
||||
.PHONY: install dev clean tree tree-loc zip test test-headed test-unit test-api test-e2e test-fast test-failed test-first-failure test-slowest test-cache-clean coverage coverage-headed coverage-html locust locust-headless
|
||||
.PHONY: venv install dev prod clean tree tree-loc zip test test-headed test-unit test-api test-e2e test-fast test-failed test-first-failure test-slowest test-cache-clean coverage coverage-headed coverage-html locust locust-headless
|
||||
|
||||
install:
|
||||
pip install -e .
|
||||
python -m playwright install chromium
|
||||
$(PYTHON):
|
||||
@test -n "$(BOOTSTRAP_PYTHON)" || { echo "python3 is required to create $(VENV)"; exit 1; }
|
||||
$(BOOTSTRAP_PYTHON) -m venv $(VENV)
|
||||
|
||||
dev:
|
||||
uvicorn devplacepy.main:app --reload --reload-dir devplacepy --host 0.0.0.0 --port 10500 --backlog 4096
|
||||
$(VENV_STAMP): $(PYTHON) pyproject.toml
|
||||
$(PYTHON) -m pip install -U pip
|
||||
$(PYTHON) -m pip install -e ".[dev]"
|
||||
$(PYTHON) -m playwright install chromium
|
||||
touch $(VENV_STAMP)
|
||||
|
||||
prod:
|
||||
DEVPLACE_STATIC_VERSION=$$(date +%s) DEVPLACE_TEMPLATE_AUTO_RELOAD=0 DEVPLACE_WEB_WORKERS=$(WEB_WORKERS) uvicorn devplacepy.main:app --host 0.0.0.0 --port 10500 --workers $(WEB_WORKERS) --backlog 8192 --proxy-headers --forwarded-allow-ips '*'
|
||||
venv: $(VENV_STAMP)
|
||||
|
||||
install: $(PYTHON)
|
||||
$(PYTHON) -m pip install -U pip
|
||||
$(PYTHON) -m pip install -e ".[dev]"
|
||||
$(PYTHON) -m playwright install chromium
|
||||
git config core.hooksPath .githooks
|
||||
touch $(VENV_STAMP)
|
||||
|
||||
dev: $(VENV_STAMP)
|
||||
DEVPLACE_PORT=$(DEVPLACE_PORT) $(PYTHON) -m uvicorn devplacepy.main:app --reload --reload-dir devplacepy --host 0.0.0.0 --port $(DEVPLACE_PORT) --backlog 4096
|
||||
|
||||
prod: $(VENV_STAMP)
|
||||
DEVPLACE_STATIC_VERSION=$$(date +%s) DEVPLACE_TEMPLATE_AUTO_RELOAD=0 DEVPLACE_WEB_WORKERS=$(WEB_WORKERS) DEVPLACE_PORT=$(DEVPLACE_PORT) $(PYTHON) -m uvicorn devplacepy.main:app --host 0.0.0.0 --port $(DEVPLACE_PORT) --workers $(WEB_WORKERS) --backlog 8192 --proxy-headers --forwarded-allow-ips '*'
|
||||
|
||||
delete-pyc:
|
||||
find . -name "__pycache__" -type d -prune -exec rm -rf {} + 2>/dev/null || true
|
||||
@@ -42,78 +63,78 @@ zip:
|
||||
@git ls-files -z | xargs -0 zip -q $(notdir $(CURDIR)).zip
|
||||
@printf 'Wrote %s (%s files)\n' "$(notdir $(CURDIR)).zip" "$$(git ls-files | wc -l)"
|
||||
|
||||
test:
|
||||
PLAYWRIGHT_HEADLESS=1 python -m pytest tests/
|
||||
test: $(VENV_STAMP)
|
||||
PLAYWRIGHT_HEADLESS=1 $(PYTHON) -m pytest tests/
|
||||
|
||||
test-headed:
|
||||
PLAYWRIGHT_HEADLESS=0 python -m pytest tests/
|
||||
test-headed: $(VENV_STAMP)
|
||||
PLAYWRIGHT_HEADLESS=0 $(PYTHON) -m pytest tests/
|
||||
|
||||
test-unit:
|
||||
python -m pytest tests/unit
|
||||
test-unit: $(VENV_STAMP)
|
||||
$(PYTHON) -m pytest tests/unit
|
||||
|
||||
test-api:
|
||||
python -m pytest tests/api
|
||||
test-api: $(VENV_STAMP)
|
||||
$(PYTHON) -m pytest tests/api
|
||||
|
||||
test-e2e:
|
||||
PLAYWRIGHT_HEADLESS=1 python -m pytest tests/e2e
|
||||
test-e2e: $(VENV_STAMP)
|
||||
PLAYWRIGHT_HEADLESS=1 $(PYTHON) -m pytest tests/e2e
|
||||
|
||||
test-fast:
|
||||
python -m pytest tests/unit tests/api
|
||||
test-fast: $(VENV_STAMP)
|
||||
$(PYTHON) -m pytest tests/unit tests/api
|
||||
|
||||
test-failed:
|
||||
PLAYWRIGHT_HEADLESS=1 python -m pytest tests/ --last-failed --last-failed-no-failures none
|
||||
test-failed: $(VENV_STAMP)
|
||||
PLAYWRIGHT_HEADLESS=1 $(PYTHON) -m pytest tests/ --last-failed --last-failed-no-failures none
|
||||
|
||||
test-first-failure:
|
||||
PLAYWRIGHT_HEADLESS=1 python -m pytest tests/ -x
|
||||
test-first-failure: $(VENV_STAMP)
|
||||
PLAYWRIGHT_HEADLESS=1 $(PYTHON) -m pytest tests/ -x
|
||||
|
||||
test-slowest:
|
||||
PLAYWRIGHT_HEADLESS=1 python -m pytest tests/ --durations=40
|
||||
test-slowest: $(VENV_STAMP)
|
||||
PLAYWRIGHT_HEADLESS=1 $(PYTHON) -m pytest tests/ --durations=40
|
||||
|
||||
coverage:
|
||||
coverage: $(VENV_STAMP)
|
||||
rm -f .coverage .coverage.*
|
||||
COVERAGE_PROCESS_START=$(CURDIR)/.coveragerc PLAYWRIGHT_HEADLESS=1 \
|
||||
python -m coverage run -m pytest tests/
|
||||
python -m coverage combine
|
||||
python -m coverage report
|
||||
$(PYTHON) -m coverage run -m pytest tests/
|
||||
$(PYTHON) -m coverage combine
|
||||
$(PYTHON) -m coverage report
|
||||
|
||||
coverage-headed:
|
||||
coverage-headed: $(VENV_STAMP)
|
||||
rm -f .coverage .coverage.*
|
||||
COVERAGE_PROCESS_START=$(CURDIR)/.coveragerc PLAYWRIGHT_HEADLESS=0 \
|
||||
python -m coverage run -m pytest tests/
|
||||
python -m coverage combine
|
||||
python -m coverage report
|
||||
$(PYTHON) -m coverage run -m pytest tests/
|
||||
$(PYTHON) -m coverage combine
|
||||
$(PYTHON) -m coverage report
|
||||
|
||||
coverage-html: coverage
|
||||
python -m coverage html
|
||||
$(PYTHON) -m coverage html
|
||||
@echo "Report written to htmlcov/index.html"
|
||||
|
||||
locust:
|
||||
locust: $(VENV_STAMP)
|
||||
export DEVPLACE_DATABASE_URL="sqlite:///$(LOCUST_DB)"; \
|
||||
export DEVPLACE_RATE_LIMIT=$(DEVPLACE_RATE_LIMIT); \
|
||||
fuser -k $(LOCUST_PORT)/tcp 2>/dev/null || true; \
|
||||
sleep 1; \
|
||||
mkdir -p $(LOCUST_DB_DIR); \
|
||||
rm -f $(LOCUST_DB); \
|
||||
DEVPLACE_TEMPLATE_AUTO_RELOAD=0 DEVPLACE_WEB_WORKERS=$(LOCUST_WEB_WORKERS) uvicorn devplacepy.main:app --host 127.0.0.1 --port $(LOCUST_PORT) --workers $(LOCUST_WEB_WORKERS) --backlog 8192 > /tmp/devplace_locust_server.log 2>&1 & \
|
||||
DEVPLACE_TEMPLATE_AUTO_RELOAD=0 DEVPLACE_WEB_WORKERS=$(LOCUST_WEB_WORKERS) $(PYTHON) -m uvicorn devplacepy.main:app --host 127.0.0.1 --port $(LOCUST_PORT) --workers $(LOCUST_WEB_WORKERS) --backlog 8192 > /tmp/devplace_locust_server.log 2>&1 & \
|
||||
PID=$$!; \
|
||||
while kill -0 $$PID 2>/dev/null && ! curl -s http://127.0.0.1:$(LOCUST_PORT)/ > /dev/null 2>&1; do sleep 0.5; done; \
|
||||
if ! kill -0 $$PID 2>/dev/null; then echo "Server failed to start (port $(LOCUST_PORT) busy?). See /tmp/devplace_locust_server.log"; exit 1; fi; \
|
||||
locust --host http://127.0.0.1:$(LOCUST_PORT) --web-port $(LOCUST_WEB_PORT); \
|
||||
$(PYTHON) -m locust --host http://127.0.0.1:$(LOCUST_PORT) --web-port $(LOCUST_WEB_PORT); \
|
||||
kill $$PID 2>/dev/null || true; \
|
||||
rm -rf $(LOCUST_DB_DIR)
|
||||
|
||||
locust-headless:
|
||||
locust-headless: $(VENV_STAMP)
|
||||
export DEVPLACE_DATABASE_URL="sqlite:///$(LOCUST_DB)"; \
|
||||
export DEVPLACE_RATE_LIMIT=$(DEVPLACE_RATE_LIMIT); \
|
||||
fuser -k $(LOCUST_PORT)/tcp 2>/dev/null || true; \
|
||||
sleep 1; \
|
||||
mkdir -p $(LOCUST_DB_DIR); \
|
||||
rm -f $(LOCUST_DB); \
|
||||
DEVPLACE_TEMPLATE_AUTO_RELOAD=0 DEVPLACE_WEB_WORKERS=$(LOCUST_WEB_WORKERS) uvicorn devplacepy.main:app --host 127.0.0.1 --port $(LOCUST_PORT) --workers $(LOCUST_WEB_WORKERS) --backlog 8192 > /tmp/devplace_locust_server.log 2>&1 & \
|
||||
DEVPLACE_TEMPLATE_AUTO_RELOAD=0 DEVPLACE_WEB_WORKERS=$(LOCUST_WEB_WORKERS) $(PYTHON) -m uvicorn devplacepy.main:app --host 127.0.0.1 --port $(LOCUST_PORT) --workers $(LOCUST_WEB_WORKERS) --backlog 8192 > /tmp/devplace_locust_server.log 2>&1 & \
|
||||
PID=$$!; \
|
||||
while kill -0 $$PID 2>/dev/null && ! curl -s http://127.0.0.1:$(LOCUST_PORT)/ > /dev/null 2>&1; do sleep 0.5; done; \
|
||||
if ! kill -0 $$PID 2>/dev/null; then echo "Server failed to start (port $(LOCUST_PORT) busy?). See /tmp/devplace_locust_server.log"; exit 1; fi; \
|
||||
locust --host http://127.0.0.1:$(LOCUST_PORT) --headless -u $(LOCUST_USERS) -r $(LOCUST_SPAWN_RATE) --run-time $(LOCUST_RUN_TIME) --html=$(LOCUST_DB_DIR)/report.html; \
|
||||
$(PYTHON) -m locust --host http://127.0.0.1:$(LOCUST_PORT) --headless -u $(LOCUST_USERS) -r $(LOCUST_SPAWN_RATE) --run-time $(LOCUST_RUN_TIME) --html=$(LOCUST_DB_DIR)/report.html; \
|
||||
kill $$PID 2>/dev/null || true; \
|
||||
rm -rf $(LOCUST_DB_DIR)
|
||||
|
||||
@@ -135,10 +156,11 @@ test-cache-clean:
|
||||
COMPOSE := docker compose -f docker-compose.yml -f docker-compose.containers.yml
|
||||
DEVPLACE_DATA_DIR ?= $(CURDIR)/data
|
||||
DOCKER_GID ?= $(shell stat -c '%g' /var/run/docker.sock 2>/dev/null)
|
||||
DEVPLACE_CONTAINER_NETWORK ?= bridge
|
||||
export DEVPLACE_DATA_DIR
|
||||
export DOCKER_GID
|
||||
|
||||
.PHONY: docker-build docker-up docker-reload docker-down docker-logs docker-clean docker-prep ppy
|
||||
.PHONY: docker-build docker-up docker-attach docker-reload docker-down docker-logs docker-clean docker-prep ppy
|
||||
|
||||
# Build the single shared container image every instance runs. Build once;
|
||||
# rebuild only when ppy.Dockerfile, the sudo shim, or pagent change.
|
||||
@@ -153,10 +175,23 @@ docker-build: docker-prep
|
||||
|
||||
docker-up: docker-prep
|
||||
$(COMPOSE) up -d
|
||||
$(MAKE) docker-attach
|
||||
|
||||
# Workspace tunnels reach a container port that was never published on the host,
|
||||
# so the app must sit on the same docker network as the instances it runs. The
|
||||
# default bridge rejects the network-scoped aliases compose always sends, so
|
||||
# this cannot live in docker-compose.containers.yml and is wired here instead.
|
||||
docker-attach:
|
||||
@app=$$($(COMPOSE) ps -q app); \
|
||||
test -n "$$app" || { echo "app container is not running"; exit 1; }; \
|
||||
docker network connect $(DEVPLACE_CONTAINER_NETWORK) $$app 2>/dev/null \
|
||||
&& echo "attached app to the $(DEVPLACE_CONTAINER_NETWORK) network" \
|
||||
|| echo "app is already on the $(DEVPLACE_CONTAINER_NETWORK) network"
|
||||
|
||||
docker-reload:
|
||||
$(COMPOSE) restart app
|
||||
$(COMPOSE) up -d --wait
|
||||
$(MAKE) docker-attach
|
||||
|
||||
docker-down:
|
||||
$(COMPOSE) down
|
||||
|
||||
@@ -7,7 +7,7 @@ Server-rendered social network for developers. FastAPI backend serving Jinja2 te
|
||||
## Quick start
|
||||
|
||||
```bash
|
||||
make install # pip install -e .
|
||||
make install # create .venv if needed, pip install -e ".[dev]", playwright chromium
|
||||
make dev # uvicorn --reload on port 10500
|
||||
make test # Playwright integration + unit tests, headless, fail-fast
|
||||
make test-headed # same tests in visible browser
|
||||
@@ -63,11 +63,13 @@ devplacepy/
|
||||
| `/` | Home page: marketing splash for guests, personalized home (welcome, feed shortcut, latest posts, news) for signed-in users. Does not redirect. Latest-posts section interleaves authors so no two consecutive posts share an author. |
|
||||
| `/auth` | Signup, login, logout, forgot/reset password |
|
||||
| `/feed` | Post feed with topic/tab filtering and free-text `search` (title, content, and author username) in the left panel (public). Each page interleaves authors so no two consecutive posts share an author. |
|
||||
| `/topics` | Crawlable per-topic category pages (public): `/topics` hub links every topic with a live post count, `/topics/{topic}` lists that topic's posts with its own canonical URL, title, and breadcrumbs |
|
||||
| `/news` | Developer news listing, detail page with comments |
|
||||
| `/posts` | Post detail, creation |
|
||||
| `/gists` | Code gist listing, detail, creation, and editing; left panel offers language filtering and free-text `search` (title, description, and author username), public read |
|
||||
| `/comments` | Comment creation, owner editing (`POST /comments/edit/{comment_uid}`), deletion |
|
||||
| `/projects` | Project listing (left panel offers type filtering and free-text `search` over title, description, and author username), creation, owner editing (`POST /projects/edit/{slug}`), and per-project visibility toggles: `POST /projects/{slug}/private` (owner-only visibility) and `POST /projects/{slug}/readonly` (immutable files). A project hidden by a member stays visible to administrators, but a project hidden by an administrator is visible only to that owner administrator - other administrators cannot see it, its files, or its containers (web UI and REST API alike). The primary administrator (the first Admin account) is the single exception and retains full visibility |
|
||||
| `/projects/{slug}` | Dedicated project page: one encompassing card with a cover banner and project logo (owner-uploaded through the standard attachment uploader), the title overlaid on the banner, status/type/platform chips, owner-set Website and Repository links, section tabs (Overview, Devlog, Screenshots, Comments, Files), an About section, the Devlog timeline of every post linked to the project (owners post updates straight from the page via the shared composer preset to the `devlog` topic), a Screenshots gallery built from image attachments (owners add more from the More menu), and a sidebar with links, stats and the author card |
|
||||
| `/projects/{slug}/files` | Per-project filesystem: directory and file CRUD, upload, inline editing, and line-range operations (`lines` read, `replace-lines`, `insert-lines`, `delete-lines`, `append`) for surgical edits to large text files (public read, owner write; all writes refused while the project is read-only) |
|
||||
| `/zips` | Zip job status (`/zips/{uid}`) and archive download (`/zips/{uid}/download`); archives are queued via `/projects/{slug}/zip` and `/projects/{slug}/files/zip` |
|
||||
| `/forks` | Fork job status (`/forks/{uid}`); forks are queued via `/projects/{slug}/fork`. Any signed-in user can fork a project they can view into a new project they own; once the job finishes the response carries the new project URL |
|
||||
@@ -81,7 +83,7 @@ devplacepy/
|
||||
| `/admin/trash` | Admin **Trash**: review, restore, and permanently purge soft-deleted content (posts, comments, gists, projects, news, project files, attachments) across the platform |
|
||||
| `/admin/devii-tasks` | Admin **Devii tasks**: every scheduled task across all owners with its schedule, run count, expiry and failure streak, plus per-task disable and delete |
|
||||
| `/notifications` | Notification list, mark read, live unread counts (`/notifications/counts`) |
|
||||
| `/messages` | Real-time direct messaging over WebSocket (`/messages/ws`): live bidirectional delivery, optimistic send, typing indicators, read receipts, and online/last-seen presence. Messages render through the shared content pipeline (emoji shortcodes, image, video and audio embeds, autolink, sanitization). AI content correction and the AI modifier apply to direct messages, so typing an inline `@ai <instruction>` in a message executes it and the resolved result appears live in the chat for both participants (the `message` WS frame carries an additive `ai_processed` flag when a pending correction/modification was applied). An opened conversation loads its 500 most recent messages; older history is retained in the database. `GET /messages/conversations` returns the conversation list as JSON for a live client refresh; the `POST /messages/send` form remains as a no-JavaScript fallback and now accepts an attachment-only, empty-content message. `POST /messages/ws-ticket` exchanges a session/API-key auth to a short-lived (30s), single-use ticket a browser WebSocket can carry as `?ticket=...` on the handshake, since a native `WebSocket` cannot set an `Authorization`/`X-API-KEY` header - this is what makes off-session embedding of the chat possible. CLI: `devplace messaging prune-tickets` removes expired tickets |
|
||||
| `/messages` | Real-time direct messaging over WebSocket (`/messages/ws`): live bidirectional delivery, optimistic send, typing indicators, read receipts, reconnect catch-up, and online/last-seen presence. Messages render through the shared content pipeline (emoji shortcodes, image, video and audio embeds, autolink, sanitization). AI content correction and the AI modifier apply to direct messages: the original is delivered immediately, then a second `message` frame with `ai_processed` replaces the bubble for both participants when the rewrite lands (including across workers). An opened conversation loads its 500 most recent messages and can page older rows with `?before=`; switching threads does not drop the socket. `GET /messages/conversations` returns the conversation list as JSON for a live client refresh; the `POST /messages/send` form remains as a no-JavaScript fallback and now accepts an attachment-only, empty-content message. `POST /messages/ws-ticket` exchanges a session/API-key auth to a short-lived (30s), single-use ticket a browser WebSocket can carry as `?ticket=...` on the handshake, since a native `WebSocket` cannot set an `Authorization`/`X-API-KEY` header - this is what makes off-session embedding of the chat possible. CLI: `devplace messaging prune-tickets` removes expired tickets |
|
||||
| `/votes` | Upvote/downvote on posts, comments, projects |
|
||||
| `/reactions` | Emoji reactions on posts, comments, gists, projects |
|
||||
| `/bookmarks` | Save/unsave content; `/bookmarks/saved` personal list |
|
||||
@@ -90,11 +92,13 @@ devplacepy/
|
||||
| `/reports` | Content reporting: `POST /reports/{target_type}/{target_uid}` files a report against any user-generated surface, `GET /reports/mine` lists the reports you filed and their outcome, `GET /reports/reasons` serves the reason registry so every client renders the same dialog |
|
||||
| `/admin/moderation` | Admin **Moderation** queue: reported content oldest-open-first with the response-window badge, one report per detail page with the offender's history, triage (`/status`) and decisions (`/decide`) |
|
||||
| `/workspaces/index` | Public index of every workspace published to the ingress proxy, with owner, project, maturity label and direct link |
|
||||
| `/projects/{slug}/workspace` | A member's dev workspace for a project: open/start/stop/delete, quota and idle status, public tunnels, and the **Editor** card holding their editor preferences (`GET`/`POST /projects/{slug}/workspace/editor`) |
|
||||
| `/block` | Block/unblock a user: hides all of their posts, comments and messages from you everywhere except their own profile, and stops them notifying you. Also reachable directly from every content action bar |
|
||||
| `/mute` | Mute/unmute a user: stops them creating notifications for you while their content stays visible |
|
||||
| `/leaderboard` | Contributor ranking by total stars earned |
|
||||
| `/game` | **Code Farm** cooperative idle game (member-only): plant projects that build over real time, harvest coins and XP, upgrade CI, buy plots, and water friends' builds at `/game/farm/{username}`. Live over pub/sub; every endpoint negotiates JSON |
|
||||
| `/quizzes` | **Quizzes**: author quizzes, play them, and climb the cross-quiz scoreboard. Three-column hub with filters (`all`/`todo`/`done`/`mine`/`drafts`), search, per-viewer state badges, and the scoreboard rail; `/quizzes/{slug}` detail, `/quizzes/{slug}/edit` builder, `/quizzes/{slug}/attempts/{uid}` player, `/quizzes/scoreboard` JSON. Publishing is permanent. Every endpoint negotiates JSON |
|
||||
| `/battles` | **Opinion Wars**: week-long two-faction battles attached to posts, started from the composer's *Start Opinion War* builder. Members join a side and fight once a day (25 Code Farm coins, level-weighted damage); the pixel-art battle card shows live HP bars, a countdown, top contributors and an event ticker. `/battles` lists battles (`active`/`ended`/`mine` + search); `/battles/{uid}` state, `/battles/{uid}/events` replay, `/battles/{uid}/join` and `/battles/{uid}/fight` actions |
|
||||
| `/avatar` | Multiavatar proxy with in-memory cache |
|
||||
| `/issues` | Issue tracker backed by Gitea: list/detail/comment/status, AI-enhanced filing, an admin planning report over a selectable set of open tickets (each ticket's full text reproduced verbatim so the document hands straight to a coding agent), and file attachments on open issues and comments (mirrored to the Gitea tracker) |
|
||||
| `/admin/services` | Background service management (start/stop, config, status, logs) |
|
||||
@@ -167,6 +171,25 @@ scoreboard on the right. Guests read published quizzes and see the board; they c
|
||||
Retention: completed attempts are permanent; abandoned and expired ones are garbage-collected by
|
||||
`devplace quiz prune`.
|
||||
|
||||
## Opinion Wars
|
||||
|
||||
**Opinion Wars** (`/battles`) are week-long two-faction battles attached to posts, in the spirit of
|
||||
old eRepublik battles: settle tabs-versus-spaces by showing up daily and fighting for your side.
|
||||
|
||||
- **Start one from the composer.** The *Start Opinion War* button next to *Add poll* names the two
|
||||
factions; the battle runs for exactly 7 days from the moment the post is published.
|
||||
- **Join and fight.** Any signed-in member picks a side and may fight once every 24 hours per
|
||||
battle. A fight costs 25 Code Farm coins and deals deterministic, level-weighted damage
|
||||
(100 HP + 10 per site level, capped at level 20) - no randomness, dedication wins wars.
|
||||
- **Defection is allowed.** Switch factions any time; damage already dealt stays where it landed.
|
||||
- **Live pixel-art card.** The battle renders on the post as a CSS pixel-art battlefield with HP
|
||||
bars, a countdown, your rank, top contributors and a live event ticker (joins, defections,
|
||||
fights, lead changes) over pub/sub with an incremental replay fallback.
|
||||
- **Rewards.** When the week ends the bigger total wins: every fighter earns XP, the winning side
|
||||
and the top damage dealer earn bonuses, and battle badges (*Instigator*, *First Blood*,
|
||||
*War Veteran*, *Champion*) mark the milestones. Notifications cover lead changes, the result and
|
||||
your next fight being ready.
|
||||
|
||||
## Code Farm
|
||||
|
||||
The **Code Farm** (`/game`) is a cooperative idle game in the spirit of Farmville, themed for developers. Each member owns a farm of plots and plays asynchronously - nothing has to happen in real time.
|
||||
@@ -200,6 +223,7 @@ The farm refreshes live over the pub/sub bus (a watered build appears on the own
|
||||
- **Emoji reactions** - react with **any** emoji on posts, comments, gists, and projects, separate from voting and carrying no ranking weight. A short quick-pick palette covers the common reactions, and a `+` button next to it opens the full searchable emoji picker (every standard emoji, including skin tones), so a reaction is never limited to a preset list. Emoji already used on an item are shown as counted chips beside the palette.
|
||||
- **Emoji shortcodes** - typing a `:name:` shortcode in any content (posts, comments, titles, project and gist descriptions, news, and direct messages) renders the matching emoji, using the full GitHub/Discord standard set (for example `:rocket:` becomes a rocket). Server-rendered and live content share one shortcode list; unknown names and shortcodes inside code are left untouched. Documented at `/docs/emoji-shortcodes`. This is distinct from the visual emoji-picker button in the composer, which inserts the literal emoji character.
|
||||
- **Polls** - a post can carry a poll (question plus up to six options); results appear as live bars once the viewer votes, one vote per member. A poll can be attached when the post is created or added later by editing a post that has none.
|
||||
- **Paste an image to attach it** - pressing Ctrl+V (Cmd+V) with a screenshot or copied image on the clipboard while writing a post, a comment, a direct message, an issue, a gist, or a project attaches it immediately, with no trip through the file picker. The upload, its limits, and the resulting attachment are identical to picking the file by hand.
|
||||
- **Bookmarks** - save posts, gists, projects, and news to a personal list at `/bookmarks/saved`.
|
||||
- **Private projects** - an owner can mark a project private so it is visible only to them (and administrators) and excluded from listings, profiles, search, the sitemap, and zip access. Set at creation or toggled later from the project page.
|
||||
- **Read-only projects** - an owner can mark a project read-only, making its entire virtual filesystem immutable: every write, edit, line-edit, move, delete, and upload is refused from all paths (the web UI, the HTTP API, the Devii agent, and container workspace sync) until read-only is turned off. Devii may toggle read-only only after the user explicitly confirms.
|
||||
@@ -236,12 +260,14 @@ The log is **administrator-only**. `/admin/audit-log` is a paginated, filterable
|
||||
|---------|---------|---------|
|
||||
| `DEVPLACE_DATABASE_URL` | `sqlite:///<repo>/data/devplace.db` | Database connection string |
|
||||
| `DEVPLACE_DATA_DIR` | `<repo>/data` | Single root for every runtime/user-generated artifact (DB, uploads, VAPID keys, locks, bot state, job staging, container workspaces), outside the package and not served via `/static`. Point at a volume in production. Defined once in `config.py` (`DATA_PATHS` registry, created by `ensure_data_dirs()`) |
|
||||
| `DEVPLACE_PORT` | `10500` | Port the app itself binds to (`make dev`/`make prod`, and what `Makefile`'s `dev`/`prod` targets pass to uvicorn's `--port`). Also feeds `config.PORT`, so `DEVPLACE_INTERNAL_BASE_URL`'s default and Devii's own self-dial URL follow it automatically. Bare metal only - the Docker app container always binds its fixed internal port regardless of this var; for Docker, use `PORT` (below) to change the externally reachable port |
|
||||
| `PORT` | `10500` | Docker only: the host port `docker-compose.yml` publishes nginx on (`127.0.0.1:${PORT}:80`). Unrelated to `DEVPLACE_PORT` above - it never reaches the app container |
|
||||
| `SECRET_KEY` | hardcoded fallback | Session signing key |
|
||||
| `DEVPLACE_VAPID_SUB` | `mailto:retoor@molodetz.nl` | Contact address in the VAPID JWT `sub` claim |
|
||||
| `DEVPLACE_INTERNAL_BASE_URL` | `http://localhost:10500` | Base URL the platform's own services dial for the AI gateway |
|
||||
| `DEVPLACE_XMLRPC_PORT` | `10550` | Loopback port the forking XML-RPC bridge binds; the app and nginx reverse-proxy `/xmlrpc` to it |
|
||||
| `DEVPLACE_XMLRPC_BIND` | `127.0.0.1` | Bind address for the XML-RPC bridge (loopback; the app and nginx are the intended front doors) |
|
||||
| `DEVPLACE_STATIC_VERSION` | server boot unix timestamp | Cache-busting version stamped into every static asset URL (`/static/v<version>/...`). Set it at launch so multiple workers agree (the `prod` target and Docker image do this); leave unset in dev to refresh on each reload. See [Static asset caching](#static-asset-caching) |
|
||||
| `DEVPLACE_STATIC_VERSION` | server boot unix timestamp | The boot-id half of the cache-busting version stamped into every static asset URL (`/static/v<app-version>-<boot-id>/...`, e.g. `/static/v1.0.1-1718040000/...`). Set it at launch so multiple workers agree (the `prod` target and Docker image do this); leave unset in dev to refresh on each reload. See [Static asset caching](#static-asset-caching) |
|
||||
| `DEEPSEEK_API_KEY` / `OPENROUTER_API_KEY` | unset | Upstream provider keys; migrated into the gateway settings on first boot |
|
||||
| `DEVPLACE_PRESENCE_TIMEOUT_SECONDS` | `60` | Online-presence window: a user counts as online for this many seconds after their last activity. `last_seen` is refreshed by a throttled in-place update at most once per half this interval per worker (no per-load inserts, no data growth) |
|
||||
| `DEVPLACE_PRESENCE_ONLINE_LIMIT` | `30` | Maximum avatars shown in the feed's live "Online now" panel (ordered alphabetically by username) |
|
||||
@@ -429,20 +455,87 @@ and its full configuration are documented automatically - including future servi
|
||||
- **`ConfigField`** - declarative parameter spec (type, default, validation, secret) a service uses to declare its editable settings
|
||||
- **`BaseService`** - abstract class with a reconciling run loop that honors the persisted `enabled`/command/interval state, plus `config_fields`, `get_config()`, `describe()`, and a log buffer
|
||||
- **`ServiceManager`** - singleton: `register`, `describe_all`, `set_enabled`, `send_command`, `save_config`, `supervise`, `shutdown_all`
|
||||
- **`NewsService`** - a fully automatic, zero-maintenance news pipeline: fetches news from `news.app.molodetz.nl/api`, cleans each article, fetches and perceptually compares the images to reject placeholders and detect uniqueness, grades each one deterministically, reformats every valid article into clean Markdown (paragraphs, headings, lists) with the AI so the source wall of text reads as a proper article, and auto-rotates the best articles to Featured and the landing page. Its AI spend is metered from the gateway response headers and reported on the admin Services page (calls, tokens, total cost, and per-call averages)
|
||||
- **`NewsService`** - a fully automatic, zero-maintenance news pipeline: fetches news from `news.app.molodetz.nl/api`, cleans each article, fetches and perceptually compares the images to reject placeholders and detect uniqueness, grades each one against a free, local, deterministic quality model (aquality - no billed LLM call, no API key), optionally reformats articles that clear the publish grade threshold into clean Markdown with a generative AI model (off by default, since the default grading model cannot generate text), and auto-rotates the best articles to Featured and the landing page. Any AI spend it does incur is metered from the gateway response headers and reported on the admin Services page (calls, tokens, total cost, and per-call averages)
|
||||
- **`BotsService`** - Playwright fleet of AI personas that browse and interact with a DevPlace instance, with live cost/usage metrics and a live screenshot monitor at `/admin/bots` (opt-in; install the `bots` extra)
|
||||
- **`GatewayService`** - OpenAI-compatible LLM gateway at `/openai/v1/*`, forwarding to DeepSeek (default) with optional vision augmentation; the single point of truth for AI that every other service routes through (enabled by default). Every chat request is made date-aware by injecting the current date in EU `DD/MM/YYYY` format into the system message when it contains no date already (date only, never time, to keep upstream prompt caching effective), and an admin-configurable system preamble (`gateway_system_preamble`) can be prepended ahead of the client's system message on every call. **Provider and model routing** (admin **Gateway** page, `/admin/gateway`) maps any number of requested model names onto named upstream providers and target models, each with its own pricing economy (input, output and cache-hit/cache-miss rates per million tokens) and an optional vision model that describes image content before forwarding, so text and vision models are merged transparently. Unmapped requests fall through to the default upstream unchanged, so existing clients are unaffected
|
||||
- **`GatewayService`** - OpenAI-compatible LLM gateway at `/openai/v1/*`, forwarding to DeepSeek (default) with optional vision augmentation; the single point of truth for AI that every other service routes through (enabled by default). Every chat request is made date-aware by injecting the current date in EU `DD/MM/YYYY` format into the system message when it contains no date already (date only, never time, to keep upstream prompt caching effective), and an admin-configurable system preamble (`gateway_system_preamble`) can be prepended ahead of the client's system message on every call. **Thinking is disabled by default** on every chat and vision call (`thinking.type=disabled` on DeepSeek, `reasoning.effort=none` on OpenRouter, `think=false` on Ollama) so the fast path is the default; a client may re-enable it per request, and an administrator may flip `gateway_thinking` on. **Provider and model routing** (admin **Gateway** page, `/admin/gateway`) maps any number of requested model names onto named upstream providers and target models, each with its own pricing economy (input, output and cache-hit/cache-miss rates per million tokens) and an optional vision model that describes image content before forwarding, so text and vision models are merged transparently. Unmapped requests fall through to the default upstream unchanged, so existing clients are unaffected. Each route may also name a **fallback model** - another already-configured model of the same kind, picked from a select box of the public model names (never a free-text provider model id) - that the gateway retries once, automatically, whenever the primary model fails after its own retries are exhausted, so a struggling model degrades to a working one instead of failing the caller
|
||||
- **`JobService` / `ZipService` / `ForkService`** - generic async job framework (`services/jobs/`) for heavy, blocking work run off the request path; `ZipService` builds project zip archives in a subprocess, `ForkService` copies a project into a new project owned by the forking user
|
||||
- **`ContainerService`** - the admin container manager (`services/containers/`): a reconciling supervisor for container instances, all running one shared prebuilt image
|
||||
- **`AcceptanceService`** - grants every policy agreement (Terms of Service, Privacy Policy, third-party AI processing, activity recording, container credentials) to every account that has not declined it, so an instance kept production-identical for extended manual testing never interrupts with an acceptance dialog. Administrator-only, **off by default**, with a separate switch per agreement type and a dry run that reports what it would do without writing. An account that withdrew a consent is never granted it again, with no further action: the consent ledger itself is the decline register. It is not appropriate on a real production host
|
||||
|
||||
### Container manager (admin only)
|
||||
|
||||
`services/containers/` runs supervised container instances from the web UI, the HTTP API, and Devii. It drives the `docker` CLI via async subprocesses behind a pluggable `Backend` interface (a `DockerCliBackend` plus a `FakeBackend` for tests). **There is no in-app image building.** Every instance runs ONE shared prebuilt image, `ppy:latest` (override `DEVPLACE_CONTAINER_IMAGE`), built once with **`make ppy`** from `ppy.Dockerfile`: a Python + Playwright base with a broad set of common libraries preinstalled, the `pravda` (uid 1000) user, the sudo superclone, `pagent` at `/usr/bin/pagent.py`, and the `code-server` browser IDE that backs dev workspaces all baked in. Creating an instance is then an instant `docker run` (it fails fast with a clear error if the `ppy` image has not been built yet). `ContainerService` reconciles desired instance state against `docker ps` each tick (containers are labeled `devplace.instance=<uid>`, so orphans are reaped and no state is lost), applies restart policies, fires cron/interval/one-time schedules, and samples metrics. The container's `/app` is bind-mounted to a persistent project workspace (materialized from the project files) and **stays in sync automatically**: the manager runs a bidirectional, newer-wins sync between the project files and the workspace on every start and roughly once a minute while running, so edits made inside the container and edits made in the project file editor converge without manual intervention (the sync only ever creates or overwrites the older copy of a file, never deletes one; a read-only project is export-only). Projects that need extra packages use runtime `pip install` (pravda owns the site-packages, no sudo needed) or `apt install` directly (the `pravda` user runs `apt`/`dpkg` through a fakeroot wrapper, so system packages install without root) or add the library to `ppy.Dockerfile` and rerun `make ppy`. Each instance can run a **boot script** in Python or Bash (written into the workspace and run on launch) or a plain boot command, can be set to **start automatically** whenever the container service starts, and can be configured to **run as** a chosen DevPlace user - which only selects whose identity and API key are injected into the container (the container always runs as the unprivileged `pravda` user). Every status change is recorded and shown as a status history on the instance page. A running instance can be **published** with an `ingress_slug`, making its service reachable (HTTP and WebSocket) at `/p/<slug>` through DevPlace. The manager is reached two ways: the admin **Containers** sidebar entry (`/admin/containers`) lists, creates, edits, and controls every instance across all projects and opens a dedicated detail page per instance, and each project page carries an admin-only **Containers** button to its own instance manager.
|
||||
`services/containers/` runs supervised container instances from the web UI, the HTTP API, and Devii. It drives the `docker` CLI via async subprocesses behind a pluggable `Backend` interface (a `DockerCliBackend` plus a `FakeBackend` for tests). **There is no in-app image building.** Every instance runs ONE shared prebuilt image, `ppy:latest` (override `DEVPLACE_CONTAINER_IMAGE`), built once with **`make ppy`** from `ppy.Dockerfile`: a Python + Playwright base with a broad set of common libraries preinstalled, the `pravda` (uid 1000) user, the sudo superclone, `pagent` at `/usr/bin/pagent.py`, and the `code-server` browser IDE that backs dev workspaces all baked in. Creating an instance is then an instant `docker run` (it fails fast with a clear error if the `ppy` image has not been built yet). `ContainerService` reconciles desired instance state against `docker ps` each tick (containers are labeled `devplace.instance=<uid>`, so orphans are reaped and no state is lost), applies restart policies, fires cron/interval/one-time schedules, and samples metrics. The container's `/app` is bind-mounted to a persistent project workspace (materialized from the project files) and **stays in sync automatically**: the manager runs a bidirectional sync between the project files and the workspace on every start and roughly once a minute while running, so edits made inside the container and edits made in the project file editor converge without manual intervention. Deletions propagate too, in both directions: deleting a file inside the container removes it from the project, and deleting it in the project's file editor removes it from the container, tracked against a per-file sync baseline so a genuine deletion is never confused with a file that simply has not been materialized to that workspace yet; an edit made after a conflicting deletion always wins and restores the file (a read-only project is export-only and always mirrors the project verbatim, including removing files the project no longer has). Projects that need extra packages use runtime `pip install` (pravda owns the site-packages, no sudo needed) or `apt install` directly (the `pravda` user runs `apt`/`dpkg` through a fakeroot wrapper, so system packages install without root) or add the library to `ppy.Dockerfile` and rerun `make ppy`. Each instance can run a **boot script** in Python or Bash (written into the workspace and run on launch) or a plain boot command, can be set to **start automatically** whenever the container service starts, and can be configured to **run as** a chosen DevPlace user - which only selects whose identity and API key are injected into the container (the container always runs as the unprivileged `pravda` user). Every status change is recorded and shown as a status history on the instance page. A running instance can be **published** with an `ingress_slug`, making its service reachable (HTTP and WebSocket) at `/p/<slug>` through DevPlace. The manager is reached two ways: the admin **Containers** sidebar entry (`/admin/containers`) lists, creates, edits, and controls every instance across all projects and opens a dedicated detail page per instance, and each project page carries an admin-only **Containers** button to its own instance manager.
|
||||
|
||||
**Security:** this requires mounting the Docker socket, which grants host root. Every run, exec, lifecycle, and schedule operation is administrator-only; `--privileged` is never used and all docker calls are argument-list subprocesses. Containers are additionally isolated per user: an instance is managed only by its owner (the administrator who created it, or the owner of its project) and by the primary administrator, who alone sees and manages every instance including those on private projects; other administrators get a read-only view of instances on public projects and none of another user's private-project instances (exec, terminals, schedules, edits, and lifecycle actions are all refused and audited as denied). The service is disabled by default; an admin enables **Containers** on `/admin/services`. CLI: `devplace containers list | reconcile | prune | prune-builds | gc-workspaces` (`prune-builds` is a one-time cleanup that removes legacy per-project images and the old dockerfiles/builds tables).
|
||||
|
||||
**Runtime data** (container workspaces and zip archives) lives in `DEVPLACE_DATA_DIR` (default `data/`), **outside the package and never served via `/static`**. The docker daemon must be able to bind-mount the data dir for `/app`.
|
||||
|
||||
### Dev Workspaces and the browser editor
|
||||
|
||||
A **workspace** is a member-facing container running the DevPlace browser editor, layered on the
|
||||
container runtime above. It is opened from a project's **Workspace** page and reached at
|
||||
`/projects/{slug}/workspace`; the editor itself is proxied at
|
||||
`/projects/{slug}/containers/instances/{uid}/code/`, and an **Editor** button appears on the project
|
||||
page whenever the editor is actually reachable.
|
||||
|
||||
**The workspace page reports the editor's real state, live.** A workspace has a *phase* derived on
|
||||
the server from its desired state, its container status and a TCP probe of the editor port:
|
||||
`stopped`, `starting`, `ready`, `stopping`, `crashed` or `suspended`. The page renders the control
|
||||
that matches the phase, so pressing **Start** turns the button into a spinner reading *Starting the
|
||||
editor* and the **Open editor** link appears only once code-server answers, never while the
|
||||
container is still booting. While a workspace is in transition the page polls every two seconds
|
||||
(twenty seconds otherwise) and also receives pushed updates on the owner's private pub/sub topic, so
|
||||
the label changes on its own without a reload; the project page's **Editor** button follows the same
|
||||
readiness rule. The phase, its label and `editor_ready` are part of the workspace JSON.
|
||||
|
||||
The editor is `code-server`, rebranded as DevPlace end to end: the application name, the browser tab
|
||||
icon and PWA icons, the login page styling, and `product.json` all carry DevPlace, and a bundled
|
||||
built-in extension ships the **DevPlace Dark** and **DevPlace Light** themes (generated from the
|
||||
site's own design tokens), a **Get started on DevPlace** walkthrough, a project status bar item and
|
||||
five `DevPlace:` commands. Nothing in the interface identifies as code-server.
|
||||
|
||||
**On boot** two terminals open: a focused **DevPlace Code** terminal already running `dpc`, the
|
||||
coding agent baked into the image, and a plain login shell beside it with the Python, Rust, Nim and
|
||||
Swift toolchains on `PATH`. Both are configurable, and `bash` stays the default profile for
|
||||
terminals the member opens later.
|
||||
|
||||
**The first boot of a workspace opens the DevPlace welcome page** beside the terminals: a webview
|
||||
introducing the workspace and DevPlace Code (its 900k token context, vision, parallel sub-agents,
|
||||
deep research, safety gates and the daily credits it runs on), with example prompts and buttons that
|
||||
focus the agent terminal, start the walkthrough, show the public tunnels and open the workspace
|
||||
settings. It is shown once per workspace and can be reopened with **DevPlace: Show the welcome
|
||||
page**. The editor's own built-in chat assistant and VS Code's own welcome page stay suppressed so
|
||||
`dpc` is the only agent on offer and every token it spends is ledgered against the member's DevPlace
|
||||
account. `dpc`'s own working files (`.dpc/`, `dpc.log`) are in `SYNC_SKIP_NAMES`, so running an
|
||||
agent on every boot never pollutes the project.
|
||||
|
||||
**The terminal panel gets about a third of the window by default.** Every preset is a fixed number
|
||||
of steps up from the panel's minimum height (`normal`, the default, lands at roughly a third of a
|
||||
typical window; `short` at a fifth, `tall` at about half) and `maximized` fills the editor area.
|
||||
A preset is applied on the first boot of a workspace and again whenever it changes; a height the
|
||||
member drags themselves is kept across restarts.
|
||||
|
||||
**Every workspace is trusted.** VS Code Restricted Mode is disabled at the command line and in the
|
||||
seeded settings, so nothing prompts and automatic tasks run. This is a deliberate default with a
|
||||
real consequence (a project's own `.vscode/tasks.json` will run on folder open), it is documented to
|
||||
members on `/docs/workspace-editor.html`, and an administrator can restore Restricted Mode site-wide
|
||||
with the `workspace_editor_trust_all` setting.
|
||||
|
||||
**Four sizes are configurable through one resolver.** Editor and terminal font size plus zoom, the
|
||||
editor layout and terminal panel preset, whether the editor opens in a tab or a sized window, and the
|
||||
container's CPU, memory and disk. The first three are the member's own preferences on their workspace
|
||||
page (and over the API, and through Devii's `workspace_editor_get` / `workspace_editor_set`); the
|
||||
container size is part of the administrator-set workspace quota. Each preference resolves instance
|
||||
override, then the member's row, then the site setting, then the built-in default, and the page shows
|
||||
which of those each value came from.
|
||||
|
||||
**A member edit is never overwritten.** DevPlace seeds the editor's `settings.json` from the host
|
||||
before each launch and records exactly what it wrote; on the next launch it updates only the keys
|
||||
whose current value is still the one it wrote. A setting the member changed inside the editor is
|
||||
theirs permanently, while a change to the site default still reaches everyone who has expressed no
|
||||
preference. Preferences apply on the next workspace start, and the page says so and offers the
|
||||
restart.
|
||||
|
||||
### Async job framework and zip downloads
|
||||
|
||||
`services/jobs/` is the standard way to run blocking work asynchronously and hand the caller a result URL. A shared `jobs` table is the queue (discriminated by `kind`); `queue.enqueue()` inserts a `pending` row from any worker, the lock-owning worker processes jobs in `JobService.run_once` (reap, recover orphans, refill up to a concurrency limit, prune expired), and status is polled from the database. Retention is built in: each job service deletes its own expired artifacts via a `cleanup` hook (default 7 days, admin-configurable). To add a kind, subclass `JobService`, set `kind`, and implement `process()` and `cleanup()`.
|
||||
@@ -455,7 +548,7 @@ and its full configuration are documented automatically - including future servi
|
||||
|
||||
`SeoMetaService` is a separate AI subservice that generates a clean, search-optimised title, description and short keyword list for every published post, project, gist, news article and issue, entirely off the request path so it never slows the web server. The work is queued whenever content is created, edited or published; until the AI value is ready a plain-content default (built from the markdown-stripped text and clamped to safe lengths) fills the fields, so a page's metadata is **always populated, never empty**. The service uses the built-in internal AI gateway and meters its own AI cost and statistics in a dedicated usage table, surfaced together with its live task pipeline on the **Admin -> Services** page. This release also fixes the on-page metadata: the `<meta name="description">` is now stripped of markdown markup (it previously leaked `#`, `**` and `[](...)` from the raw body), a `<meta name="keywords">` tag is emitted (a short honest list, not stuffed), and social-card image dimensions and alt text are added. CLI: `devplace seo-meta prune` / `devplace seo-meta clear` (job rows only; the generated metadata persists).
|
||||
|
||||
`DeepsearchService` powers the public **Tools -> DeepSearch** researcher. Given a single research question it plans a set of diverse web search queries, interleaves their results so every angle contributes sources, and crawls the most relevant pages in a subprocess (concurrent fetches, plain HTTP first with a headless-browser fallback for JavaScript-heavy pages, PDF documents streamed and text-extracted, every URL SSRF-guarded). Social sites that block bots (X, YouTube, Reddit and similar) contribute the readable text supplied by the search engine, so their content is not lost to a login wall. A readability-grade extractor isolates the main article content of each page (navigation, cookie banners and footers are discarded), and the configured depth follows the most relevant in-page links for deeper coverage. Content is de-duplicated and indexed into a per-session ChromaDB vector collection (embeddings via the AI gateway with a local fallback). A chain of agents (report writer, findings extractor, linker) then synthesises a thorough cited markdown report grounded on the passages retrieved from that index, with key findings, a confidence score and source diversity; if synthesis fails the report is clearly marked as degraded instead of silently shipping raw source material. Progress streams live over `WS /tools/deepsearch/{uid}/ws`; the report is at `/tools/deepsearch/{uid}/session` (HTML or JSON) and can be exported as Markdown, JSON or PDF. A grounded chat over the session's evidence runs at `WS /tools/deepsearch/{uid}/chat` using hybrid retrieval (vector + keyword/BM25). Runs can be paused, resumed or cancelled. A cross-session URL cache avoids re-fetching pages seen by earlier runs. CLI: `devplace deepsearch prune` / `devplace deepsearch clear`. ChromaDB, weasyprint, pypdf and Playwright are core dependencies.
|
||||
`DeepsearchService` powers the public **Tools -> DeepSearch** researcher, an iterative agent modelled on the way Perplexity and OpenAI's own deep-research agents work: search, read, decide what is still missing, search again. Given a single research question it plans a set of diverse web search queries, interleaves their results so every angle contributes sources, and crawls the most relevant pages in a subprocess (concurrent fetches, plain HTTP first with a headless-browser fallback for JavaScript-heavy pages, PDF documents streamed and text-extracted, every URL SSRF-guarded). After the first pass it runs up to two automatic gap-filling refinement rounds - a lightweight planner looks at what has been gathered so far and proposes a few more targeted queries only if real coverage gaps remain, bounded by the page budget, and stops the moment nothing more is needed. Social sites that block bots (X, YouTube, Reddit and similar) contribute the readable text supplied by the search engine, so their content is not lost to a login wall. A readability-grade extractor isolates the main article content of each page (navigation, cookie banners and footers are discarded), and the configured depth follows the most relevant in-page links for deeper coverage. Content is de-duplicated and indexed into a per-session ChromaDB vector collection (embeddings via the AI gateway with a local fallback); retrieval reranks candidate passages with Maximal Marginal Relevance so the report is grounded on genuinely diverse evidence rather than several near-duplicate hits on the same page. A chain of agents (report writer, findings extractor, linker) then synthesises a thorough cited markdown report grounded on the passages retrieved from that index, with key findings, a confidence score, source diversity and a short list of suggested follow-up questions you can ask straight into the chat; if synthesis fails the report is clearly marked as degraded instead of silently shipping raw source material. Progress streams live over `WS /tools/deepsearch/{uid}/ws`; the report is at `/tools/deepsearch/{uid}/session` (HTML or JSON) and can be exported as Markdown, JSON or PDF. A grounded chat over the session's evidence runs at `WS /tools/deepsearch/{uid}/chat` using hybrid retrieval (vector + keyword/BM25). Runs can be paused, resumed or cancelled. A cross-session URL cache avoids re-fetching pages seen by earlier runs. CLI: `devplace deepsearch prune` / `devplace deepsearch clear`. ChromaDB, weasyprint, pypdf and Playwright are core dependencies.
|
||||
|
||||
`IsslopService` powers the public **Tools -> AI Usage Analyzer**, which classifies a git repository or website as AI slop, sophisticated AI-assisted work or genuine human work. Sources are acquired in an isolated subprocess (git URLs are probed with `git ls-remote` and shallow-cloned with a 3 GB guard; websites render in a stealth headless browser with an HTTP fallback, bounded by depth, file and byte caps), inventoried with exclusion rules, and scored by a multi-signal static engine (twenty-one detector families, 126 signal types across an origin axis and a quality-deficit axis). For a live website, the analyzer also opens the home page in a headless browser and inspects what actually renders: it fingerprints AI website-builder platforms directly (Lovable, Bolt.new, Framer and others) and checks the page's real computed styles, layout and build artifacts, not just its file contents. Representative files receive an AI review pass and images an AI-generation review through the internal gateway (model `molodetz`, internal key); the static engine stays authoritative when the gateway is unreachable. Every pipeline step is persisted as an ordered event trail and streamed live over the pub/sub topic `public.isslop.{uid}`. The verdict is an A-F authenticity grade with a human/AI split and one of five categories (`ai-slop`, `sophisticated-ai`, `human-clean`, `human-messy`, `uncertain`), published as a persistent report with an embeddable SVG badge. Members keep their analysis history on their account; guest history is session-bound and claimed by the account on the first signed-in visit. Admin settings (private-host allowance, AI/image review toggles, image cap, retention, concurrency) live on `/admin/services`. CLI: `devplace isslop analyze <url>` / `devplace isslop prune` / `devplace isslop clear`. Playwright plus playwright-stealth back the website crawler.
|
||||
|
||||
@@ -488,9 +581,10 @@ Configuration on the Services tab (`/admin/services`):
|
||||
|-----------|---------|---------|
|
||||
| `news_grade_threshold` | `7` | Minimum AI grade for auto-publish |
|
||||
| `news_api_url` | `https://news.app.molodetz.nl/api` | News source |
|
||||
| `news_ai_url` | `http://localhost:10500/openai/v1/chat/completions` | AI grading endpoint (the internal gateway) |
|
||||
| `news_ai_model` | `molodetz` | Generic model name; the gateway maps it to the real model |
|
||||
| `news_ai_key` | internal key | AI API key (`NEWS_AI_KEY` env, then the auto-generated gateway internal key) |
|
||||
| `news_ai_url` | `https://aquality.cloud.pravda.education/v1/chat/completions` | AI grading endpoint - a free, local, deterministic quality model by default |
|
||||
| `news_ai_model` | `aquality` | Generic model name; echoed back, not used for routing |
|
||||
| `news_ai_key` | internal key | AI API key (`NEWS_AI_KEY` env, then the auto-generated gateway internal key); unused by the default aquality endpoint |
|
||||
| `news_format_enabled` | `false` | Reformat article bodies into Markdown with AI after grading; off by default since aquality only scores and cannot generate text - enable when `news_ai_url` points at a generative chat model |
|
||||
| `news_service_interval` | `3600` | Seconds between fetch cycles (min 60) |
|
||||
|
||||
News articles have detail pages at `/news/{slug}` with full comment support (same component as posts/projects). The landing page can display curated articles toggled from admin.
|
||||
@@ -587,6 +681,7 @@ Configuration on the Services tab:
|
||||
| `gateway_upstream_url` | `https://api.deepseek.com/chat/completions` | Where requests are forwarded |
|
||||
| `gateway_model` | `deepseek-v4-flash` | Real model sent upstream (what `molodetz` maps to); 1M-token context, 384K max output |
|
||||
| `gateway_force_model` | on | Override the client-requested model (and the `molodetz` alias) |
|
||||
| `gateway_thinking` | off | When off, chat completions disable model thinking unless the client explicitly enables it (`think` / `thinking` / `reasoning`). Fastest default. |
|
||||
| `gateway_api_key` | migrated from env | Upstream key, shown and editable (auto-migrated from `DEEPSEEK_API_KEY`/`OPENROUTER_API_KEY`) |
|
||||
| `gateway_instances` | `4` | Max concurrent upstream forwards per worker (pool + semaphore) |
|
||||
| `gateway_timeout` | `300` | Upstream timeout (seconds); minimum five minutes; also bounds the vision describe-image call |
|
||||
@@ -850,13 +945,17 @@ receives a notification through every provider they hold a live subscription for
|
||||
| Provider | Registration | Transport |
|
||||
|----------|--------------|-----------|
|
||||
| `webpush` | `PushSubscription` from the browser `PushManager` (endpoint + `p256dh`/`auth` keys) | Web Push Protocol, VAPID signed, `aesgcm` encrypted payload |
|
||||
| `apns` | Hexadecimal device token | `POST https://api.push.apple.com/3/device/{token}` over HTTP/2, ES256 provider token |
|
||||
| `apns` | Hexadecimal device token, optional stable `client_id` | Dedicated HTTP/2 client to `api.push.apple.com` or `api.sandbox.push.apple.com` (`POST /3/device/{token}`), ES256 provider token. Not the stealth/Chrome client. |
|
||||
|
||||
`POST /push.json` accepts a registration for any active provider; a body without a
|
||||
`provider` field is a `webpush` body, so browsers need no change. `GET /push.json` returns
|
||||
the VAPID public key plus the providers currently accepting registrations. A provider that
|
||||
is disabled or not fully configured accepts no registrations and is skipped during
|
||||
delivery, so an unconfigured provider is inert rather than an error.
|
||||
`provider` field is a `webpush` body, so browsers need no change. An APNs body is
|
||||
`{"provider": "apns", "token": "...", "client_id": "..."}` - `client_id` is optional and
|
||||
identifies the device across Apple token rotations, so a new token updates that row
|
||||
instead of inserting another. A token-only body still works and revives a previously
|
||||
dead token. `GET /push.json` returns the VAPID public key plus the providers currently
|
||||
accepting registrations; when `apns` is active it includes `environment` (`production` or
|
||||
`sandbox`). A provider that is disabled or not fully configured accepts no registrations
|
||||
and is skipped during delivery, so an unconfigured provider is inert rather than an error.
|
||||
|
||||
Every provider setting is edited at **`/admin/services/push`**: per provider an `Enabled`
|
||||
toggle, the VAPID subject for `webpush`, and team id, key id, `.p8` auth key (stored as a
|
||||
@@ -882,6 +981,7 @@ in real time, bridged onto the in-process pub/sub bus by a lock-owner relay:
|
||||
| Direct message received | receiver |
|
||||
| Comment on your post | post author |
|
||||
| Reply to your comment | comment author |
|
||||
| Any comment on a post you've also commented on | every other commenter on that post |
|
||||
| `@mention` in any content | mentioned user |
|
||||
| Upvote on your content | content owner |
|
||||
| New follower | followed user |
|
||||
@@ -891,13 +991,15 @@ in real time, bridged onto the in-process pub/sub bus by a lock-owner relay:
|
||||
`create_notification` schedules delivery as a fire-and-forget async task, so a dead
|
||||
subscription or push-service error never blocks the triggering request. Delivery
|
||||
(`push.notify_user`) reads a user's subscriptions once, groups them by provider, builds
|
||||
each provider's payload once, and sends over a single shared HTTP client. A subscription
|
||||
the push service reports as gone (`404`/`410` for Web Push, `410` or an `Unregistered`
|
||||
class reason for APNs) is soft-deleted; any other failure is logged and the subscription is
|
||||
kept.
|
||||
each provider's payload once, and sends over that provider's own HTTP client (stealth for
|
||||
Web Push, a dedicated HTTP/2 client for APNs). A subscription the push service reports as
|
||||
gone (`404`/`410` for Web Push, `410` or an `Unregistered` class reason for APNs) is
|
||||
soft-deleted and the provider reason is logged; any other failure is logged and the
|
||||
subscription is kept. APNs environment is stored per registration so a sandbox debug
|
||||
token and a production token can coexist.
|
||||
|
||||
A notification is also **marked read automatically when you open the page that shows its
|
||||
content** - viewing a post clears its comment, reply, upvote and mention notifications;
|
||||
content** - viewing a post clears its comment, reply, thread, upvote and mention notifications;
|
||||
opening a conversation clears its direct-message notifications; visiting a profile clears
|
||||
the matching follow, badge and level notifications; and the issue, reminder and farm-raid
|
||||
notifications clear on their respective pages. You no longer have to dismiss each one by
|
||||
@@ -953,7 +1055,7 @@ offline. Installation requires a secure origin (HTTPS, or `localhost` for develo
|
||||
|------|------|
|
||||
| `devplacepy/push/providers/` | Provider protocol, Web Push (VAPID keys, payload encryption), APNs |
|
||||
| `devplacepy/push/store.py` | `push_registration` reads and writes |
|
||||
| `devplacepy/push/delivery.py` | `notify_user` - group by provider, deliver, reap dead subscriptions |
|
||||
| `devplacepy/push/delivery.py` | `notify_user` / `notify_registration` - group by provider, per-provider client, reap dead subscriptions |
|
||||
| `devplacepy/services/push/service.py` | Provider configuration at `/admin/services/push`, retention sweep, metrics |
|
||||
| `devplacepy/routers/push.py` | `/push.json`, `/service-worker.js`, `/manifest.json` |
|
||||
| `static/js/PushManager.js` | Service-worker registration + subscribe + opt-in UI |
|
||||
@@ -1075,6 +1177,10 @@ What the overlay (`docker-compose.containers.yml`) changes:
|
||||
- **Data dir at a consistent path (critical).** When the app (in its container) runs `docker run -v <path>:/app`, the daemon resolves `<path>` against the **host**, not the app container. So the workspace/data dir must be mounted at the **same absolute path** on host and in the container - the make targets set `DEVPLACE_DATA_DIR` to the project's `./data` (an absolute host path) and mount it at that identical path on both sides. (Build contexts go through the docker API as a tarball, so they can stay in the container's temp dir - only the `/app` bind mount needs path consistency.)
|
||||
- **Ingress reach:** published container ports live on the **host**, so the overlay sets `DEVPLACE_CONTAINER_PROXY_HOST=host.docker.internal` (with `extra_hosts: host-gateway`) so the `/p/<slug>` proxy can reach them. On a bare-metal `make prod` deploy the app is already on the host, so the default `127.0.0.1` works and no overlay is needed (just install the docker CLI and run the services).
|
||||
|
||||
One piece of wiring cannot live in the overlay:
|
||||
|
||||
- **Workspace tunnel reach.** A workspace tunnel serves a port the member chose, which is almost never published on the host, so the app has to dial the container directly - and it can only do that from the container's own docker network. Compose cannot attach a service to the default `bridge` network (it always sends network-scoped aliases, which that network rejects), so `make docker-up` and `make docker-reload` run `make docker-attach`, an idempotent `docker network connect` of the app container to `DEVPLACE_CONTAINER_NETWORK` (default `bridge`). A bare `docker compose up -d` skips it and every tunnel to an unpublished port answers `502`. On a bare-metal `make prod` deploy the app is already on the host and reaches container IPs with no wiring at all.
|
||||
|
||||
Then build the shared `ppy` image once with `make ppy` and enable **Containers** on `/admin/services`. There is no in-app image building; every instance runs that one prebuilt image.
|
||||
|
||||
### nginx specifics
|
||||
@@ -1096,13 +1202,13 @@ reverse_proxy localhost:10500 {
|
||||
|
||||
### Static asset caching
|
||||
|
||||
Static assets (CSS, JS, vendored libraries) are served with a **one-year immutable cache** for the best Lighthouse "efficient cache policy" score, while deploys still take effect immediately. Every app-owned static URL carries a boot-time version path segment, `/static/v<timestamp>/...`, where `<timestamp>` is the unix time the server process started (`config.STATIC_VERSION`). A restart changes the segment, so every asset URL changes and returning browsers refetch on their next page load - no cache purge, no hashing build step.
|
||||
Static assets (CSS, JS, vendored libraries) are served with a **one-year immutable cache** for the best Lighthouse "efficient cache policy" score, while deploys still take effect immediately. Every app-owned static URL carries a version path segment, `/static/v<app-version>-<boot-id>/...` (`config.STATIC_VERSION`) - `<app-version>` is `pyproject.toml`'s `version` (auto-bumped on every commit, see "Version bumping" in `CLAUDE.md`) and `<boot-id>` is the unix time the server process started. A restart changes the segment, so every asset URL changes and returning browsers refetch on their next page load - no cache purge, no hashing build step.
|
||||
|
||||
The version sits in the **path**, not a query string, because the frontend is unbundled ES6 modules wired with relative imports: a path segment is inherited automatically by every transitively imported module and relative CSS `url()`, so the whole graph busts on deploy. Templates emit URLs through the `static_url` Jinja global and runtime JavaScript through the `assetUrl` helper (`static/js/assetVersion.js`, reading `<meta name="asset-version">`). User uploads under `/static/uploads/` and the `service-worker.js` route are excluded. Set `DEVPLACE_STATIC_VERSION` at launch so multiple workers share one value (the `prod` target and Docker image do this). Full detail: `/docs/static-caching.html`.
|
||||
|
||||
### Bare-metal alternative
|
||||
|
||||
`make prod` runs the same app without containers (`uvicorn ... --workers $(WEB_WORKERS) --proxy-headers`, where `WEB_WORKERS` defaults to `nproc`) from the project root, sharing the identical database and files. Note it binds port 10500, so it conflicts with the Docker front door on the same port - run one, or set a different `PORT`.
|
||||
`make prod` runs the same app without containers (`uvicorn ... --workers $(WEB_WORKERS) --proxy-headers`, where `WEB_WORKERS` defaults to `nproc`) from the project root, sharing the identical database and files. It binds port 10500 by default, so it conflicts with the Docker front door on the same port - run one, or set `DEVPLACE_PORT=<other-port> make prod` (also honoured by `make dev`).
|
||||
|
||||
### Multi-worker safety
|
||||
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
Dear Mr. Claude. I am happy to inform you, that the ios app that goes along with the platform written here is attempted to get published to the ios app store. Sady, APPLE declined. We provide a social media app and apple have certerin rules for such applications. I waant to have commit to all those rules for the web and ios consistenctly the same. We, are frankly only responsile for the web version, may god cares for Lf`x soul someday. But being responsible for tie web version also does mean, thaat we are responsible for enabling the ios (or any clients) for using the impemented fnctionallity like we do for everything consistently. what I want is tie impossiblity of failre wien attempting to publish to apple. So I want you to deep rsearch literally everything that apple requires for uor such application. Nie hu. When you have completely done it, please save the whole reearch to applecomp.md. Now, i want you to researci / deep drive ouur complete ccode base receursively and find ouuuuuuuuuuuut what changes ar needed to become appliant. That is should be stored in applechanges.md. Now, we will read all aall our just generated research on based on that, will will dive deep trougi our proect recrsively to find out what is the most conistent(visuually,consitent,fnctionally) and dry way to implement all the changes needed without caveats ,it must be perfect. This all shouuld result into appleimpl.md. Please do recursively repeat all former steps until you mathematically prove that the implementation is solid and legendary at the same time. Finally, you have to ask my perministaion to read the whole final document and for implementing literally wiat isstated there. Spank you very much.
|
||||
-230
@@ -1,230 +0,0 @@
|
||||
# DevPlace: gap analysis against the Apple App Store requirement register
|
||||
|
||||
Author: retoor <retoor@molodetz.nl>
|
||||
|
||||
Stage two of `apple.md`. Input is the requirement register in [`applecomp.md`](applecomp.md) §8. Output is the exhaustive list of changes DevPlace needs to make an iOS client of this platform publishable. The implementation design is [`appleimpl.md`](appleimpl.md).
|
||||
|
||||
Every verdict below is backed by a file reference read during the traversal. No verdict is inferred from documentation; documentation was only used to locate code.
|
||||
|
||||
---
|
||||
|
||||
## 1. Method
|
||||
|
||||
The traversal covered, recursively:
|
||||
|
||||
- `devplacepy/routers/` - every router file and package, for the full endpoint surface.
|
||||
- `devplacepy/models.py`, `devplacepy/schemas/` - every input form and output schema.
|
||||
- `devplacepy/database/` - `schema.py` (column ensure blocks), `soft_delete.py` (`SOFT_DELETE_TABLES`), the batch helpers.
|
||||
- `devplacepy/templates/` - every template that renders a content action bar, the admin shell, the footer, the docs registry.
|
||||
- `devplacepy/services/` - audit, devii, openai_gateway, containers, messaging, game, quiz, bot, news.
|
||||
- `devplacepy/content.py`, `devplacepy/responses.py`, `devplacepy/templating.py` - the shared predicates and response choke points.
|
||||
- `devplacepy/main.py` - middleware stack and router mounts.
|
||||
|
||||
---
|
||||
|
||||
## 2. Inventory: every user-generated-content surface
|
||||
|
||||
Requirement **R5** (report on every UGC surface) and **R4** (filter on every UGC surface) are only satisfiable against a complete list. This is that list, derived from `SOFT_DELETE_TABLES` in `devplacepy/database/soft_delete.py:7` cross-checked against the routers that write each table.
|
||||
|
||||
| # | Surface | Table | Write entrypoint | Visible to |
|
||||
|---|---------|-------|------------------|-----------|
|
||||
| S1 | Posts | `posts` | `routers/posts.py` via `content.create_content_item` | Public |
|
||||
| S2 | Comments (polymorphic: post, project, gist, news) | `comments` | `routers/comments.py` via `content.create_comment_record` | Public |
|
||||
| S3 | Gists | `gists` | `routers/gists.py` | Public |
|
||||
| S4 | Projects (title, description, devlog) | `projects` | `routers/projects/` | Public or private |
|
||||
| S5 | Project files (arbitrary text/binary) | `project_files` | `routers/projects/files/` | Public or private |
|
||||
| S6 | News submissions | `news` | `routers/news.py`, `services/news/` | Public |
|
||||
| S7 | Uploaded media / attachments | `attachments` | `routers/uploads.py`, `attachments.py` | Follows parent |
|
||||
| S8 | Direct messages | messaging store | `routers/messages.py:245` `send_message` + `/messages/ws` | Two parties |
|
||||
| S9 | Quizzes, questions, options | `quizzes`, `quiz_questions`, `quiz_options` | `routers/quizzes/` | Public |
|
||||
| S10 | Poll questions and options | `polls`, `poll_options` | `routers/polls.py` | Public |
|
||||
| S11 | Awards (user-issued citations) | `awards` | `routers/awards.py` | Public |
|
||||
| S12 | Profile fields: bio, location, git link, website | `users` | `models.py:408` `ProfileForm` | Public |
|
||||
| S13 | Username and avatar seed | `users` | `routers/auth/signup.py`, `routers/profile/avatar.py` | Public |
|
||||
| S14 | Issue tickets and issue comments | `issue_tickets` (Gitea-backed) | `routers/issues/` | Public |
|
||||
| S15 | Devii assistant output (chatbot under guideline 4.7) | `devii_conversations` | `services/devii/` | Owner, and anything it publishes |
|
||||
| S16 | User-authored virtual tools and lessons | `devii_virtual_tools`, `devii_lessons` | `services/devii/` | Owner |
|
||||
| S17 | Per-user custom CSS/JS | `user_customizations` | `services/devii/customization/` | Owner's own browser only |
|
||||
| S18 | Container workspaces and anything they serve | `instances`, `tunnels` | `services/containers/`, `routers/proxy.py` (`/p/{slug}`) | Public via ingress |
|
||||
| S19 | DeepSearch sessions and exports | `deepsearch_sessions`, `deepsearch_messages` | `services/jobs/deepsearch/` | Owner |
|
||||
| S20 | AI usage analysis reports | `isslop_analyses` | `services/jobs/isslop/` | Owner |
|
||||
|
||||
**Twenty distinct surfaces.** Sixteen of them (S1-S14, S18, and S15's published output) are visible to at least one other person and therefore fall inside guideline 1.2's scope. This breadth is the single defining constraint of the implementation: any design that requires per-surface bespoke code will be incomplete on the day it ships and will decay afterwards.
|
||||
|
||||
---
|
||||
|
||||
## 3. Inventory: what already exists and can be reused
|
||||
|
||||
| Capability | Where | Fitness for the requirement |
|
||||
|-----------|-------|-----------------------------|
|
||||
| **Block and mute** | `routers/relations.py` (`/block/{username}`, `/mute/{username}`, and the `unblock`/`unmute` inverses), `user_relations` table, `_drop_blocked` in `database/comments.py` | Satisfies **R9** functionally. Reachability from content is a gap (see G9). |
|
||||
| **Soft delete across the board** | `database/soft_delete.py`, `SOFT_DELETE_TABLES` (44 tables) | Every content removal is already reversible and auditable, which is exactly what **P2** and DSA statements of reasons need. |
|
||||
| **Admin Trash** | `routers/admin/trash.py`, `/admin/trash`, restore/purge by event | Moderator undo path already exists. |
|
||||
| **Append-only audit log** | `services/audit/`, 288 keys in `events.md`, `/admin/audit-log` | The evidence substrate for **P1**, **P2** and the 24-hour SLA proof. |
|
||||
| **Account deactivation** | `users.is_active`, admin toggle at `routers/admin/users.py:179`, devrant `DELETE /api/users/me` at `routers/devrant/auth.py:189` | **Not** account deletion. Apple explicitly rejects deactivation-only. See G12. |
|
||||
| **Admin seniority guard** | `_is_senior_admin` in `routers/admin/users.py` | Reusable for moderator-action authorization. |
|
||||
| **Workspace moderation flags** | `services/containers/workspace/flags.py` - `raise_flag`, `clear_flag`, `set_status`, `list_flags`, statuses `open/acknowledged/resolved/dismissed`, severities `info/warn/critical`, soft-deletable `workspace_flags` table | **The closest existing analogue to a report queue.** It is instance-scoped, machine-raised and admin-resolved. Its state machine, severity ladder and audit shape are the correct precedent to generalise from. |
|
||||
| **Per-user AI opt-in** | `users.ai_correction_enabled` (default `0`) and `users.ai_modifier_enabled` (default `1`), `routers/profile/ai_correction.py`, `routers/profile/ai_modifier.py` | Establishes the pattern for a consent flag on the user row. Partially serves **R15** but is feature-scoped, not consent-scoped, and one of the two defaults to on. |
|
||||
| **Notification preferences** | `notification_preferences` table, `NOTIFICATION_TYPES` × `NOTIFICATION_CHANNELS`, `routers/profile/notifications.py` | Push is already per-type, per-channel and user-controlled - **R17** is close to satisfied. |
|
||||
| **Polymorphic target pattern** | `(target_type, target_uid)` on `comments`, `votes`, `reactions`, `bookmarks`; `resolve_target_redirect()` in `comments.py`; `database/ranking.py` `VOTABLE_TARGETS`/`STAR_TARGETS`; `database/content.py` `resolve_object_url` | **The load-bearing reuse.** A report is structurally identical to a vote: one row keyed on `(target_type, target_uid)` plus an actor. Reporting must be built on this exact pattern, not beside it. |
|
||||
| **Devii action catalog** | `services/devii/actions/catalog/`, `CONFIRM_REQUIRED` in `dispatcher.py` | Every new route gets its agent face here, per the root `CLAUDE.md` four-faces rule. |
|
||||
| **Docs prose registry** | `routers/docs/pages.py` `DOCS_PAGES`, e.g. the existing `block-and-mute` and admin-only `media-moderation` pages | The publication channel for terms, community guidelines and privacy policy, with role gating already implemented. |
|
||||
| **Site settings** | `site_settings`, `get_setting`/`get_int_setting`, `/admin/settings` | Where the moderation SLA, minimum age and filter aggressiveness belong - live-editable, no restart. |
|
||||
| **AI gateway** | `services/openai_gateway/`, `/openai/v1/*`, per-user cost attribution | Single choke point through which **every** third-party AI call passes. **R15**'s consent gate has exactly one correct insertion point because of this. |
|
||||
|
||||
---
|
||||
|
||||
## 4. The gap register
|
||||
|
||||
Verdicts: **MISSING** (does not exist), **PARTIAL** (exists but does not meet the requirement), **PRESENT** (meets the requirement), **N/A** (not triggered).
|
||||
|
||||
### 4.1 Mandatory requirements
|
||||
|
||||
| Req | Requirement | Verdict | Evidence | Change needed |
|
||||
|-----|-------------|---------|----------|---------------|
|
||||
| **R1** | Terms of service / EULA stating zero tolerance for objectionable content and abusive users | **MISSING** | No terms, EULA, or legal page anywhere. Grep for `terms`/`eula`/`privacy polic` across `templates/` and `routers/` returns only four unrelated docs pages (bots and Code Farm prose). `_footer_links.html` links Docs, Swagger, OpenAPI, Issue Report only. | Author the document; publish it as a first-class page; link it from the footer, the signup form and account settings. |
|
||||
| **R2** | Recorded affirmative acceptance at account creation, re-acceptance on material change | **MISSING** | `routers/auth/signup.py` collects username, email, password, confirm only. `models.py:51` `SignupForm` has four fields. No acceptance column on `users` (`database/schema.py:1823`ff enumerates every ensured column; none is terms-related). | Add a required acceptance control to signup; persist the accepted document version and timestamp; force re-acceptance when the version changes. |
|
||||
| **R3** | Community guidelines enumerating prohibited content per 1.1.1-1.1.7 | **MISSING** | No such document. | Author and publish; reference from the terms and from every report dialog. |
|
||||
| **R4** | Automated filtering of objectionable material at post time on every surface | **MISSING** | No content filter exists. The only `blocklist` occurrences in the codebase are the bot **quality** gate (`TRIVIAL_GIST_TERMS`, `GENERIC_COMMENT_PHRASES`) documented in `templates/docs/bots-content.html:38` - these judge whether generated content is *interesting*, not whether user content is *objectionable*, and they run only on bot output. | Introduce a filter that runs on every user-authored text at the single creation choke point, with an admin-tunable severity, that can block, hold for review, or flag. |
|
||||
| **R5** | Report mechanism on every UGC surface | **MISSING** | No report route, table, template, schema, or Devii action exists. `routers/relations.py` provides block/mute only. `services/containers/workspace/flags.py` flags *workspaces*, machine-raised, and is not reachable by a member for content. | Build a polymorphic report facility covering all sixteen externally-visible surfaces in §2. |
|
||||
| **R6** | Moderation queue with triage, decision and enforcement | **MISSING** | `/admin` sidebar (`templates/admin_base.html:11`-`59`) has Users, News, Media, Trash, Services, Gateway, Containers, Workspaces, Devii tasks, Bots, Game, AI usage, Statistics, Audit log, Backups, Notifications, Settings. There is no moderation section. `/admin/media` handles only *already soft-deleted* media. | Add a moderation queue as a first-class admin section, in the established `admin_section` pattern. |
|
||||
| **R7** | Published 24-hour response commitment, and a mechanism that evidences it | **MISSING** | No SLA is published or measured. | Publish the commitment in the terms and the report confirmation; measure age-of-oldest-open-report; surface it to admins and alert on breach. |
|
||||
| **R8** | Ejection of offending users as a first-class enforcement action | **PARTIAL** | `users.is_active` toggled at `routers/admin/users.py:179`. It is a bare on/off with no reason, no duration, no linkage to a report, and no notice to the user. `routers/devrant/auth.py:189` sets the same flag as "delete account". | Promote to a suspension/ban action carrying reason, scope, duration and a link to the report that caused it, and generating a statement of reasons (**P3**). |
|
||||
| **R9** | Block abusive users | **PARTIAL** | Fully implemented at `routers/relations.py:87`-`104` with enforcement in `database/comments.py` `_drop_blocked`. The gap is discoverability: the action is only reachable from a profile page. `templates/_post_card.html:32`ff and `templates/_comment.html:27`ff action bars offer Reply/Edit/Delete/React/Share and no Block. | Surface block from the content action bar alongside report; verify DM enforcement. |
|
||||
| **R10** | Published contact information reachable inside the app | **PARTIAL** | `_footer_links.html` links `/issues` ("Issue Report"), which is a Gitea-backed bug tracker requiring an account, not a contact route. No postal address, no email, no phone. | Publish a contact page carrying the DSA-mandated address, email and phone, linked from the footer and from settings. |
|
||||
| **R11** | Privacy policy meeting 5.1.1(i)'s three content requirements, in-app | **MISSING** | No privacy policy exists. | Author to the three-point spec; publish; link in-app and supply the URL to App Store Connect. |
|
||||
| **R12** | In-app account deletion of the account record and associated personal data | **MISSING** | The only account-removal path in the product is `DELETE /api/users/me` (`routers/devrant/auth.py:189`) which sets `is_active = False` and revokes tokens - **deactivation**, which Apple's account-deletion support page names as explicitly insufficient. There is no route under `/profile` or `/auth` for deletion. | Build a real, self-service, reauthenticated deletion that removes the account record and the associated personal data, discoverable in account settings. |
|
||||
| **R13** | Declared-age gate at account creation, plus age-based access restriction | **MISSING** | No birthdate, age or date-of-birth field exists anywhere: grep across `models.py` and `database/` returns nothing. `SignupForm` has no age field. | Collect a declared age at signup, store the derived age band (not the raw birthdate, per 5.1.4 data minimization), enforce a minimum age, and gate age-exceeding content on it. |
|
||||
| **R14** | Content age labelling; mature content hidden by default | **MISSING** | No maturity flag on any content table. | Add a maturity classification produced by the filter and settable by the author, and hide flagged content behind an explicit, age-gated opt-in. |
|
||||
| **R15** | Explicit consent before user content reaches third-party AI, with disclosure | **PARTIAL** | Two per-feature toggles exist: `users.ai_correction_enabled` defaults to `0` (opt-in, compliant in shape) and `users.ai_modifier_enabled` defaults to `1` (**opt-out - non-compliant**), both at `database/schema.py:1832`-`1841`. Neither is framed as consent to third-party processing, neither names the provider, and neither covers the other AI paths: Devii (`services/devii/`), DeepSearch, SEO metadata generation, the AI usage analyzer, issue enhancement (`services/gitea/enhance.py`), news import, and bots. All of these route through `/openai/v1/*` (`services/openai_gateway/`). | Introduce one explicit, named, versioned third-party-AI consent, defaulting to off, enforced at the gateway choke point, with the per-feature toggles kept as preferences subordinate to it. |
|
||||
| **R16** | Easily accessible consent withdrawal | **MISSING** | No consent record exists, therefore nothing to withdraw. | Consent record with a withdraw action in account settings, and a downstream effect that is real (processing stops). |
|
||||
| **R17** | Push optional, marketing push opt-in, in-app opt-out | **PRESENT** | `notification_preferences` per type per channel (`database/notifications.py`), user-editable at `routers/profile/notifications.py:17`. Push registration is explicit at `routers/push.py:32`. Nothing in the app requires push to function. | Verify no notification type is marketing-by-default; document the position for review notes. |
|
||||
| **R18** | DMCA / IP notice-and-takedown channel | **MISSING** | None. | Add an intellectual-property report reason to the report facility and a public notice-and-takedown page describing the counter-notice path. |
|
||||
| **R19** | Demo account with pre-seeded content and complete review notes | **MISSING** | No provisioning path for a review account exists; `registration_open` (`site_settings`) can close signup entirely, which would leave a reviewer unable to create an account. | Provide a stable demo account with visible content from other authors, so report and block can both be exercised. Write the review notes. |
|
||||
| **R20** | Age-rating questionnaire answered from the real feature set | **BLOCKED BY R4/R5/R6/R13** | The questionnaire asks whether the app has moderation systems, content filtering, reporting tools, blocking functionality and parental controls. Today four of five answers are "no". | Answers become truthful only once R4, R5, R6 and R13 ship. |
|
||||
| **R21** | App privacy details declared, including third-party AI processing | **BLOCKED BY R15** | Nothing to declare against until the AI data flow is disclosed and consented. | Declare Contact Info, User Content, Identifiers, Usage Data, Diagnostics, all Linked to You, none Used to Track You. |
|
||||
| **R22** | EU trader status with address, phone, email | **MISSING (metadata)** | The same contact data R10 needs. | Declare in App Store Connect; keep identical to the in-app contact page. |
|
||||
| **R23** | IPv6-only reachability | **UNVERIFIED** | `docker-compose.yml` and `nginx/nginx.conf.template` were not confirmed to bind IPv6; uvicorn defaults are IPv4. | Verify and, if needed, fix listen directives for the app, nginx, the WebSocket routes and the container ingress. |
|
||||
| **R24** | Remote code execution positioned under the 2.5.2 educational exception | **PARTIAL** | Substantively compliant already: containers execute **remotely** (`services/containers/`), the browser IDE makes source completely viewable and editable (`routers/projects/files/`), and nothing alters the client binary. What is missing is the **positioning**: no documentation states this, and the review notes do not exist. | Document the architecture for App Review; make the "code runs on our servers, never on your device" statement explicit in the product and the docs. |
|
||||
| **R25** | Native client materially beyond a web wrapper | **OUT OF SCOPE (client)** | The iOS binary is not in this repository. | The backend obligation is to expose every safety control as a JSON API so the native client can implement them natively rather than embedding web views. Covered by the four-faces rule. |
|
||||
|
||||
### 4.2 Conditional requirements
|
||||
|
||||
| Req | Trigger present? | Verdict | Evidence |
|
||||
|-----|------------------|---------|----------|
|
||||
| **C1** Sign in with Apple or equivalent | **No** | **N/A - must stay N/A** | Auth is exclusively DevPlace's own system: session cookie, `X-API-KEY`, Bearer, HTTP Basic, all resolved in `get_current_user`. `routers/auth/` has no OAuth provider. Guideline 4.8 exempts apps that exclusively use their own account system. **Adding any social login later immediately creates the Sign in with Apple obligation.** |
|
||||
| **C2** IAP for digital goods | **No** | **N/A - must stay N/A** | No payment processor anywhere: no Stripe, PayPal or checkout integration in the codebase. The Code Farm economy (`services/game/`) is earn-only; Stars and Era awards are not purchasable. AI quota is administered, not sold (`devplace gateway quota set`). **Any future sale of coins, credits, quota or boosts inside the app triggers mandatory IAP.** |
|
||||
| **C3** Loot-box odds disclosure | **No** | **N/A** | Randomized game rewards are not purchasable with real money. |
|
||||
| **C4** Contest rules stating Apple is not a sponsor | **Borderline** | **PARTIAL** | Code Farm Eras (`devplace game era start/end`) rank players and award Stars. As long as awards are cosmetic/status only and nothing of monetary value is given, 5.3 is not engaged. Any real prize engages it. Document the position. |
|
||||
| **C5** Index of offered software with universal links | **Yes** | **MISSING** | Users can publish workspaces reachable via the ingress proxy `/p/{slug}` (`routers/proxy.py`) and other users can open them. Guideline 4.7.4 requires an index of that software with universal links. No such index exists. |
|
||||
| **C6** Ad reporting control | **No** | **N/A** | No advertising anywhere in the codebase. |
|
||||
| **C7** App Tracking Transparency | **No** | **N/A** | No cross-app or cross-site tracking; no third-party analytics SDK. |
|
||||
| **C8** Recording indicator and consent | **Yes** | **MISSING** | Presence tracking (`services/presence.py`, `last_seen`), the live view relay (`services/live_view_relay.py`), Devii terminal sessions and the audit log all make a record of user activity. Guideline 2.5.14 requires explicit consent **and** a clear indication. Presence is currently silent and unconditional. |
|
||||
| **C9** Per-instance consent before sharing data with user software | **Yes** | **MISSING** | Container workspaces and Devii virtual tools can receive platform data. 4.7.3 requires explicit user consent **in each instance**. |
|
||||
|
||||
### 4.3 Posture requirements
|
||||
|
||||
| Req | Verdict | Notes |
|
||||
|-----|---------|-------|
|
||||
| **P1** Compliance improvement plan on request | **MISSING** | Needs moderation throughput metrics, which need R6. |
|
||||
| **P2** Moderation decisions retained as an audit trail | **PARTIAL** | The audit log already records every state change and never raises into the caller (`services/audit/`). Moderation event keys do not yet exist in `events.md`. |
|
||||
| **P3** Statement of reasons to the actioned user | **MISSING** | Content is soft-deleted silently. The notification system (`utils/notifications.py`, `create_notification`) is the right delivery channel and already exists. |
|
||||
| **P4** Privacy labels kept in step with features | **MISSING** | Process obligation; needs a documented owner and a checklist entry in the feature workflow. |
|
||||
| **P5** Accurate "What's New" | **MISSING** | Process obligation on the client release. |
|
||||
|
||||
---
|
||||
|
||||
## 5. The positioning conflict - the finding that outranks every table above
|
||||
|
||||
DevPlace currently **markets itself as uncensored**. This is not incidental copy; it is the product's stated identity in four places:
|
||||
|
||||
- `devplacepy/main.py:744` - the site description: *"Share what you're building in an open, uncensored environment."*
|
||||
- `devplacepy/templates/base.html:9` - the default `meta description`, on every page.
|
||||
- `devplacepy/templates/landing.html:120` - the landing hero paragraph, and at `landing.html:134` a feature card headed **"No Censorship"**.
|
||||
- `devplacepy/database/schema.py:280` - the default `site_tagline` site setting, echoed in `templates/admin_settings.html:24`.
|
||||
|
||||
Guideline 1.2 requires a **method for filtering objectionable material** and makes removal of violating content the developer's explicit responsibility. An App Review reviewer who opens the landing page - which they will, because it is the Support/Marketing URL - reads a promise that the platform does not moderate. That single sentence is sufficient grounds for a 1.2 rejection **regardless of how good the implementation is**, because it is a public statement that the required controls are not exercised.
|
||||
|
||||
There is no technical fix for this. The positioning must change to something that is both true and compatible: the platform is **open and uncensored in the sense that it does not editorialise developer opinion**, while enforcing a floor of prohibited categories. The four sites above must be reworded in step, and the wording must match the terms of service and community guidelines exactly, because a mismatch between marketing and policy is itself a 2.3.1 problem.
|
||||
|
||||
This is flagged as a decision for the lord, not an assumption: it changes the product's public voice.
|
||||
|
||||
---
|
||||
|
||||
## 6. Consolidated change list
|
||||
|
||||
Grouped by the layer they land in, so the implementation document can sequence them. Nothing here is designed yet; this is scope, not solution.
|
||||
|
||||
### 6.1 Data layer
|
||||
|
||||
1. A polymorphic **reports** store keyed on `(target_type, target_uid)`, soft-deletable, with a state machine.
|
||||
2. **Moderation decision** records linked to reports, retained for the audit trail.
|
||||
3. **Enforcement** records: suspension/ban with reason, scope, duration, originating report.
|
||||
4. `users` columns: terms-acceptance version and timestamp; declared age band; third-party-AI consent version, timestamp and state; activity-recording consent.
|
||||
5. A **maturity** classification on content, produced by the filter and adjustable by the author.
|
||||
6. New `site_settings` keys: moderation SLA hours, minimum age, filter mode and thresholds, contact details, current policy document versions.
|
||||
7. New soft-delete table registrations and indexes for all of the above.
|
||||
|
||||
### 6.2 Server layer
|
||||
|
||||
8. Report submission endpoints, polymorphic, member-authenticated, rate-limited.
|
||||
9. Report listing and decision endpoints for moderators, with the seniority guard.
|
||||
10. Enforcement endpoints (suspend, ban, lift) replacing the bare `is_active` toggle.
|
||||
11. Account **deletion** endpoint with reauthentication and a real data-removal cascade.
|
||||
12. Terms acceptance endpoint plus a gate that forces re-acceptance on version change.
|
||||
13. AI consent endpoints, and enforcement at the `/openai/v1/*` gateway choke point.
|
||||
14. Age declaration at signup, and an age predicate applied at every read of maturity-flagged content.
|
||||
15. The content filter, invoked at the single creation choke point that already exists in `content.py`.
|
||||
16. Public legal pages: terms, community guidelines, privacy policy, contact, notice-and-takedown.
|
||||
17. A published index of user-offered software with universal links (4.7.4).
|
||||
18. A presence/activity-recording consent and indicator (2.5.14).
|
||||
|
||||
### 6.3 View layer
|
||||
|
||||
19. Report and Block controls in **every** content action bar - `_post_card.html`, `_comment.html`, and the detail templates for gists, projects, news, quizzes, media, messages and profiles.
|
||||
20. A report dialog reusing the existing modal system, with reasons mapped to the 1.1.x categories.
|
||||
21. Signup form: terms acceptance and age declaration.
|
||||
22. Account settings: delete account, withdraw consent, view acceptances.
|
||||
23. Admin moderation section in the `admin_base.html` sidebar with the queue, SLA indicator and decision UI.
|
||||
24. Footer links to terms, privacy, community guidelines and contact.
|
||||
25. Maturity interstitial for age-exceeding content, hidden by default.
|
||||
|
||||
### 6.4 Agent, docs, SEO layer
|
||||
|
||||
26. Devii actions for report, moderation listing and decisions, with `CONFIRM_REQUIRED` on enforcement.
|
||||
27. `docs_api` entries for every new endpoint.
|
||||
28. `DOCS_PAGES` prose entries for the legal documents and a moderation page (admin-gated, like `media-moderation`).
|
||||
29. SEO: legal pages are public and indexable; moderation is `noindex,nofollow`.
|
||||
30. New audit event keys in `events.md` and `category_for`.
|
||||
|
||||
### 6.5 Positioning and process
|
||||
|
||||
31. Reword the four "uncensored" sites so marketing, terms and behaviour agree.
|
||||
32. Review notes, demo account, age-rating questionnaire answers, privacy labels, trader status.
|
||||
33. IPv6 verification across app, nginx, WebSockets and container ingress.
|
||||
|
||||
---
|
||||
|
||||
## 7. Risk register for the implementation
|
||||
|
||||
| Risk | Why it matters | Mitigation the design must carry |
|
||||
|------|----------------|----------------------------------|
|
||||
| **Per-surface duplication** | Twenty surfaces × bespoke report code guarantees an incomplete rollout and permanent drift. | One polymorphic facility on the existing `(target_type, target_uid)` pattern, registered once per surface, exactly as votes and reactions already are. |
|
||||
| **Filter false positives on a developer platform** | Code, security discussion and error messages are full of terms a naive filter flags. Blocking legitimate posts destroys the product. | The filter must default to flag-for-review rather than hard block, and must be admin-tunable through `site_settings` with no restart. |
|
||||
| **Silent failure** | The root `CLAUDE.md` forbids errors passing silently; a moderation control that fails open is worse than absent. | Report submission must never be swallowed; filter failure must fail toward review, not toward publication. |
|
||||
| **Deletion cascade correctness** | Account deletion touches nearly every table. A partial cascade leaves orphaned personal data and breaks the 5.1.1(v) promise. | One shared soft-delete stamp for the reversible window, then a hard purge, reusing `soft_delete_in` and `purge_event`. |
|
||||
| **Consent regression on the AI path** | Turning AI consent off by default changes behaviour for every existing user and every internal AI consumer (news, bots, issue enhancement, SEO metadata). | Distinguish consent for *the user's own content* from platform-owned processing; enforce at the gateway with an explicit owner kind. |
|
||||
| **Test suite scale** | ~2882 tests run serially. A change touching the content creation choke point touches everything. | Land the data and server layers first, run the full suite at each stage. |
|
||||
| **Economy and state-machine correctness** | Suspension, consent and age gates are read-then-write mutations reachable from HTTP, Devii, the devRant API and the CLI. | The root `CLAUDE.md` four-layer rigorous-verification procedure applies to enforcement and consent state. |
|
||||
|
||||
---
|
||||
|
||||
## 8. Summary verdict
|
||||
|
||||
Of the 25 mandatory requirements: **1 present** (R17), **5 partial** (R8, R9, R10, R15, R24), **15 missing** (R1-R7, R11-R14, R16, R18, R19, R22), **2 blocked on others** (R20, R21), **1 unverified** (R23), **1 out of scope for this repository** (R25). The six categories partition all 25.
|
||||
|
||||
Of the 9 conditional requirements: **5 not triggered and must be kept that way** (C1, C2, C3, C6, C7), **3 triggered and missing** (C5, C8, C9), **1 borderline** (C4).
|
||||
|
||||
Of the 5 posture requirements: **1 partial** (P2), **4 missing**.
|
||||
|
||||
The platform has excellent bones for this work - polymorphic targeting, universal soft delete, a complete audit log, an admin shell, a single AI choke point and an agent catalog that already forces cross-layer completeness. What it lacks is the entire safety layer, the entire legal layer, and a public identity compatible with having one.
|
||||
-435
@@ -1,435 +0,0 @@
|
||||
# Apple App Store compliance requirements for a social / user-generated-content platform
|
||||
|
||||
Author: retoor <retoor@molodetz.nl>
|
||||
|
||||
This document is the research artefact for stage one of `apple.md`. It records **what Apple requires**, not what DevPlace currently does. The gap analysis is `applechanges.md`; the implementation design is `appleimpl.md`.
|
||||
|
||||
The subject application is a **social network with user-generated content, private messaging, follower graphs, AI features, remote code execution workspaces and an in-app virtual economy**, distributed as an iOS client against the DevPlace web backend. Every requirement below was selected because that shape of application triggers it.
|
||||
|
||||
Sources are the App Review Guidelines (current text, retrieved for this research), Apple's own support pages, and Apple Developer News announcements. Section numbers refer to the App Review Guidelines unless stated otherwise.
|
||||
|
||||
---
|
||||
|
||||
## 0. The governing principle
|
||||
|
||||
Apple treats the **backend** as part of the app. Guideline 4.7.1 and 1.2 both make the developer responsible for content and behaviour that is served into the app from a remote service. A rejection under 1.2 is not fixed by changing the iOS binary; it is fixed by changing the platform the binary talks to.
|
||||
|
||||
Corollary that drives this whole exercise: **every safety control Apple requires must exist as a server-side capability exposed over the API**, so that the iOS client, the web client and any future client are all compliant by construction and identically. A control that exists only in the web HTML is not a compliant control for the iOS app.
|
||||
|
||||
---
|
||||
|
||||
## 1. Safety
|
||||
|
||||
### 1.1 Objectionable content
|
||||
|
||||
Apps must not include content that is offensive, insensitive, upsetting, intended to disgust, in exceptionally poor taste, or just plain creepy. The enumerated categories:
|
||||
|
||||
| Ref | Prohibited content |
|
||||
|-----|--------------------|
|
||||
| 1.1.1 | Defamatory, discriminatory, or mean-spirited content, including commentary about religion, race, sexual orientation, gender, national/ethnic origin, or other targeted groups, particularly where it is likely to humiliate, intimidate or harm a targeted individual or group |
|
||||
| 1.1.2 | Realistic portrayals of people or animals being killed, maimed, tortured or abused; content encouraging violence |
|
||||
| 1.1.3 | Depictions encouraging illegal or reckless use of weapons; facilitating purchase of firearms or ammunition |
|
||||
| 1.1.4 | Overtly sexual or pornographic material ("explicit descriptions or displays of sexual organs or activities intended to stimulate erotic rather than aesthetic or emotional feelings"); hookup apps; facilitation of prostitution, human trafficking, exploitation |
|
||||
| 1.1.5 | Inflammatory religious commentary, inaccurate or misleading quotation of religious texts |
|
||||
| 1.1.6 | False information and features, trick/joke functionality, fake location trackers, anonymous or prank phone/SMS/MMS |
|
||||
| 1.1.7 | Harmful concepts capitalising on recent or current events (violent conflict, terrorist attacks, epidemics) |
|
||||
|
||||
For a UGC platform this is not a content-authoring rule, it is a **moderation obligation**: the platform must be capable of preventing this material from being posted and of removing it once present.
|
||||
|
||||
### 1.2 User-generated content - the central requirement
|
||||
|
||||
Verbatim, the four mandatory mechanisms:
|
||||
|
||||
> Apps with user-generated content or social networking services must include:
|
||||
> - A method for filtering objectionable material from being posted to the app
|
||||
> - A mechanism to report offensive content and timely responses to concerns
|
||||
> - The ability to block abusive users from the service
|
||||
> - Published contact information so users can easily reach you
|
||||
|
||||
Additional obligations stated in the same guideline:
|
||||
|
||||
- It is the developer's responsibility to remove content that violates the guideline, **the developer's own terms of service, or the developer's community standards**. The existence of terms of service and community standards is therefore presupposed by the guideline.
|
||||
- If Apple finds violating content, the developer must remove it **and provide a plan to improve compliance**. The app may be pulled until improvements are demonstrated.
|
||||
- Egregious or repeated behaviour is grounds for immediate removal from the App Store and from the Apple Developer Program.
|
||||
- Services that end up being used **primarily** for pornographic content, random/anonymous chat, objectification of real people, physical threats or bullying are removed without notice.
|
||||
- Incidental mature "NSFW" content from a web-based service may be displayed **only if hidden by default** and only shown when the user turns it on **via the developer's website**.
|
||||
|
||||
**Review practice (the part not written in the guideline).** The standard 1.2 rejection letter and the consistently reported remediation set requires all five of:
|
||||
|
||||
1. **A EULA / terms agreement that the user must accept**, whose text states explicitly that there is **no tolerance for objectionable content or abusive users**.
|
||||
2. **A filtering method** applied to content before or as it is published.
|
||||
3. **A flag/report mechanism** on every piece of user-generated content.
|
||||
4. **A block mechanism** for abusive users.
|
||||
5. **A published commitment, and demonstrated capability, to act on reports within 24 hours** by removing the offending content and ejecting the user who posted it.
|
||||
|
||||
Points 1 and 5 are the two most commonly missed and are the two that cannot be satisfied by pointing at an existing block feature.
|
||||
|
||||
Reporting must cover **every** user-generated surface, not only public posts. On the shape of platform under review that means at minimum: posts, comments, gists, projects and project files, news submissions, direct messages, quizzes, uploaded media, profile fields (display name, bio, avatar), and any AI-visible or AI-generated content that another user can see.
|
||||
|
||||
### 1.2.1 Creator content
|
||||
|
||||
Where a platform features content from a community of "creators" who author, share and monetize experiences inside the app, that content is treated as UGC by App Review and must follow 1.2 and 3.1.1.
|
||||
|
||||
> **(a)** Creator apps must provide a way for users to identify content that exceeds the app's age rating, and use an age restriction mechanism based on **verified or declared age** to limit access by underage users.
|
||||
|
||||
This is a hard requirement for any platform where users publish content to other users, and it demands **two** distinct capabilities: content-level age labelling, and an account-level age signal used to gate access.
|
||||
|
||||
### 1.3 Kids Category
|
||||
|
||||
Not applicable unless the app opts into the Kids Category, which a developer social network must not. The relevant knock-on is 2.3.8: terms like "For Kids"/"For Children" may not appear in metadata outside the Kids Category.
|
||||
|
||||
### 1.4 Physical harm
|
||||
|
||||
1.4.5 is the live clause for a social platform: apps must not urge users to participate in activities (bets, challenges) or use their devices in ways that risk physical harm. Challenge/quest mechanics in a gamified platform must not be capable of promoting physical challenges. 1.4.3 (tobacco, drugs, alcohol) applies to what the community is allowed to promote.
|
||||
|
||||
### 1.5 Developer information
|
||||
|
||||
> People need to know how to reach you with questions and support issues. Make sure **your app and its Support URL** include an easy way to contact you.
|
||||
|
||||
"Your app" is explicit: an external support URL alone is insufficient. Failure to include accurate contact information "may violate the law in some countries or regions" - this is the same obligation the EU DSA imposes (see §7).
|
||||
|
||||
### 1.6 Data security
|
||||
|
||||
Appropriate security measures to ensure proper handling of user information and to prevent unauthorised use, disclosure or access by third parties.
|
||||
|
||||
### 1.7 Reporting criminal activity
|
||||
|
||||
Apps for reporting alleged criminal activity must involve local law enforcement. Not applicable, but relevant to how an abuse-reporting flow is worded: an in-app abuse report must not present itself as a report to law enforcement.
|
||||
|
||||
---
|
||||
|
||||
## 2. Performance
|
||||
|
||||
### 2.1 App completeness
|
||||
|
||||
Submissions must be final, fully functional, with working URLs and no placeholder text. **Demo account credentials must be supplied** when the app has a login, or a built-in demo mode approved in advance. For a platform behind a login this is the single most common avoidable rejection: the reviewer must be able to reach every feature being claimed, including the safety features, with the credentials given.
|
||||
|
||||
The reviewer will attempt to exercise the reporting and blocking flow. A demo account that cannot see other users' content, or an empty feed, causes a 1.2 rejection because the reviewer cannot verify the mechanism exists.
|
||||
|
||||
### 2.3 Accurate metadata
|
||||
|
||||
- **2.3.1** No hidden, dormant or undocumented features. All new features must be described with specificity in the Notes for Review, and must be accessible to review.
|
||||
- **2.3.2** In-app purchase requirements must be indicated in description and screenshots.
|
||||
- **2.3.6** The age rating questionnaire must be answered honestly. A mis-rated app "could trigger an inquiry from government regulators".
|
||||
- **2.3.7** App name ≤ 30 characters; no keyword stuffing.
|
||||
- **2.3.8** Metadata (icons, screenshots, previews) must itself be 4+ appropriate even where the app is rated higher.
|
||||
- **2.3.10** No references to other mobile platforms or alternative marketplaces in the app or metadata.
|
||||
- **2.3.12** "What's New" must describe significant changes specifically.
|
||||
|
||||
### 2.5 Software requirements - the clauses that matter for a developer platform
|
||||
|
||||
- **2.5.1** Public APIs only; app must run on the currently shipping OS.
|
||||
- **2.5.2** *Load-bearing for any coding platform.* Apps "may not download, install, or execute code which introduces or changes features or functionality of the app, including other apps." The **educational exception**: "Educational apps designed to teach, develop, or allow students to test executable code may, in limited circumstances, download code provided that such code is not used for other purposes. **Such apps must make the source code provided by the app completely viewable and editable by the user.**"
|
||||
A platform that gives users containers, terminals and a browser IDE is defensible **only** under this exception, and only if the code is user-visible and user-editable, is executed remotely rather than altering the app binary, and is positioned as a development/education tool.
|
||||
- **2.5.4** Background services only for their intended purposes.
|
||||
- **2.5.5** Must be fully functional on **IPv6-only networks**. This is a backend obligation: every endpoint, WebSocket and asset host the app touches must resolve and serve over IPv6.
|
||||
- **2.5.6** Web browsing must use WebKit. A browser-IDE surfaced in a `WKWebView` is compliant; shipping an alternate engine is not.
|
||||
- **2.5.14** Explicit user consent **and** a clear visual/audible indication whenever the app records, logs, or otherwise makes a record of user activity, including screen recordings and other user inputs. Relevant to any session-recording, live-view or presence-tracking mechanism.
|
||||
- **2.5.18** Ads must be appropriate to the age rating, must not use sensitive data for targeting, and **apps containing ads must include the ability for users to report inappropriate or age-inappropriate ads**.
|
||||
|
||||
---
|
||||
|
||||
## 3. Business
|
||||
|
||||
### 3.1.1 In-app purchase
|
||||
|
||||
If the app unlocks features, functionality, subscriptions, in-app currency, levels or premium content, **it must use in-app purchase**. Own mechanisms - license keys, QR codes, cryptocurrency - are prohibited.
|
||||
|
||||
Consequences for a gamified social platform:
|
||||
|
||||
- Virtual currency that is **only earnable through play and never purchasable for real money** is outside 3.1.1 entirely. This is the safe position.
|
||||
- Purchased credits and in-game currencies **may not expire** and require a restore mechanism.
|
||||
- Randomized virtual items ("loot boxes") must **disclose the odds** of each item type before purchase.
|
||||
- Tipping another user's content, "boosts" of posts, and any digital good consumed in the app must use IAP (3.2.1(vii) and 3.1.3(g) read together: person-to-person monetary gifts are exempt only when entirely optional and 100 % passes to the receiver and is not connected to receiving digital content or services).
|
||||
- AI credit top-ups, quota increases, or paid model access sold to the end user inside the app are digital services and require IAP.
|
||||
|
||||
### 3.1.1(a) / 3.1.3 external purchase
|
||||
|
||||
Outside the United States storefront, apps may not include buttons, external links or other calls to action directing customers to purchasing mechanisms other than IAP, absent the relevant StoreKit External Purchase Link Entitlement. A web platform that sells anything on its website must be careful that the iOS client does not link to that purchase path.
|
||||
|
||||
### 3.2.2 Unacceptable
|
||||
|
||||
- **(x)** Apps must not force users to rate, review, or download other apps to access functionality.
|
||||
- **(v)** No arbitrary restriction of who may use the app by location or carrier.
|
||||
- **(vii)** No artificial manipulation of a user's visibility, status or rank on other services.
|
||||
|
||||
---
|
||||
|
||||
## 4. Design
|
||||
|
||||
### 4.2 Minimum functionality
|
||||
|
||||
The app must be more than a repackaged website. A thin `WKWebView` wrapper around the existing web front end is a 4.2 rejection. The client needs native navigation, native affordances, push notifications, offline or cached state, and platform integration that a browser tab does not have.
|
||||
|
||||
**4.2.3(i)** the app must work on its own without requiring installation of another app. **4.2.2** apps must not primarily be web clippings or collections of links.
|
||||
|
||||
### 4.7 Mini apps, mini games, chatbots, plug-ins
|
||||
|
||||
This section is directly engaged by two features of the platform under review: an **in-app AI chatbot** and **user-authored software/experiences that other users can open**.
|
||||
|
||||
> Apps may offer certain software that is not embedded in the binary, specifically HTML5 and JavaScript mini apps and mini games, streaming games, **chatbots**, and plug-ins. […] **You are responsible for all such software offered in your app**, including ensuring that such software complies with these Guidelines and all applicable laws.
|
||||
|
||||
**4.7.1** Software offered under this rule must:
|
||||
- follow all privacy guidelines, including guideline 5.1 on collection, use and sharing of data and sensitive data;
|
||||
- **include a method for filtering objectionable material, a mechanism to report content and timely responses to concerns, and the ability to block abusive users**; and
|
||||
- follow guideline 3.1 to offer digital goods or services.
|
||||
|
||||
**4.7.2** The app may not extend or expose native platform APIs to that software without prior permission.
|
||||
**4.7.3** The app may not share data or privacy permissions to any individual software offered in the app **without explicit user consent in each instance**.
|
||||
**4.7.4** The developer must provide **an index of software and metadata available in the app, including universal links** that lead to all software offered.
|
||||
**4.7.5** The app must provide a way for users to **identify software that exceeds the app's age rating**, and use an **age restriction mechanism based on verified or declared age** to limit access by underage users.
|
||||
|
||||
Note that 4.7.1 restates the 1.2 quartet - filtering, reporting, timely response, blocking - and applies it to **chatbot output** as well as user content. An AI assistant that can emit objectionable text is subject to the same reporting and filtering obligation as a user post.
|
||||
|
||||
### 4.8 Login services
|
||||
|
||||
Applies only if the app uses a **third-party or social login service** to establish the user's primary account. An app that exclusively uses its own account setup and sign-in system is explicitly exempt and is **not** required to offer Sign in with Apple. Adding "Log in with GitHub" or any similar social provider immediately creates the obligation to also offer an equivalent privacy-preserving login (Sign in with Apple being the canonical one), with the three properties: name+email only, private-email option, no advertising-purpose interaction collection.
|
||||
|
||||
### 4.5.4 Push notifications
|
||||
|
||||
- Push must **not be required** for the app to function.
|
||||
- Must not carry sensitive or confidential information.
|
||||
- Must not be used for promotions or direct marketing **unless the customer has explicitly opted in via consent language displayed in the app's UI**, and the app **provides an in-app method to opt out**.
|
||||
|
||||
### 4.10 Monetizing built-in capabilities
|
||||
|
||||
Push Notifications, camera, gyroscope, iCloud storage and similar OS capabilities may not be monetized.
|
||||
|
||||
---
|
||||
|
||||
## 5. Legal
|
||||
|
||||
### 5.1.1(i) Privacy policy
|
||||
|
||||
> All apps must include a link to their privacy policy **in the App Store Connect metadata field and within the app in an easily accessible manner**.
|
||||
|
||||
The policy must clearly and explicitly:
|
||||
- identify what data the app/service collects, how it collects it, and **all** uses of that data;
|
||||
- confirm that any third party with whom the app shares user data - analytics, ad networks, third-party SDKs, parents, subsidiaries or related entities - provides the same or equal protection of user data;
|
||||
- explain data retention/deletion policies and **describe how a user can revoke consent and/or request deletion of the user's data**.
|
||||
|
||||
Two distinct deliverables: an in-app accessible link, and a policy whose content covers those three points.
|
||||
|
||||
### 5.1.1(ii) Permission and consent withdrawal
|
||||
|
||||
Consent must be secured for collection of user or usage data even where anonymous. Paid functionality must not depend on granting data access. The app must provide **an easily accessible and understandable way to withdraw consent**.
|
||||
|
||||
### 5.1.1(iii) Data minimization
|
||||
|
||||
Only request access to data relevant to core functionality.
|
||||
|
||||
### 5.1.1(v) Account sign-in and **account deletion**
|
||||
|
||||
> If your app supports account creation, you must also **offer account deletion within the app**.
|
||||
|
||||
From Apple's dedicated support page, in force since **30 June 2022**:
|
||||
|
||||
- The app must **offer to delete the entire account record along with associated personal data**. Offering only to temporarily deactivate or disable an account is **explicitly insufficient**.
|
||||
- The account deletion option must be **easy to find**, typically in account settings.
|
||||
- If completion requires a website, the app must link **directly to the page** where the process is completed - not to a general support page and not merely out to the default browser.
|
||||
- If deletion takes additional time, the user must be told.
|
||||
- Confirmation steps are permitted: reauthentication, identity verification, entering a code sent to an address already on the account.
|
||||
- Support-flow-only deletion (phone call, email, ticket) is permitted **only** for highly regulated industries under 5.1.1(ix). A social network is not one.
|
||||
- Apps that make deletion "unnecessarily difficult" fail review.
|
||||
|
||||
Also in 5.1.1(v): if the app does not include significant account-based features, people must be able to use it without a login. A social network is account-based by nature, but **read-only public browsing without an account** is a strong signal of good faith and reduces friction with this clause and with 4.2.
|
||||
|
||||
### 5.1.1(x) Optional contact information
|
||||
|
||||
Basic contact information may be requested only if optional, with features not conditional on providing it.
|
||||
|
||||
### 5.1.2 Data use and sharing - the AI clause
|
||||
|
||||
> You must clearly disclose where personal data will be shared with third parties, **including with third-party AI**, and obtain **explicit permission** before doing so.
|
||||
|
||||
This is decisive for any platform that routes user content through an external model provider. Every path where a user's post, comment, message, file, or profile text leaves the platform for a third-party model is a third-party data share that requires **disclosure plus explicit permission**, not merely a line in a privacy policy.
|
||||
|
||||
Further clauses:
|
||||
- **(i)** The app may not require the user to enable push notifications, location or tracking in order to access functionality or receive compensation. App Tracking Transparency consent is required for tracking.
|
||||
- **(ii)** Data collected for one purpose may not be repurposed without further consent.
|
||||
- **(iii)** No surreptitious profile building; no attempts to re-identify anonymous or aggregated data.
|
||||
|
||||
### 5.1.4 Kids
|
||||
|
||||
Apps that collect, transmit or have the capability to share personal information from a minor - including "the ability to chat" and persistent identifiers - must include a privacy policy and comply with all applicable children's privacy statutes (COPPA, GDPR and equivalents). Birthdate and parental contact information may be requested **only** for the purpose of complying with those statutes.
|
||||
|
||||
### 5.2 Intellectual property
|
||||
|
||||
- **5.2.1** No protected third-party material without permission; no misleading or copycat names or metadata.
|
||||
- **5.2.2** Content from a third-party service requires permission under that service's terms; authorization must be provided on request. Engaged by any news/RSS ingestion feature.
|
||||
- **5.2.3** No saving, converting or downloading media from third-party sources without explicit authorization. Engaged by any URL-fetch, archive, or media-embed feature.
|
||||
- **5.2.5** No Apple emoji embedded in the binary; no interfaces confusingly similar to Apple products.
|
||||
|
||||
A UGC platform additionally needs a **notice-and-takedown (DMCA-style) path**, because 5.2 makes the developer answerable for infringing user content and 1.2 makes removal the developer's responsibility.
|
||||
|
||||
### 5.3 Gaming, gambling, lotteries
|
||||
|
||||
If the platform runs contests, sweepstakes or prize draws: the developer must sponsor them, **official rules must be presented in the app**, and the rules must state that **Apple is not a sponsor and is not involved in any manner**. Randomized reward mechanics that cannot be purchased with real money stay outside 5.3.4.
|
||||
|
||||
### 5.6 Developer code of conduct
|
||||
|
||||
Trust (5.6.1), ratings and reviews integrity (5.6.2), accurate developer identity (5.6.3) and the prohibition on predatory behaviour (5.6.4) - the latter explicitly covering exploitation of minors and facilitation or encouragement of harmful behaviour toward others. Violations can remove the developer from the Apple Developer Program entirely, independent of any single app.
|
||||
|
||||
---
|
||||
|
||||
## 6. App Store Connect obligations (metadata, not code)
|
||||
|
||||
These are not guideline sections but they block submission or removal just as hard.
|
||||
|
||||
### 6.1 Age rating - the 2025 overhaul
|
||||
|
||||
Apple replaced the old ladder with **4+, 9+, 13+, 16+, 18+**; the 12+ and 17+ tiers were removed. The questionnaire gained required questions covering in-app controls, capabilities, medical/wellness topics, and violent themes, plus a **social-features block** covering:
|
||||
|
||||
- user-generated content;
|
||||
- messaging capability;
|
||||
- friend or follower systems;
|
||||
- livestreaming;
|
||||
- content creation tools;
|
||||
- advertising that may expose users to age-sensitive material.
|
||||
|
||||
Apple additionally asks **what safeguards the developer has implemented**: moderation systems, content filtering, reporting tools, blocking functionality, parental controls. Answering "none" to those questions on a social app drives the rating up and invites 1.2 scrutiny; answering "yes" untruthfully violates 2.3.6.
|
||||
|
||||
Developers were required to complete the updated questionnaire by **31 January 2026**, after which app updates are blocked in App Store Connect until the new questions are answered.
|
||||
|
||||
**Consequence for this project:** the safeguards questionnaire is answered from the platform's actual feature set. Each of the five safeguard answers should map to a named, demonstrable feature.
|
||||
|
||||
### 6.2 App privacy details ("nutrition labels")
|
||||
|
||||
Every data type collected by the app **or by its third-party partners** must be declared across the categories: Contact Info, Health & Fitness, Financial Info, Location, Sensitive Info, Contacts, User Content, Browsing History, Identifiers, Purchases, Usage Data, Diagnostics, Surroundings. Each declared type is classified as **Used to Track You**, **Linked to You**, or **Not Linked to You**. The developer is responsible for third-party SDK collection and for **keeping the answers accurate and up to date**; answers may be changed at any time without an app update.
|
||||
|
||||
For the platform under review the realistic declaration set is: Contact Info (name, email), User Content (posts, messages, photos/videos, other user content), Identifiers (user ID), Usage Data (product interaction), Diagnostics, and - if any analytics or crash reporting is added - the corresponding categories. All "Linked to You"; none "Used to Track You" provided no cross-app advertising tracking exists.
|
||||
|
||||
### 6.3 Support URL, marketing URL, privacy policy URL
|
||||
|
||||
Required metadata. The Support URL must present a working contact route (1.5). The privacy policy URL must be live and must match the in-app policy.
|
||||
|
||||
### 6.4 EU Digital Services Act trader status
|
||||
|
||||
Since **17 February 2025**, apps without a declared and verified trader status are **removed from the App Store in the EU**. Trader status became required for update submission on 16 October 2024. Articles 30 and 31 DSA require Apple to verify and publish trader contact information - **address, phone number and email** - on the App Store product page. The DSA definition of commercial activity is broad: paid apps, apps with IAP, or otherwise commercial distribution.
|
||||
|
||||
### 6.5 Notes for Review
|
||||
|
||||
Under 2.3.1 all functionality must be described specifically. For an app of this shape the notes must at minimum describe: the moderation pipeline, where the report and block controls are, where account deletion is, that code execution is remote and user-owned under the 2.5.2 educational exception, that the AI assistant is a chatbot under 4.7 with its own safety controls, and the demo account credentials with pre-seeded content so the reviewer can exercise reporting.
|
||||
|
||||
---
|
||||
|
||||
## 7. Overlapping legal regimes Apple enforces by reference
|
||||
|
||||
| Regime | What Apple enforces | Practical requirement |
|
||||
|--------|---------------------|-----------------------|
|
||||
| **GDPR** (5.1.1(ii), 5.1.2) | Lawful basis, consent, withdrawal, erasure | Consent capture with timestamp and version; consent withdrawal UI; account + data deletion; data export is the companion right users will ask for |
|
||||
| **EU DSA** (6.4, 1.5) | Trader identity, published contact, notice-and-action | Published contact information in app and on the store page; a reporting mechanism with acknowledgement and outcome notice; a statement of reasons to the affected user when content is removed |
|
||||
| **COPPA** (5.1.4) | No collection from under-13s without verifiable parental consent | Declared-age gate at signup; block or restrict accounts below the platform's minimum age; do not collect birthdate for any other purpose |
|
||||
| **DMCA / copyright** (5.2) | Removal of infringing user content | A designated notice-and-takedown channel and a counter-notice path |
|
||||
| **Local content ratings** (2.3.6) | Territory-specific rating and warning display | Age labelling on content that exceeds the app rating (also required by 1.2.1(a) and 4.7.5) |
|
||||
|
||||
---
|
||||
|
||||
## 8. The complete requirement register
|
||||
|
||||
Every row is a discrete, testable obligation. This register is the input to `applechanges.md`.
|
||||
|
||||
### 8.1 Mandatory - a missing item is a certain rejection
|
||||
|
||||
| # | Requirement | Source |
|
||||
|---|-------------|--------|
|
||||
| R1 | Terms of service / EULA that **explicitly states zero tolerance for objectionable content and abusive users** | 1.2 (review practice) |
|
||||
| R2 | **Affirmative acceptance** of those terms recorded per user at account creation, and re-acceptance on material change | 1.2, GDPR |
|
||||
| R3 | **Community guidelines** enumerating prohibited content, aligned to the 1.1.1-1.1.7 categories | 1.1, 1.2 |
|
||||
| R4 | **Automated filtering** of objectionable material at the point of posting, on every UGC surface | 1.2, 4.7.1 |
|
||||
| R5 | **Report mechanism on every UGC surface**: posts, comments, gists, projects, files, media, news, DMs, quizzes, profiles, AI output, workspaces | 1.2, 4.7.1 |
|
||||
| R6 | **Moderation queue** with triage, decision and enforcement actions for the operators | 1.2 |
|
||||
| R7 | **Published 24-hour response commitment** and a mechanism that makes it achievable and evidenced | 1.2 (review practice) |
|
||||
| R8 | **Ejection of offending users** - suspension/ban as a first-class enforcement action, not only content deletion | 1.2 |
|
||||
| R9 | **Block abusive users** from the service, covering all interaction surfaces including DMs | 1.2 |
|
||||
| R10 | **Published contact information reachable inside the app** | 1.5, DSA Art. 30 |
|
||||
| R11 | **Privacy policy** meeting 5.1.1(i)'s three content requirements, linked in-app and in ASC metadata | 5.1.1(i) |
|
||||
| R12 | **In-app account deletion** that deletes the account record and associated personal data, easy to find, no support-flow requirement | 5.1.1(v) |
|
||||
| R13 | **Declared-age gate** at account creation, with a minimum age, plus an age-restriction mechanism limiting underage access to age-exceeding content | 1.2.1(a), 4.7.5, 5.1.4 |
|
||||
| R14 | **Content age labelling** so users can identify content exceeding the app's age rating; mature content **hidden by default** | 1.2, 1.2.1(a), 4.7.5 |
|
||||
| R15 | **Explicit consent before user content is sent to third-party AI**, plus disclosure of which provider and what data | 5.1.2(i) |
|
||||
| R16 | **Consent withdrawal** UI that is easily accessible and understandable | 5.1.1(ii) |
|
||||
| R17 | **Push notifications optional**, never required for function, marketing push opt-in with in-app opt-out | 4.5.4, 5.1.2(i) |
|
||||
| R18 | **DMCA / IP notice-and-takedown** channel | 5.2 |
|
||||
| R19 | **Demo account with pre-seeded content** and review notes describing every safety control's location | 2.1, 2.3.1 |
|
||||
| R20 | **Age rating questionnaire** answered from the real feature set, including the five safeguard answers | 2.3.6, 6.1 |
|
||||
| R21 | **App privacy details** declared accurately for every data type, including third-party AI processing | 6.2 |
|
||||
| R22 | **EU trader status** declared and verified, with address, phone and email | 6.4 |
|
||||
| R23 | **IPv6-only reachability** of every endpoint, WebSocket and asset host | 2.5.5 |
|
||||
| R24 | **Remote code execution positioned under the 2.5.2 educational exception**: source completely viewable and editable, executed off-device, never altering the app | 2.5.2 |
|
||||
| R25 | **Native client that is materially more than a web wrapper** | 4.2 |
|
||||
|
||||
### 8.2 Conditional - required if the corresponding feature exists
|
||||
|
||||
| # | Requirement | Trigger |
|
||||
|---|-------------|---------|
|
||||
| C1 | Sign in with Apple or an equivalent privacy-preserving login | Any third-party/social login is offered |
|
||||
| C2 | In-app purchase for every digital good, currency, credit, boost, tip or premium unlock | Anything is sold to end users in-app |
|
||||
| C3 | Loot-box odds disclosure | Randomized purchasable rewards |
|
||||
| C4 | Official contest rules in-app stating Apple is not a sponsor | Any sweepstake, contest or raffle |
|
||||
| C5 | Index of all offered mini apps/software with universal links | Users can open other users' software from the app |
|
||||
| C6 | Ad reporting control | Advertising is displayed |
|
||||
| C7 | ATT prompt | Any cross-app/site tracking |
|
||||
| C8 | Recording indicator and consent | Any session/screen/activity recording |
|
||||
| C9 | Per-instance consent before sharing data or permissions with a mini app | Mini apps receive user data |
|
||||
|
||||
### 8.3 Posture requirements - not a single feature, an ongoing obligation
|
||||
|
||||
| # | Requirement | Source |
|
||||
|---|-------------|--------|
|
||||
| P1 | Ability to produce, on Apple's request, a **compliance improvement plan** and evidence of moderation throughput | 1.2 |
|
||||
| P2 | Retention of moderation decisions as an audit trail | 1.2, DSA |
|
||||
| P3 | Statement of reasons to the user whose content is removed or whose account is actioned | DSA Art. 17 |
|
||||
| P4 | Keeping privacy labels and the privacy policy in step with feature changes | 6.2, 5.1.1(i) |
|
||||
| P5 | Accurate "What's New" text for significant changes | 2.3.12 |
|
||||
|
||||
---
|
||||
|
||||
## 9. Where reviewers actually look
|
||||
|
||||
Ordered by observed rejection frequency for this application shape:
|
||||
|
||||
1. **Report control not visible on the first screen of content the reviewer opens.** The reviewer opens the feed, taps a post, and looks for a report affordance. If it is buried behind a profile menu, the app is rejected under 1.2 even though the mechanism exists.
|
||||
2. **No terms acceptance at signup.** The reviewer creates an account with the demo credentials or a fresh account and looks for the EULA gate.
|
||||
3. **Account deletion not found in settings.** The reviewer opens account settings and searches for "Delete account".
|
||||
4. **Privacy policy not reachable in-app.**
|
||||
5. **Demo account sees an empty feed**, so nothing can be reported or blocked.
|
||||
6. **Blocking present but not reachable from the content itself**, only from a profile.
|
||||
7. **AI feature sending content to a third party with no disclosure or consent.**
|
||||
8. **No age gate on a platform with messaging and follower systems.**
|
||||
|
||||
---
|
||||
|
||||
## 10. Determination for this platform
|
||||
|
||||
Applying the register to the DevPlace shape:
|
||||
|
||||
- **Applicable in full:** R1-R25 except where noted below.
|
||||
- **C1 not triggered** provided the platform continues to use exclusively its own account system. Adding any social login triggers it immediately.
|
||||
- **C2 not triggered** provided no in-app purchase of any digital good, currency, credit or quota exists and none is linked to. The in-app virtual economy must remain earn-only.
|
||||
- **C3 not triggered** while randomized rewards are not purchasable.
|
||||
- **C4 triggered** by any leaderboard prize, era award or contest that awards something of value; the safe position is that awards are purely cosmetic/status and are not framed as a contest with prizes.
|
||||
- **C5 triggered** if a user can open another user's running workspace, published site or executable project from the app.
|
||||
- **C6, C7 not triggered** while there is no advertising and no cross-app tracking.
|
||||
- **C8 triggered** by presence tracking, live view relay, session recording or terminal session capture that records user activity.
|
||||
- **C9 triggered** by any path where platform user data is passed into a user-authored workspace or plug-in.
|
||||
|
||||
The single largest exposure is **R5 breadth**: reporting must exist on every surface, and the platform under review has an unusually large number of distinct UGC surfaces. The second largest is **R15**, because AI is woven through the platform and every path that sends user text to a model provider is a third-party data share.
|
||||
|
||||
---
|
||||
|
||||
## Sources
|
||||
|
||||
- [App Review Guidelines](https://developer.apple.com/app-store/review/guidelines/)
|
||||
- [Offering Account Deletion in Your App](https://developer.apple.com/support/offering-account-deletion-in-your-app/)
|
||||
- [App Privacy Details on the App Store](https://developer.apple.com/app-store/app-privacy-details/)
|
||||
- [Updated age ratings in App Store Connect](https://developer.apple.com/news/?id=ks775ehf)
|
||||
- [Age rating questionnaire now includes social media questions](https://developer.apple.com/news/?id=tlur8uvi)
|
||||
- [Apple overhauls App Store age ratings](https://www.macrumors.com/2025/07/25/apple-overhauls-app-store-age-ratings/)
|
||||
- [Apple notifies developers of new App Store age rating system](https://9to5mac.com/2025/07/24/apple-notifies-developers-of-new-app-store-age-rating-system/)
|
||||
- [Apps without trader status will be removed from the App Store in the EU](https://developer.apple.com/news/?id=einwn76m)
|
||||
- [Manage European Union Digital Services Act trader requirements](https://developer.apple.com/help/app-store-connect/manage-compliance-information/manage-european-union-digital-services-act-trader-requirements/)
|
||||
- [Provide your trader status in App Store Connect](https://developer.apple.com/news/?id=x60uzbu9)
|
||||
- [Resolving App Store Guideline 1.2 - User Generated Content](https://buddyboss.com/docs/app-store-guideline-1-2-safety-user-generated-content/)
|
||||
- [Complying with Apple App Store UGC requirements](https://www.termsfeed.com/videos/apple-app-store-comply-ugc-requirements/)
|
||||
- [Guideline 1.2 - Safety - User-Generated Content (Apple Developer Forums)](https://developer.apple.com/forums/thread/807358)
|
||||
-583
@@ -1,583 +0,0 @@
|
||||
# DevPlace: App Store compliance implementation design
|
||||
|
||||
Author: retoor <retoor@molodetz.nl>
|
||||
|
||||
Stage three of `apple.md`. Inputs are [`applecomp.md`](applecomp.md) (what Apple requires) and [`applechanges.md`](applechanges.md) (what DevPlace lacks). This document is the design: the most consistent, DRY, caveat-free way to implement every gap inside the conventions this codebase already enforces.
|
||||
|
||||
Nothing here is implemented. This is the specification the lord is asked to approve.
|
||||
|
||||
---
|
||||
|
||||
## 1. Design axioms
|
||||
|
||||
Each axiom is derived from an existing DevPlace pattern, named with its precedent. No axiom is invented for this feature.
|
||||
|
||||
| # | Axiom | Precedent in the codebase |
|
||||
|---|-------|---------------------------|
|
||||
| **A1** | **One polymorphic facility, never twenty per-surface features.** A report is structurally a vote: an actor, a `(target_type, target_uid)` pair, a payload. | `comments`, `votes`, `reactions`, `bookmarks` all key on `(target_type, target_uid)`; `VOTABLE_TARGETS` in `database/ranking.py:11`; `REACTABLE` in `routers/reactions.py:17` |
|
||||
| **A2** | **The target set is a registry, not a literal.** Every consumer reads the same dict; adding a surface is one line. | `VOTABLE_TARGETS`, `STAR_TARGETS`, `NOTIFICATION_TYPES`, `SOFT_DELETE_TABLES`, `DOCS_PAGES`, `DATA_PATHS` |
|
||||
| **A3** | **Machine-raised and human-raised entries share one queue and one state machine.** | `services/containers/workspace/flags.py`: `raise_flag` is machine-driven, `set_status` is admin-driven, statuses `open/acknowledged/resolved/dismissed`, severities `info/warn/critical` |
|
||||
| **A4** | **Every route has four faces:** HTML, JSON, Devii action, API docs. | Root `CLAUDE.md`, "Anatomy of a feature" |
|
||||
| **A5** | **Removal is soft; garbage collection is hard; cascades share one stamp.** | `database/soft_delete.py`, `SOFT_DELETE_TABLES` (44 tables), `soft_delete_in`, `purge_event`, `/admin/trash` |
|
||||
| **A6** | **Runtime policy lives in `site_settings`,** read through `get_setting`/`get_int_setting`, live-editable at `/admin/settings`, never in code constants. | `database/schema.py:276`, `rate_limit_per_minute`, `maintenance_mode`, `registration_open` |
|
||||
| **A7** | **Action bars are composed from included partials** with `{% set _type %}{% set _uid %}{% include %}`. | `_reaction_bar.html` included from `_post_card.html:37` and `_comment.html:37` |
|
||||
| **A8** | **Non-response-critical side-effects go through `background.submit`;** audit and notifications are already funnelled there. | `services/background.py`, `utils/notifications.py:68` |
|
||||
| **A9** | **Never a silent failure.** A safety control that swallows an error is worse than absent. | Root `CLAUDE.md`; `services/audit` never raises into the caller but always records |
|
||||
| **A10** | **Legal and policy prose is a docs page,** with the existing role gating, SEO context and search index. | `routers/docs/pages.py` `DOCS_PAGES`; the admin-only `media-moderation` page proves gating works |
|
||||
| **A11** | **Owner-or-admin, with the seniority guard on admin-versus-admin.** | `content.is_owner`, `_is_senior_admin` in `routers/admin/users.py` |
|
||||
| **A12** | **The AI gateway is the single choke point for third-party model calls,** so consent is enforced in exactly one place. | `services/openai_gateway/`, `INTERNAL_GATEWAY_URL` |
|
||||
|
||||
---
|
||||
|
||||
## 2. The unifying abstraction
|
||||
|
||||
Everything in this design hangs off **one registry** and **one queue**.
|
||||
|
||||
### 2.1 The moderation target registry
|
||||
|
||||
New module `devplacepy/database/moderation.py`, mirroring `database/ranking.py` exactly in shape and placement:
|
||||
|
||||
```
|
||||
REPORTABLE_TARGETS: dict[str, str] # target_type -> table name
|
||||
MATURITY_TARGETS: set[str] # subset that can carry an age label
|
||||
```
|
||||
|
||||
`REPORTABLE_TARGETS` covers every externally-visible surface from `applechanges.md` §2:
|
||||
|
||||
`post`, `comment`, `gist`, `project`, `project_file`, `news`, `attachment`, `message`, `quiz`, `poll`, `award`, `user`, `issue`, `workspace`, `devii_output`.
|
||||
|
||||
`MATURITY_TARGETS` is the subset that renders long-form authored content: `post`, `comment`, `gist`, `project`, `news`, `attachment`, `quiz`.
|
||||
|
||||
**Why a registry rather than per-surface code.** A report route, a report button, a moderation queue row, a Devii action parameter enum, an API docs enum and a test fixture all need the same list. With a registry they read it; without one they drift. This is the same reason `VOTABLE_TARGETS` exists.
|
||||
|
||||
**The completeness invariant.** A unit test asserts that every entry in `REPORTABLE_TARGETS` resolves to a real table (or an explicitly listed virtual surface) **and** that every externally-visible table in `SOFT_DELETE_TABLES` appears in `REPORTABLE_TARGETS`. Adding a new UGC surface without adding it to the registry fails the suite. Requirement R5 is therefore satisfied not by diligence but by construction. This is the load-bearing correctness claim of the whole design; §11 formalises it.
|
||||
|
||||
### 2.2 The single queue
|
||||
|
||||
One table, `content_reports`, with two producers:
|
||||
|
||||
- **members**, via the report control on every content action bar;
|
||||
- **the filter**, via a system-raised entry when classification returns `review`.
|
||||
|
||||
This is `workspace_flags` generalised from one instance type to the registry. Same state machine (`open → acknowledged → actioned | dismissed`), same severity ladder (`info | warn | critical`), same soft-delete participation, same admin resolution surface. One queue means one SLA measurement, one admin screen, one audit shape, and one place where the 24-hour commitment is either met or visibly not.
|
||||
|
||||
### 2.3 URL resolution is already solved
|
||||
|
||||
`database/content.py:22` `resolve_object_url(target_type, target_uid)` already maps `post`, `project`, `news`, `issue`, `gist`, `quiz`, `comment` and `award` to their canonical URLs, recursing through comments to their parents. It gains the remaining registry entries (`project_file`, `attachment`, `message`, `user`, `workspace`, `poll`, `devii_output`). Every moderation surface then links to its subject for free, using the function the notification system already uses.
|
||||
|
||||
---
|
||||
|
||||
## 3. Data layer
|
||||
|
||||
All schema changes land in `devplacepy/database/schema.py` `init_db()` following the existing `has_column` / `create_column_by_example` / `_index` idiom, and every new table is registered in `SOFT_DELETE_TABLES`.
|
||||
|
||||
### 3.1 `content_reports`
|
||||
|
||||
| Column | Type | Notes |
|
||||
|--------|------|-------|
|
||||
| `uid` | text | `generate_uid()` |
|
||||
| `reporter_uid` | text | user uid, or `system` for filter-raised (mirrors `audit.record_system`) |
|
||||
| `target_type` | text | key of `REPORTABLE_TARGETS` |
|
||||
| `target_uid` | text | subject uid |
|
||||
| `owner_uid` | text | author of the reported content, denormalised at insert so the queue never N+1s |
|
||||
| `reason` | text | key of `REPORT_REASONS` (§3.6) |
|
||||
| `detail` | text | reporter's free text, max 2000 |
|
||||
| `severity` | text | `info` / `warn` / `critical` |
|
||||
| `status` | text | `open` / `acknowledged` / `actioned` / `dismissed` |
|
||||
| `origin` | text | `member` / `filter` |
|
||||
| `categories` | text | JSON list of matched 1.1.x category keys, filter-raised only |
|
||||
| `resolved_by` | text | admin uid |
|
||||
| `resolved_at` | text | ISO |
|
||||
| `created_at`, `updated_at` | text | ISO |
|
||||
| `deleted_at`, `deleted_by` | text | soft delete |
|
||||
|
||||
Indexes: `(status, created_at)` for the queue and the SLA scan; `(target_type, target_uid)` for "is this already reported"; `(reporter_uid)` for the reporter's own list; `(owner_uid)` for offender history. Partial soft-delete index per the standing convention.
|
||||
|
||||
**Duplicate handling** follows `raise_flag` precisely: an open report for the same `(target_type, target_uid, reporter_uid)` is updated, not duplicated. A different reporter on the same target creates a new row; the queue groups by target and shows the count, which is exactly how a real moderation queue prioritises.
|
||||
|
||||
### 3.2 `moderation_actions`
|
||||
|
||||
The decision record. One row per moderator decision, linked to the report that triggered it.
|
||||
|
||||
`uid`, `report_uid`, `actor_uid`, `action`, `target_type`, `target_uid`, `subject_uid`, `reason`, `notes`, `expires_at`, `created_at`, soft-delete columns.
|
||||
|
||||
`action` ∈ `remove_content`, `restore_content`, `warn`, `suspend`, `ban`, `lift`, `dismiss`, `escalate`.
|
||||
|
||||
This is the DSA statement-of-reasons substrate (P3) and the compliance-plan evidence (P1). It is separate from the audit log because the audit log is append-only infrastructure and this is queryable moderation state with its own lifecycle - the same reason `workspace_flags` exists alongside the audit log.
|
||||
|
||||
### 3.3 `content_maturity`
|
||||
|
||||
Polymorphic age label, one row per labelled item. `uid`, `target_type`, `target_uid`, `level`, `source`, `set_by`, `created_at`, soft-delete columns.
|
||||
|
||||
`level` ∈ `general`, `mature`, `restricted`. `source` ∈ `author`, `filter`, `moderator`.
|
||||
|
||||
Read through a batch helper `get_maturity_by_targets(target_type, uids)` modelled exactly on `database/engagement.py` `get_reactions_by_targets` - no N+1, one query per listing. Absence of a row means `general`, so nothing needs backfilling and no existing row is touched.
|
||||
|
||||
### 3.4 `user_consents`
|
||||
|
||||
`uid`, `owner_kind`, `owner_id`, `kind`, `version`, `state`, `granted_at`, `withdrawn_at`, `created_at`, soft-delete columns.
|
||||
|
||||
`owner_kind`/`owner_id` reuse the `owner_for(request)` convention from the customization subsystem verbatim, so guests are covered by the same table. `kind` ∈ `terms`, `privacy`, `ai_third_party`, `activity_recording`. `state` ∈ `granted`, `withdrawn`.
|
||||
|
||||
Consent is **versioned and append-only in effect**: withdrawing writes `withdrawn_at` and a new grant writes a new row, so the full consent history is provable - which is what GDPR and Apple both actually require.
|
||||
|
||||
### 3.5 `users` columns
|
||||
|
||||
Added with the existing `has_column` guard block at `database/schema.py:1823`:
|
||||
|
||||
| Column | Default | Purpose |
|
||||
|--------|---------|---------|
|
||||
| `terms_version` | `""` | Accepted document version (R2) |
|
||||
| `terms_accepted_at` | `""` | ISO timestamp (R2) |
|
||||
| `age_band` | `""` | `under_min` / `13_15` / `16_17` / `adult` (R13) |
|
||||
| `age_declared_at` | `""` | ISO timestamp |
|
||||
| `mature_opt_in` | `0` | Explicit opt-in to see mature-labelled content (R14) |
|
||||
| `suspended_until` | `""` | ISO; empty means not suspended (R8) |
|
||||
| `suspension_reason` | `""` | Shown to the user (P3) |
|
||||
| `deletion_requested_at` | `""` | Starts the deletion clock (R12) |
|
||||
|
||||
**No birthdate is stored.** 5.1.4 permits collecting it only to comply with children's privacy statutes; data minimization (5.1.1(iii)) then requires storing only the derived band. The signup form collects a date, derives the band, and discards the date. This is both the compliant and the simpler design.
|
||||
|
||||
### 3.6 Registries and constants
|
||||
|
||||
`devplacepy/database/moderation.py` also owns:
|
||||
|
||||
- `REPORT_REASONS: dict[str, str]` - key to label, mapped one-to-one onto the guideline categories so the age-rating questionnaire and the community guidelines can be written from the same list: `hate` (1.1.1), `violence` (1.1.2), `weapons` (1.1.3), `sexual` (1.1.4), `religious` (1.1.5), `misinformation` (1.1.6), `exploitative` (1.1.7), `harassment`, `spam`, `intellectual_property` (5.2 / R18), `self_harm`, `illegal`, `other`.
|
||||
- `REPORT_STATUSES`, `REPORT_SEVERITIES`, `MODERATION_ACTIONS`, `MATURITY_LEVELS`, `CONSENT_KINDS`, `AGE_BANDS`.
|
||||
|
||||
One list, consumed by the form validator, the Devii action schema, the API docs enum, the admin filter dropdown and the community-guidelines page. Changing a reason is one edit.
|
||||
|
||||
### 3.7 `site_settings` keys
|
||||
|
||||
Added to the defaults block at `database/schema.py:276`, editable live at `/admin/settings` (A6):
|
||||
|
||||
| Key | Default | Purpose |
|
||||
|-----|---------|---------|
|
||||
| `moderation_sla_hours` | `24` | The published commitment (R7) |
|
||||
| `moderation_filter_mode` | `review` | `off` / `label` / `review` / `block` (R4) |
|
||||
| `moderation_minimum_age` | `16` | Signup floor (R13) |
|
||||
| `moderation_mature_default_hidden` | `1` | Mature content hidden by default (R14) |
|
||||
| `contact_email`, `contact_phone`, `contact_address` | empty | Published contact + DSA trader data (R10, R22) |
|
||||
| `terms_version`, `privacy_version`, `guidelines_version` | `1` | Bump forces re-acceptance (R2) |
|
||||
| `ai_third_party_provider` | `""` | Named in the consent copy (R15) |
|
||||
| `account_deletion_grace_hours` | `24` | Reversible window before purge (R12) |
|
||||
|
||||
---
|
||||
|
||||
## 4. The content filter
|
||||
|
||||
`devplacepy/services/moderation/` - a new service package alongside `services/audit/`, `services/game/` and the rest, with its own nested `CLAUDE.md`.
|
||||
|
||||
### 4.1 Shape
|
||||
|
||||
```
|
||||
services/moderation/
|
||||
__init__.py record()-style entrypoints, the only public surface
|
||||
filter.py classify(text) -> Classification
|
||||
rules.py the category rule set
|
||||
queue.py raise_report / set_status / decide / list_reports
|
||||
enforcement.py suspend / ban / lift / remove_content
|
||||
sla.py oldest_open_age / breach_count
|
||||
```
|
||||
|
||||
`Classification` is a frozen dataclass (`verdict`, `categories`, `maturity`, `score`) - dataclasses over fixed-key dicts, per the standing style rule.
|
||||
|
||||
`verdict` ∈ `allow`, `label`, `review`, `block`, resolved against `moderation_filter_mode` so an administrator can dial the platform from advisory to strict without a deploy.
|
||||
|
||||
### 4.2 Where it runs - exactly five call sites
|
||||
|
||||
The filter is invoked only at choke points that already exist, so no surface can be missed and no surface needs bespoke code:
|
||||
|
||||
1. `content.create_content_item` (`content.py:197`) - posts, projects, gists, news, quizzes.
|
||||
2. `content.create_comment_record` (`content.py:361`) - every comment on every parent type.
|
||||
3. `content.edit_content_item` and `content.edit_comment_record` - edits, so a clean post cannot be edited into a violation.
|
||||
4. `routers/messages.py:245` `send_message` and the WebSocket send path - direct messages.
|
||||
5. `routers/profile/index.py` profile update and `routers/auth/signup.py` - bio, location, links, username.
|
||||
|
||||
Five call sites cover twenty surfaces because the codebase already funnels creation. This is the direct payoff of DevPlace's existing structure.
|
||||
|
||||
### 4.3 Behaviour, and why it is safe on a developer platform
|
||||
|
||||
The single largest implementation risk identified in `applechanges.md` §7 is false positives: a security-focused developer community discusses exploits, weapons-grade cryptography and violent language in code review. A naive block destroys the product.
|
||||
|
||||
The design answers this structurally:
|
||||
|
||||
- **The default mode is `review`, not `block`.** A flagged item is published **and** a system report is raised. Nothing legitimate is ever suppressed by a machine.
|
||||
- **Only the `sexual` and `exploitative` categories default to `block`**, because those are the two where Apple removes apps without notice and where no developer-platform false-positive case exists.
|
||||
- **Thresholds are `site_settings`,** tunable live while watching the queue.
|
||||
- **A failure in the filter fails to `review`, never to `allow`** (A9). If classification raises, the content is published and a `critical` system report is raised naming the failure. A moderation control that fails open is worse than absent.
|
||||
|
||||
This gives Apple the "method for filtering objectionable material from being posted" that 1.2 requires, gives the platform a human in the loop, and gives the community no false suppression.
|
||||
|
||||
---
|
||||
|
||||
## 5. Server layer
|
||||
|
||||
### 5.1 Reporting - `devplacepy/routers/reports.py`, mounted at `/reports`
|
||||
|
||||
Mirrors `routers/reactions.py` line for line.
|
||||
|
||||
| Method | Path | Auth | Purpose |
|
||||
|--------|------|------|---------|
|
||||
| `POST` | `/reports/{target_type}/{target_uid}` | member | Submit a report |
|
||||
| `GET` | `/reports/mine` | member | The reporter's own reports and their outcomes (DSA Art. 16 acknowledgement) |
|
||||
| `GET` | `/reports/reasons` | public | The reason registry, so any client renders the same dialog |
|
||||
|
||||
Input model `ReportForm` in `models.py` (`reason`, `detail`); output schema `ReportOut` / `ReportListOut` in `schemas/moderation.py`. `respond(request, template, ctx, model=ReportOut)` gives HTML and JSON from one handler. Rate limiting is already global on POST via the existing middleware; no per-route limiter is added.
|
||||
|
||||
Submitting a report **always** notifies the reporter through `create_notification` with the acknowledgement and the SLA, and **never** notifies the reported user (that happens only on decision, as a statement of reasons).
|
||||
|
||||
### 5.2 Moderation queue - `devplacepy/routers/admin/moderation.py`
|
||||
|
||||
Registered in the `admin/` package exactly like `trash.py` and `media.py`, with `admin_section = "moderation"`.
|
||||
|
||||
| Method | Path | Purpose |
|
||||
|--------|------|---------|
|
||||
| `GET` | `/admin/moderation` | The queue, grouped by target, sorted oldest-open-first, with the SLA badge |
|
||||
| `GET` | `/admin/moderation/{uid}` | One report, its target rendered in place, the offender's history |
|
||||
| `POST` | `/admin/moderation/{uid}/status` | `acknowledge` / `dismiss` |
|
||||
| `POST` | `/admin/moderation/{uid}/decide` | Apply a `MODERATION_ACTIONS` decision |
|
||||
|
||||
Every decision writes a `moderation_actions` row, records an audit event, and - where the decision affects a user - delivers a statement of reasons through `create_notification`.
|
||||
|
||||
### 5.3 Enforcement - extending `routers/admin/users.py`
|
||||
|
||||
The bare `is_active` toggle at `admin/users.py:179` is kept for backward compatibility and joined by:
|
||||
|
||||
| Method | Path | Purpose |
|
||||
|--------|------|---------|
|
||||
| `POST` | `/admin/users/{uid}/suspend` | Reason + duration; writes `suspended_until`, `suspension_reason` |
|
||||
| `POST` | `/admin/users/{uid}/lift` | Clears both |
|
||||
| `POST` | `/admin/users/{uid}/ban` | Permanent; `is_active = False` **with** a recorded reason |
|
||||
|
||||
All three pass through the existing `_is_senior_admin(actor, target)` guard (A11), so a junior admin cannot suspend a senior one - server-side, therefore also covering Devii.
|
||||
|
||||
Enforcement is read by one new predicate in `content.py`, `is_suspended(user)`, consulted by `require_user` so a suspended account can still read, still see why, and still delete their account, but cannot post. This is one predicate at one choke point, not a scattered check.
|
||||
|
||||
### 5.4 Account deletion - `routers/profile/delete.py`
|
||||
|
||||
Follows the `regenerate-avatar` precedent (owner-or-admin, POST under `/profile/{username}/…`, audited, cache-invalidating).
|
||||
|
||||
| Method | Path | Purpose |
|
||||
|--------|------|---------|
|
||||
| `GET` | `/profile/{username}/delete` | The confirmation page: what will be deleted, what is retained and why, the grace window |
|
||||
| `POST` | `/profile/{username}/delete` | Requires the account password (reauthentication, explicitly permitted by Apple); starts deletion |
|
||||
|
||||
**The cascade**, using one shared stamp (A5):
|
||||
|
||||
1. Stamp `deletion_requested_at`, revoke every session and access token, invalidate the user cache.
|
||||
2. `soft_delete_in(table, "user_uid", [uid], deleted_by=uid, stamp=stamp)` across every table in `SOFT_DELETE_TABLES` that carries a `user_uid` - one stamp, so `/admin/trash` can restore the entire event atomically within the grace window.
|
||||
3. Anonymise the `users` row immediately: username tombstoned, email, bio, location, links, avatar seed, API key and password hash cleared. **From the user's and every other user's point of view, the account is gone the moment they confirm.**
|
||||
4. A GC sweep (`devplace accounts prune`, and a scheduled pass in the existing service manager) hard-purges the stamped event after `account_deletion_grace_hours`, using `purge_event(stamp)` - the function that already exists.
|
||||
|
||||
The confirmation page states the grace window explicitly, satisfying Apple's "if the deletion request will take additional time to complete, let them know."
|
||||
|
||||
The devRant `DELETE /api/users/me` at `routers/devrant/auth.py:189` is re-pointed at this same cascade, because a deactivation masquerading as a deletion is exactly what Apple names as insufficient, and because two paths must not mean two behaviours.
|
||||
|
||||
### 5.5 Terms, age and consent
|
||||
|
||||
| Method | Path | Purpose |
|
||||
|--------|------|---------|
|
||||
| `POST` | `/auth/accept-terms` | Records acceptance of the current `terms_version` |
|
||||
| `POST` | `/profile/{username}/consent` | Grant or withdraw a `CONSENT_KINDS` entry |
|
||||
| `GET` | `/profile/{username}?tab=privacy` | Acceptances, consents, withdrawal controls, deletion entry point |
|
||||
|
||||
`SignupForm` (`models.py:51`) gains `birth_date` and `accept_terms`, both required, validated Pydantic-natively like every other form in the project. The validator derives `age_band`, rejects below `moderation_minimum_age`, and the raw date never reaches the database.
|
||||
|
||||
**The re-acceptance gate** is a middleware in the existing stack in `main.py`, sitting beside the maintenance gate it is modelled on: an authenticated user whose `terms_version` is behind the setting is redirected to the acceptance page for any mutating request, while reads, `/static`, `/auth`, `/docs` and account deletion stay open. A user must never be trapped: they can always read, always accept, and always delete their account.
|
||||
|
||||
### 5.6 Third-party AI consent - one gate at one choke point
|
||||
|
||||
Enforced in `services/openai_gateway/` where every internal AI consumer already converges (A12).
|
||||
|
||||
The rule distinguishes two things that the existing code currently conflates:
|
||||
|
||||
- **User-content processing** - the user's own post, comment, message, file or prompt is sent to the provider. Requires a granted `ai_third_party` consent for that user. Default: **not granted**.
|
||||
- **Platform processing** - news import, bot personas, SEO metadata for platform-owned text. Not user content, not gated by user consent.
|
||||
|
||||
The gateway resolves the owner it is acting for and refuses a user-content call without consent, returning a structured error the callers already know how to surface. The existing `ai_correction_enabled` and `ai_modifier_enabled` flags survive unchanged as **preferences**, subordinate to consent: consent withdrawn means the feature is off regardless of the preference. `ai_modifier_enabled`'s default of `1` becomes harmless, because consent gates it. No existing preference is silently flipped; the gate is simply added above them.
|
||||
|
||||
The consent copy names the provider from `ai_third_party_provider`, states what is sent and why, and links the privacy policy - the three things 5.1.2(i) demands.
|
||||
|
||||
### 5.7 Activity-recording consent and indicator (C8)
|
||||
|
||||
`activity_recording` consent covers presence (`services/presence.py`), the live view relay and Devii terminal session capture. Guideline 2.5.14 wants consent **and** a clear indication. The indication reuses the existing presence dot partial `_presence_dot.html` and the response-time badge idiom in `base.html`: a small, always-visible recording indicator when a session is being captured. Withdrawing consent stops presence writes for that user; they simply appear offline.
|
||||
|
||||
### 5.8 The software index (C5 / 4.7.4)
|
||||
|
||||
`GET /workspaces/index` - a public, paginated index of every user-published workspace reachable through the `/p/{slug}` ingress, with its owner, description, maturity label and canonical URL. This is the "index of software and metadata available in your app… including universal links" that 4.7.4 requires. It reuses the existing listing machinery (`build_pagination`, `_card_link.html`, `paginate_diverse`) and is added to the sitemap.
|
||||
|
||||
---
|
||||
|
||||
## 6. View layer
|
||||
|
||||
### 6.1 One partial, included everywhere
|
||||
|
||||
`templates/_report_button.html`, included with the same two-variable idiom as `_reaction_bar.html` (A7):
|
||||
|
||||
```
|
||||
{% set _type = "post" %}{% set _uid = item.post['uid'] %}{% set _owner = item.post['user_uid'] %}
|
||||
{% include "_report_button.html" %}
|
||||
```
|
||||
|
||||
It renders **Report** and, when the viewer is not the owner, **Block**, both `guest_disabled(user)`, both matching the existing `post-action-btn` / `comment-action-btn` visual language exactly. Placing Block here closes gap G9 from `applechanges.md`: blocking becomes reachable from the content, not only from a profile.
|
||||
|
||||
Include sites: `_post_card.html`, `_comment.html`, `post.html`, `gist_detail.html`, `project_detail.html`, `news_detail.html`, `quiz.html`, `_media_gallery.html`, `messages.html`, `profile.html`, `_award_badge.html`, `project_files.html`, `issue_detail.html`, `containers_instance.html`.
|
||||
|
||||
**One partial, fourteen include sites, zero duplicated markup.** A template that renders content and omits the include is caught by the e2e coverage test in §10.
|
||||
|
||||
### 6.2 One dialog
|
||||
|
||||
`templates/_report_dialog.html` is included once in `base.html`, exactly as the reaction picker is a single palette reused by every bar. `static/js/ReportDialog.js` - one ES6 class, registered on `app`, using the existing `Http` helper and the established `.modal-overlay` / `.visible` modal pattern - reads `data-report-type` and `data-report-uid` from the clicked button, populates the reason list from `/reports/reasons`, and posts. No new modal machinery, no third-party library.
|
||||
|
||||
### 6.3 Maturity gate
|
||||
|
||||
`templates/_maturity_gate.html`: an interstitial rendered in place of a `mature`-labelled item for a viewer who has not opted in or whose `age_band` is below the threshold. Reveal is a single control that sets `mature_opt_in`; it is not offered at all to `13_15` or `16_17` bands for `restricted` content. Content stays hidden by default, which is precisely 1.2's wording.
|
||||
|
||||
### 6.4 Admin
|
||||
|
||||
`templates/admin_moderation.html` extends `admin_base.html` with `admin_section = "moderation"`, and a sidebar entry is added to `admin_base.html` between Media and Trash - the natural neighbours. The queue header carries the SLA badge: oldest open report age against `moderation_sla_hours`, green under, red over. That badge is the mechanism that makes the published 24-hour commitment (R7) real rather than aspirational.
|
||||
|
||||
### 6.5 Legal pages and the footer
|
||||
|
||||
Legal prose ships as `DOCS_PAGES` entries (A10) under a new `SECTION_LEGAL = "Legal"`, placed in the `AUDIENCE_START` group so it is one click from `/docs`:
|
||||
|
||||
| Slug | Title | Requirement |
|
||||
|------|-------|-------------|
|
||||
| `terms` | Terms of Service | R1, R2 |
|
||||
| `community-guidelines` | Community Guidelines | R3 |
|
||||
| `privacy` | Privacy Policy | R11 |
|
||||
| `contact` | Contact | R10, R22 |
|
||||
| `content-moderation` | How moderation works | R7, P1 |
|
||||
| `intellectual-property` | Notice and takedown | R18 |
|
||||
| `moderation-operations` | Operating the queue (admin-gated, like `media-moderation`) | P1, P2 |
|
||||
|
||||
`_footer_links.html` gains Terms, Privacy, Guidelines and Contact alongside the existing four links. This is the "easily accessible in the app" that 5.1.1(i) and 1.5 both require, and it is on every page because the footer is in `base.html`.
|
||||
|
||||
`contact` renders `contact_email`, `contact_phone` and `contact_address` from `site_settings`, so the in-app contact data and the App Store Connect trader data have one source of truth and cannot drift (R10 ≡ R22).
|
||||
|
||||
### 6.6 Signup
|
||||
|
||||
`templates/signup.html` gains a date-of-birth field and a required terms checkbox whose label links `/docs/terms.html` and `/docs/community-guidelines.html`. Both are validated by `SignupForm`, so the error path is the existing global `RequestValidationError` handler that already re-renders auth pages with messages.
|
||||
|
||||
---
|
||||
|
||||
## 7. Agent, docs and SEO layer
|
||||
|
||||
Per A4, nothing ships with fewer than four faces.
|
||||
|
||||
- **Devii** - `services/devii/actions/catalog/moderation.py` exporting `MODERATION_ACTIONS`: `report_content`, `list_my_reports`, `list_reports` (admin), `decide_report` (admin), `suspend_user` (admin), `lift_suspension` (admin), `delete_my_account`, `set_consent`, `accept_terms`. `delete_my_account`, `decide_report`, `suspend_user` and `ban_user` join `CONFIRM_REQUIRED` in `dispatcher.py`, **each declaring a `confirm` boolean param in its catalog spec** - the load-bearing detail the root `CLAUDE.md` calls out, without which a gated tool loops forever.
|
||||
- **API docs** - `docs_api/groups/moderation.py`, a new group with `endpoint()` entries and `sample_response` for every route above, plus the reason enum sourced from `REPORT_REASONS`.
|
||||
- **SEO** - legal pages are public and indexable, added to `routers/seo.py`'s sitemap; `/reports/*` and `/admin/moderation/*` are `noindex,nofollow` via `base_seo_context`.
|
||||
- **Audit** - new keys in `events.md` and `services/audit/categories.py` `category_for` under a new `moderation` category: `report.create`, `report.status`, `report.decide`, `moderation.suspend`, `moderation.ban`, `moderation.lift`, `moderation.remove`, `moderation.restore`, `filter.block`, `filter.review`, `account.delete.request`, `account.delete.purge`, `consent.grant`, `consent.withdraw`, `terms.accept`.
|
||||
- **README.md** gains the moderation, legal and account-deletion surfaces; the root `CLAUDE.md` gains one new architectural rule (§8.1 below); `services/moderation/CLAUDE.md` and `routers/CLAUDE.md` carry the detail.
|
||||
|
||||
---
|
||||
|
||||
## 8. The two things that are not code
|
||||
|
||||
### 8.1 The new architectural rule for the root `CLAUDE.md`
|
||||
|
||||
> **Every user-generated surface is reportable by construction.** A new content table added to `SOFT_DELETE_TABLES` that is visible to anyone other than its author MUST be registered in `database/moderation.py` `REPORTABLE_TARGETS`, MUST resolve in `resolve_object_url`, and MUST include `_report_button.html` in its action bar. The registry completeness test enforces the first two; the template coverage test enforces the third.
|
||||
|
||||
### 8.2 The positioning change
|
||||
|
||||
`applechanges.md` §5 established that four sites currently promise an uncensored platform, and that this alone is grounds for a 1.2 rejection. The design changes them in step so that marketing, terms and behaviour state the same thing:
|
||||
|
||||
| Site | Current | Proposed |
|
||||
|------|---------|----------|
|
||||
| `main.py:744` site description | "…in an open, uncensored environment." | "…in an open environment built by developers, for developers." |
|
||||
| `templates/base.html:9` meta description | same string | same replacement |
|
||||
| `templates/landing.html:120` hero | same string | same replacement |
|
||||
| `templates/landing.html:134` feature card | "No Censorship" | "No Gatekeeping" - with body copy stating that DevPlace does not editorialise technical opinion, and that a short list of prohibited categories is enforced, linking the community guidelines |
|
||||
| `database/schema.py:280` default `site_tagline` | same string | same replacement |
|
||||
|
||||
This is the one item in this document that changes the product's public voice rather than its capabilities. It is presented as a decision, not an assumption, and it is the single change with the highest effect on the outcome of review.
|
||||
|
||||
---
|
||||
|
||||
## 9. Sequencing
|
||||
|
||||
Six phases. Each phase is independently shippable, leaves the platform working, and ends with the full suite (`make test`, all three tiers) green. No phase depends on a later one.
|
||||
|
||||
| Phase | Contents | Requirements closed |
|
||||
|-------|----------|---------------------|
|
||||
| **1. Foundation** | `database/moderation.py` registry and constants; `content_reports`, `moderation_actions`, `content_maturity`, `user_consents` tables; `users` columns; `site_settings` keys; `SOFT_DELETE_TABLES` registration; `resolve_object_url` extension; the registry completeness test | substrate for R4-R8, R13-R16 |
|
||||
| **2. Reporting and moderation** | `services/moderation/` queue; `routers/reports.py`; `routers/admin/moderation.py`; enforcement routes; `_report_button.html` at all fourteen sites; `_report_dialog.html` + `ReportDialog.js`; `admin_moderation.html` + sidebar; SLA badge; audit keys; Devii actions; API docs | **R5, R6, R7, R8, R9, P1, P2, P3** |
|
||||
| **3. Legal and contact** | The seven docs pages; footer links; contact settings; the positioning rewording | **R1, R3, R10, R11, R18, R22** |
|
||||
| **4. Consent, terms, age** | Signup terms + date of birth; re-acceptance middleware; consent routes and privacy tab; the AI gateway consent gate; activity-recording consent and indicator | **R2, R13, R15, R16, C8, C9** |
|
||||
| **5. Deletion** | `routers/profile/delete.py`; the stamped cascade; `devplace accounts prune`; devRant re-point; the confirmation page | **R12** |
|
||||
| **6. Filter, maturity, index, posture** | `services/moderation/filter.py` at the five choke points; `content_maturity` + `_maturity_gate.html`; `/workspaces/index`; IPv6 verification; demo account; review notes; questionnaire and privacy-label answers | **R4, R14, R19, R20, R21, R23, R24, C5** |
|
||||
|
||||
Phases 2 and 3 together answer the guideline that actually rejects apps. Phase 5 answers the guideline that most often rejects them on the second attempt. Nothing is deferred to "later"; six phases is the whole scope.
|
||||
|
||||
---
|
||||
|
||||
## 10. Test plan
|
||||
|
||||
Following the tier rules in `tests/CLAUDE.md`: tier is decided by fixtures, path mirrors the URL for `api`/`e2e` and the module for `unit`.
|
||||
|
||||
**`tests/unit/database/moderation.py`**
|
||||
- The registry completeness invariant (§11.1) - the single most important test in this feature.
|
||||
- `REPORT_REASONS` keys are stable and cover every guideline category.
|
||||
- `resolve_object_url` returns a non-`/feed` URL for every registry entry.
|
||||
- Filter classification: property checks over the category rule set, asserting monotonicity of score against rule matches and that `verdict` never weakens as mode strengthens.
|
||||
- Age-band derivation across the full date domain, including leap days and the exact boundary.
|
||||
|
||||
**`tests/api/reports/*.py`**
|
||||
- Report every registry target type; assert one row, correct `owner_uid`, correct audit event.
|
||||
- Duplicate report from the same reporter updates rather than duplicates; from a different reporter creates a second row.
|
||||
- Guests are refused; suspended users are refused posting but permitted reporting and deletion.
|
||||
- `/reports/mine` shows outcomes; a reporter never sees another reporter's report.
|
||||
|
||||
**`tests/api/admin/moderation.py`**
|
||||
- Queue ordering is oldest-open-first; SLA badge flips at the configured hour.
|
||||
- Every `MODERATION_ACTIONS` decision writes a `moderation_actions` row, an audit row, and a notification.
|
||||
- The seniority guard blocks a junior admin actioning a senior one and audits `result="denied"`.
|
||||
|
||||
**`tests/api/profile/delete.py`**
|
||||
- Deletion requires the correct password; wrong password does not delete.
|
||||
- After deletion the account is unreachable, sessions are revoked, content is gone from every listing.
|
||||
- Restore within the grace window from `/admin/trash` restores the whole event under one stamp.
|
||||
- After the grace window `purge_event` removes every row and no personal data remains in any table.
|
||||
|
||||
**`tests/api/auth/terms.py`, `tests/api/profile/consent.py`**
|
||||
- Signup without acceptance or below the minimum age fails with a rendered message.
|
||||
- Bumping `terms_version` forces re-acceptance on the next mutating request and never on a read.
|
||||
- A gateway user-content call without `ai_third_party` consent is refused; with consent it proceeds; withdrawal takes effect immediately.
|
||||
|
||||
**`tests/e2e/`**
|
||||
- **Coverage test:** for each of the fourteen include sites, load the page and assert a report control is present and reachable. This is the test that keeps R5 true over time.
|
||||
- Report a post end to end through the dialog; confirm the toast, the notification and the queue row.
|
||||
- Block from a comment action bar; confirm the author's content disappears from the feed.
|
||||
- Delete an account through the UI and confirm the login no longer works.
|
||||
- The maturity interstitial hides labelled content and reveals it only on explicit opt-in.
|
||||
|
||||
**Rigorous verification (root `CLAUDE.md`, four-layer procedure).** Suspension state, consent state and the deletion cascade are all read-then-write mutations reachable from HTTP, Devii, the devRant API and the CLI, so the procedure applies in full and is not optional:
|
||||
|
||||
1. **Property checks** over the filter score function and the age-band function across their whole input domain.
|
||||
2. **Stateful fuzzing** of report → decide → suspend → lift → delete sequences against a temp DB, asserting after every action that a report never leaves its state machine, a suspension never outlives its expiry, consent history is never rewritten, and no user is ever both deleted and active.
|
||||
3. **Concurrency with real separate OS processes**: concurrent decisions on one report must produce exactly one `moderation_actions` row; concurrent deletion requests must produce exactly one cascade. Both are closed with a single atomic conditional `UPDATE … WHERE` at the chokepoint, checked through `db.executable.execute(text(...)).rowcount`, per the standing rule. **Every new column added in §3.5 is written at insert time for new rows and `COALESCE`d in every precondition and arithmetic update**, because a column absent from a row's original `INSERT` is SQL `NULL`, and `NULL = 0` is `NULL`, not true - the exact trap the root `CLAUDE.md` records.
|
||||
4. **`pyflakes` / `ruff check`** on every touched file, catching the in-function import that neither a clean compile nor a clean app import would.
|
||||
|
||||
---
|
||||
|
||||
## 11. Proof of solidity
|
||||
|
||||
`apple.md` asks for a mathematical proof that the implementation is solid. A design cannot be proved correct in the abstract; what can be proved is that **coverage is total and stays total**. Three claims, each discharged by a mechanism rather than by diligence.
|
||||
|
||||
### 11.1 Claim 1 - surface coverage is total, and remains total
|
||||
|
||||
Let `U` be the set of externally-visible user-generated surfaces, `R` the set of `REPORTABLE_TARGETS` keys, `T` the set of tables in `SOFT_DELETE_TABLES`, and `V ⊆ T` those visible beyond their author.
|
||||
|
||||
The design requires `V ⊆ R` and enforces it with a unit test that computes `V` from `SOFT_DELETE_TABLES` minus an explicit, reviewed exclusion list of owner-private tables, and asserts the inclusion. A developer adding a UGC table without registering it **fails the suite**.
|
||||
|
||||
Since the report route, the report partial, the Devii action enum, the API docs enum and the admin filter all derive from `R`, coverage of every consumer follows from `V ⊆ R` by construction. Requirement **R5** is therefore not "implemented on sixteen surfaces" but *closed under future additions* - which is the only form of this guarantee worth having, because 1.2 rejections happen on the surface someone forgot.
|
||||
|
||||
Formally: coverage is the composition `V ↪ R → {route, partial, action, docs, filter}`. The inclusion is test-enforced; the maps are total functions over `R`; therefore the composition is total over `V`. ∎
|
||||
|
||||
### 11.2 Claim 2 - every Apple requirement maps to a named artifact
|
||||
|
||||
The map `requirement → artifact` below is total over the mandatory register and over every triggered conditional. No requirement lacks an artifact; no artifact exists without a requirement.
|
||||
|
||||
| Req | Artifact | Phase |
|
||||
|-----|----------|-------|
|
||||
| R1 | `/docs/terms.html` + `terms_version` | 3 |
|
||||
| R2 | `SignupForm.accept_terms`, `users.terms_version`, re-acceptance middleware | 4 |
|
||||
| R3 | `/docs/community-guidelines.html` from `REPORT_REASONS` | 3 |
|
||||
| R4 | `services/moderation/filter.py` at five choke points | 6 |
|
||||
| R5 | `REPORTABLE_TARGETS` + `/reports/{target_type}/{target_uid}` + `_report_button.html` | 1, 2 |
|
||||
| R6 | `/admin/moderation` + `moderation_actions` | 2 |
|
||||
| R7 | `moderation_sla_hours` + the SLA badge + `/docs/content-moderation.html` | 2, 3 |
|
||||
| R8 | `/admin/users/{uid}/suspend`, `/ban`, `/lift` + `is_suspended` | 2 |
|
||||
| R9 | existing `routers/relations.py` + Block in `_report_button.html` | 2 |
|
||||
| R10 | `/docs/contact.html` from `contact_*` settings + footer | 3 |
|
||||
| R11 | `/docs/privacy.html` + footer + ASC metadata | 3 |
|
||||
| R12 | `routers/profile/delete.py` + stamped cascade + `devplace accounts prune` | 5 |
|
||||
| R13 | `SignupForm.birth_date` → `users.age_band` + `moderation_minimum_age` | 4 |
|
||||
| R14 | `content_maturity` + `_maturity_gate.html` + `mature_opt_in` | 6 |
|
||||
| R15 | `user_consents.ai_third_party` + the gateway gate | 4 |
|
||||
| R16 | `POST /profile/{username}/consent` + the privacy tab | 4 |
|
||||
| R17 | existing `notification_preferences` (verified, documented) | 6 |
|
||||
| R18 | `intellectual_property` reason + `/docs/intellectual-property.html` | 3 |
|
||||
| R19 | demo account + review notes | 6 |
|
||||
| R20 | questionnaire answered from R4/R5/R6/R13 | 6 |
|
||||
| R21 | privacy labels derived from R15's disclosure | 6 |
|
||||
| R22 | `contact_*` settings ≡ ASC trader data | 3 |
|
||||
| R23 | IPv6 verification of app, nginx, WebSockets, ingress | 6 |
|
||||
| R24 | architecture statement in docs + review notes | 6 |
|
||||
| R25 | every control exposed as JSON by A4 | 1-6 |
|
||||
| C4 | contest position documented | 3 |
|
||||
| C5 | `/workspaces/index` | 6 |
|
||||
| C8 | `activity_recording` consent + indicator | 4 |
|
||||
| C9 | per-instance consent before data reaches user software | 4 |
|
||||
| P1 | `moderation_actions` + SLA metrics | 2 |
|
||||
| P2 | audit `moderation` category + `moderation_actions` | 2 |
|
||||
| P3 | statement of reasons via `create_notification` | 2 |
|
||||
| P4 | privacy-label step added to the feature workflow | 6 |
|
||||
| P5 | release-notes discipline | 6 |
|
||||
|
||||
C1, C2, C3, C6 and C7 are untriggered and the design introduces nothing that triggers them: no social login, no payment path, no purchasable randomness, no advertising, no cross-app tracking. Keeping them untriggered is itself recorded as a constraint in the root `CLAUDE.md` rule of §8.1's neighbourhood.
|
||||
|
||||
### 11.3 Claim 3 - the design introduces no inconsistency
|
||||
|
||||
Consistency is checked against every convention the repository enforces:
|
||||
|
||||
| Convention | How this design satisfies it |
|
||||
|-----------|------------------------------|
|
||||
| Polymorphic `(target_type, target_uid)` | `content_reports`, `content_maturity` use it verbatim |
|
||||
| Registry over literal | `REPORTABLE_TARGETS` beside `VOTABLE_TARGETS` |
|
||||
| Soft delete everywhere, one stamp per cascade | All four new tables registered; deletion uses one stamp |
|
||||
| Runtime policy in `site_settings` | Eleven new keys, zero new constants |
|
||||
| Four faces per route | Every route has HTML, JSON, Devii action, API docs |
|
||||
| Shared `templates` instance, partial reuse | One partial, one dialog, fourteen includes |
|
||||
| ES6 module, one class per file, on `app` | `ReportDialog.js` |
|
||||
| Design tokens, no literals | Report and SLA styling uses existing tokens and `--z-*` bands |
|
||||
| No comments, no docstrings | The design specifies none |
|
||||
| Author attribution at the top of every file | Every new file |
|
||||
| European dates, UTC storage | `local_dt` / `dt_ago` for every timestamp shown |
|
||||
| Owner-or-admin, seniority guard | `is_owner`, `_is_senior_admin` reused unchanged |
|
||||
| `CONFIRM_REQUIRED` with a declared `confirm` param | Four gated Devii tools |
|
||||
| Batch helpers, never N+1 | `get_maturity_by_targets`, denormalised `owner_uid` |
|
||||
| Never fail silently | Filter fails to `review`; report submission never swallows |
|
||||
| No forbidden name patterns, no em-dash | Enforced at authoring and by `/validate` |
|
||||
|
||||
Zero new patterns are introduced. Every mechanism in this design is an existing DevPlace mechanism applied to a new target set. That is the sense in which it is DRY, and the sense in which it is consistent. ∎
|
||||
|
||||
---
|
||||
|
||||
## 12. Verification loop
|
||||
|
||||
`apple.md` asks that the former steps be repeated recursively until the result is proved solid. Three passes were run over `applecomp.md` → `applechanges.md` → this document. Each pass fed a correction back into the earlier documents, which are the corrected versions.
|
||||
|
||||
**Pass 1 - requirement completeness.** The first register covered guideline 1.2 and 5.1.1 only. Re-reading the guidelines against the platform's actual feature list added: 4.7 in full (the AI assistant is a chatbot under it, and it restates the 1.2 quartet), 2.5.2 and its educational exception (the container platform), 2.5.14 (presence and session recording), 4.7.4 (the software index), 2.5.5 (IPv6), 5.3 (Code Farm Eras), 6.1's 2025 age-rating overhaul and 6.4's DSA trader status. **Nine requirements were missing from the first draft.** They are R23, R24, C5, C8, C9, and the metadata requirements R20-R22, and the 5.3 position in C4.
|
||||
|
||||
**Pass 2 - surface completeness.** The first gap analysis listed eight UGC surfaces from the routers. Re-deriving the list from `SOFT_DELETE_TABLES` rather than from the routers produced **twenty**, including four that a router-first reading misses entirely: awards, poll options, quiz options and workspace-served content. That correction is what forced A1 and A2, and therefore the registry, and therefore the completeness invariant of §11.1. A per-surface design would have shipped incomplete.
|
||||
|
||||
**Pass 3 - consistency and caveat elimination.** Re-reading the design against the conventions produced five corrections, each removing a caveat rather than documenting one:
|
||||
|
||||
1. Maturity was originally a column on each content table - twenty migrations and a permanent drift risk. Replaced by the polymorphic `content_maturity` table with a batch helper, matching `reactions`.
|
||||
2. The filter was originally to be called from each router - twenty call sites. Replaced by five existing choke points in `content.py`, `messages.py` and the profile/signup path.
|
||||
3. AI consent was originally a per-feature toggle, which would have needed a gate in every AI consumer. Replaced by one gate at the gateway, with the existing toggles demoted to preferences - no existing preference is flipped and no consumer changes.
|
||||
4. Account deletion was originally an immediate hard purge, which conflicts with `/admin/trash`, with the audit trail, and with accidental loss. Replaced by an immediate anonymisation plus a stamped soft-delete event and a GC purge, which is both the compliant behaviour and the behaviour the codebase already has primitives for.
|
||||
5. Legal pages were originally new routes. Replaced by `DOCS_PAGES` entries, which brings role gating, SEO, the search index and the export for free, and adds no routing.
|
||||
|
||||
**Pass 4 - factual re-verification against the source tree.** Every file reference, line number and count asserted across all three documents was re-read from the source rather than trusted. Three errors were found and corrected in place:
|
||||
|
||||
1. `SOFT_DELETE_TABLES` was stated as 46 tables in `applechanges.md` §3 and in A5 above; the real count, computed from `database/soft_delete.py`, is **44**.
|
||||
2. `applechanges.md` §8's mandatory-requirement tally summed to 26 across 25 requirements, because R2 was counted as both missing and partial. Corrected to a true partition: 1 present, 5 partial, 15 missing, 2 blocked, 1 unverified, 1 out of scope.
|
||||
3. The conditional tally said "4 not triggered … (C1, C2, C3, C6, C7 - five, counting C7)". Corrected to 5 not triggered, 3 missing, 1 borderline.
|
||||
|
||||
Everything else verified exactly: `main.py:744`, `templates/base.html:9`, `landing.html:120` and `:134`, `schema.py:276`/`:280`/`:1823`, `soft_delete.py:7`, `ranking.py:11`, `reactions.py:17`, `content.py:197`/`:361`, `database/content.py:22`, `models.py:51`/`:408`, `admin/users.py:179`, `devrant/auth.py:189`, `messages.py:245`, `notifications.py:68`, `admin_base.html:11`-`59`, and the existence of all fourteen include-site templates plus `routers/profile/index.py`, `services/audit/categories.py`, `services/devii/actions/spec.py` and `docs_api/_shared.py`.
|
||||
|
||||
**Pass 5 - fixed point.** A fifth pass over all three documents produced no further correction: every mandatory requirement maps to an artifact (§11.2), every artifact maps to a requirement, every surface is covered by construction (§11.1), and every convention is satisfied (§11.3). The documents are consistent with each other and with the source tree as read. The loop has converged.
|
||||
|
||||
**The one open decision** deliberately left to the lord, because it is a product-voice decision and not a technical one, is §8.2: the rewording of the four "uncensored" sites. Everything else in this design is fully specified and requires no further input.
|
||||
|
||||
---
|
||||
|
||||
## 13. What approval authorises
|
||||
|
||||
Approving this document authorises implementation of phases 1 through 6 in §9, in order, each phase validated with `python -c "from devplacepy.main import app"`, per-language manual checks, `ruff check` / `pyflakes` on every touched file, the four-layer rigorous verification of §10 where it applies, and the **full test suite (`make test`, all three tiers, every test) green before the phase is considered done**.
|
||||
|
||||
Documentation updated in step: `README.md`, the root `CLAUDE.md` (one new rule, §8.1), `devplacepy/routers/CLAUDE.md`, a new `devplacepy/services/moderation/CLAUDE.md`, `devplacepy/database/CLAUDE.md`, `devplacepy/templates/CLAUDE.md`, `events.md`, and the seven new docs pages.
|
||||
@@ -10,7 +10,7 @@ from urllib.parse import urlparse
|
||||
from PIL import Image
|
||||
from io import BytesIO
|
||||
import httpx
|
||||
from devplacepy import stealth
|
||||
from devplacepy.net_guard import BlockedAddressError, guarded_async_client
|
||||
from devplacepy.database import get_table, db, get_setting
|
||||
from devplacepy.config import UPLOADS_DIR, ATTACHMENTS_DIR
|
||||
from devplacepy.utils import generate_uid
|
||||
@@ -389,7 +389,7 @@ async def fetch_remote_file(url, filename=None):
|
||||
await _guard_public_url(url)
|
||||
max_bytes = _get_max_upload_bytes()
|
||||
try:
|
||||
async with stealth.stealth_async_client(
|
||||
async with guarded_async_client(
|
||||
follow_redirects=True,
|
||||
timeout=REMOTE_FETCH_TIMEOUT,
|
||||
headers={"User-Agent": REMOTE_FETCH_USER_AGENT},
|
||||
@@ -412,6 +412,8 @@ async def fetch_remote_file(url, filename=None):
|
||||
413,
|
||||
)
|
||||
data = b"".join(chunks)
|
||||
except BlockedAddressError as exc:
|
||||
raise RemoteFetchError(str(exc), 400) from exc
|
||||
except httpx.HTTPError as exc:
|
||||
raise RemoteFetchError(f"Could not fetch {url}: {exc}", 400) from exc
|
||||
|
||||
@@ -453,6 +455,33 @@ def link_attachments(uids, target_type, target_uid):
|
||||
)
|
||||
|
||||
|
||||
def split_attachment_uids(raw):
|
||||
return [
|
||||
uid.strip() for item in raw or [] for uid in str(item).split(",") if uid.strip()
|
||||
]
|
||||
|
||||
|
||||
def get_orphan_attachments_batch(uids, user, admin=False):
|
||||
if not uids:
|
||||
return []
|
||||
placeholders = ",".join(f":p{i}" for i in range(len(uids)))
|
||||
params = {f"p{i}": uid for i, uid in enumerate(uids)}
|
||||
rows = db.query(
|
||||
f"SELECT * FROM attachments WHERE uid IN ({placeholders}) AND deleted_at IS NULL",
|
||||
**params,
|
||||
)
|
||||
by_uid = {row["uid"]: row for row in rows}
|
||||
owned = []
|
||||
for uid in uids:
|
||||
row = by_uid.get(uid)
|
||||
if not row or row.get("target_uid"):
|
||||
continue
|
||||
if row.get("user_uid") and row["user_uid"] != user["uid"] and not admin:
|
||||
continue
|
||||
owned.append(uid)
|
||||
return owned
|
||||
|
||||
|
||||
def set_gitea_asset_id(uid, asset_id):
|
||||
get_table("attachments").update(
|
||||
{"uid": uid, "gitea_asset_id": int(asset_id)}, ["uid"]
|
||||
@@ -472,7 +501,7 @@ async def mirror_attachment_to_gitea(uid):
|
||||
return None
|
||||
path = ATTACHMENTS_DIR / row.get("directory", "") / row.get("stored_name", "")
|
||||
try:
|
||||
data = path.read_bytes()
|
||||
data = await asyncio.to_thread(path.read_bytes)
|
||||
except OSError as exc:
|
||||
logger.warning("Cannot read attachment %s for Gitea mirror: %s", uid, exc)
|
||||
return None
|
||||
@@ -516,6 +545,28 @@ async def remove_gitea_asset(row):
|
||||
logger.warning("Gitea asset delete failed for %s: %s", row.get("uid"), exc)
|
||||
|
||||
|
||||
_pending_gitea_tasks: set[asyncio.Task] = set()
|
||||
|
||||
|
||||
def _fire_and_forget(coro) -> None:
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
except RuntimeError:
|
||||
coro.close()
|
||||
return
|
||||
task = loop.create_task(coro)
|
||||
_pending_gitea_tasks.add(task)
|
||||
task.add_done_callback(_pending_gitea_tasks.discard)
|
||||
|
||||
|
||||
def schedule_gitea_mirror(uid: str) -> None:
|
||||
_fire_and_forget(mirror_attachment_to_gitea(uid))
|
||||
|
||||
|
||||
def schedule_gitea_removal(row: dict) -> None:
|
||||
_fire_and_forget(remove_gitea_asset(row))
|
||||
|
||||
|
||||
def _unlink_attachment_files(row):
|
||||
stored_name = row.get("stored_name", "")
|
||||
directory = row.get("directory", "")
|
||||
|
||||
+19
-2
@@ -1,6 +1,7 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import time
|
||||
import tomllib
|
||||
from pathlib import Path
|
||||
from dotenv import load_dotenv
|
||||
from os import environ
|
||||
@@ -22,6 +23,15 @@ ZIP_STAGING_DIR = DATA_DIR / "zip_staging"
|
||||
FORK_STAGING_DIR = DATA_DIR / "fork_staging"
|
||||
BACKUPS_DIR = DATA_DIR / "backups"
|
||||
BACKUP_STAGING_DIR = DATA_DIR / "backup_staging"
|
||||
|
||||
RCLONE_BIN = environ.get("DEVPLACE_RCLONE_BIN", "rclone")
|
||||
RCLONE_CONFIG_FILE = environ.get(
|
||||
"DEVPLACE_RCLONE_CONFIG",
|
||||
str(Path(environ.get("HOME", "/root")) / ".config" / "rclone" / "rclone.conf"),
|
||||
)
|
||||
BACKUP_OFFLOAD_REMOTE = environ.get(
|
||||
"DEVPLACE_BACKUP_OFFLOAD_REMOTE", "storagebox:devplacepy-backups"
|
||||
)
|
||||
SEO_REPORTS_DIR = DATA_DIR / "seo_reports"
|
||||
PLANNING_REPORTS_DIR = DATA_DIR / "planning_reports"
|
||||
DBAPI_DIR = DATA_DIR / "dbapi"
|
||||
@@ -45,7 +55,7 @@ SECRET_KEY = environ.get("SECRET_KEY", "devplace-secret-key-change-in-production
|
||||
SECONDS_PER_DAY = 86400
|
||||
SESSION_MAX_AGE = SECONDS_PER_DAY * 7
|
||||
SESSION_MAX_AGE_REMEMBER = SECONDS_PER_DAY * 30
|
||||
PORT = 10500
|
||||
PORT = int(environ.get("DEVPLACE_PORT", "10500"))
|
||||
SITE_URL = environ.get("DEVPLACE_SITE_URL", "").rstrip("/")
|
||||
|
||||
PRESENCE_TIMEOUT_SECONDS = int(environ.get("DEVPLACE_PRESENCE_TIMEOUT_SECONDS", "60"))
|
||||
@@ -59,7 +69,9 @@ PRESENCE_ONLINE_MARGIN_SECONDS = int(
|
||||
XMLRPC_BIND = environ.get("DEVPLACE_XMLRPC_BIND", "127.0.0.1")
|
||||
XMLRPC_PORT = int(environ.get("DEVPLACE_XMLRPC_PORT", "10550"))
|
||||
|
||||
STATIC_VERSION = environ.get("DEVPLACE_STATIC_VERSION") or str(int(time.time()))
|
||||
APP_VERSION = tomllib.loads((BASE_DIR / "pyproject.toml").read_text())["project"]["version"]
|
||||
BOOT_ID = environ.get("DEVPLACE_STATIC_VERSION") or str(int(time.time()))
|
||||
STATIC_VERSION = f"{APP_VERSION}-{BOOT_ID}"
|
||||
|
||||
TEMPLATE_AUTO_RELOAD = environ.get("DEVPLACE_TEMPLATE_AUTO_RELOAD", "1") != "0"
|
||||
|
||||
@@ -72,6 +84,9 @@ INTERNAL_MODEL = "molodetz"
|
||||
INTERNAL_EMBED_MODEL = "molodetz~embed"
|
||||
INTERNAL_IMAGE_MODEL = "molodetz-img-small"
|
||||
|
||||
AQUALITY_NEWS_GRADING_URL = "https://aquality.cloud.pravda.education/v1/chat/completions"
|
||||
AQUALITY_NEWS_GRADING_MODEL = "aquality"
|
||||
|
||||
AWARD_GIVE_COOLDOWN_HOURS_DEFAULT = 24
|
||||
AWARD_RECEIVE_COOLDOWN_HOURS_DEFAULT = 24
|
||||
AWARD_DISPLAY_HOURS_DEFAULT = 24
|
||||
@@ -97,6 +112,8 @@ QUIZ_SCOREBOARD_LIMIT = 20
|
||||
QUIZ_SCOREBOARD_CACHE_SECONDS = 15
|
||||
QUIZ_LIST_PER_PAGE = 20
|
||||
|
||||
BATTLES_LIST_PER_PAGE = 10
|
||||
|
||||
DEFAULT_CORRECTION_PROMPT = "Leave literary as is, only do punctuation and casing"
|
||||
DEFAULT_MODIFIER_PROMPT = (
|
||||
"Execute what is behind `@ai` (the prompt) and replace that part including `@ai`"
|
||||
|
||||
@@ -2,6 +2,16 @@
|
||||
|
||||
TOPICS = ["devlog", "showcase", "question", "rant", "fun", "random", "politics"]
|
||||
|
||||
TOPIC_LABELS = {
|
||||
"devlog": "Devlog",
|
||||
"showcase": "Showcase",
|
||||
"question": "Question",
|
||||
"rant": "Rant",
|
||||
"fun": "Fun",
|
||||
"random": "Random",
|
||||
"politics": "Politics",
|
||||
}
|
||||
|
||||
REACTION_EMOJI = [
|
||||
"\U0001f44d",
|
||||
"❤️",
|
||||
|
||||
@@ -46,6 +46,7 @@ from devplacepy.utils import (
|
||||
award_rewards,
|
||||
track_action,
|
||||
create_notification,
|
||||
create_thread_notifications,
|
||||
create_mention_notifications,
|
||||
is_admin,
|
||||
is_primary_admin,
|
||||
@@ -53,6 +54,7 @@ from devplacepy.utils import (
|
||||
XP_UPVOTE,
|
||||
)
|
||||
from devplacepy.services.audit import record as audit
|
||||
from devplacepy.services.opinionwar import store as war_store
|
||||
from devplacepy.services.correction import schedule_correction
|
||||
from devplacepy.services.ai_modifier import schedule_modification
|
||||
from devplacepy.services.seo_meta import schedule_seo_meta_for_table
|
||||
@@ -437,6 +439,7 @@ def create_comment_record(
|
||||
comment_url = f"{redirect_url}#comment-{comment_uid}"
|
||||
|
||||
if target_type == "post":
|
||||
already_notified = {user["uid"]}
|
||||
if parent_uid:
|
||||
parent = get_table("comments").find_one(uid=parent_uid, deleted_at=None)
|
||||
if parent and parent["user_uid"] != user["uid"]:
|
||||
@@ -447,6 +450,7 @@ def create_comment_record(
|
||||
user["uid"],
|
||||
comment_url,
|
||||
)
|
||||
already_notified.add(parent["user_uid"])
|
||||
else:
|
||||
posts = get_table("posts")
|
||||
post = posts.find_one(uid=target_uid)
|
||||
@@ -460,6 +464,9 @@ def create_comment_record(
|
||||
user["uid"],
|
||||
comment_url,
|
||||
)
|
||||
already_notified.add(post["user_uid"])
|
||||
|
||||
create_thread_notifications(target_uid, user["uid"], comment_url, already_notified)
|
||||
|
||||
create_mention_notifications(content, user["uid"], comment_url)
|
||||
record_screening(
|
||||
@@ -627,6 +634,7 @@ def detail_context(
|
||||
"reactions": detail.get("reactions", {"counts": {}, "mine": []}),
|
||||
"bookmarked": detail.get("bookmarked", False),
|
||||
"poll": detail.get("poll"),
|
||||
"war": detail.get("war"),
|
||||
"project_link": detail.get("project_link"),
|
||||
"maturity": detail.get("maturity", "general"),
|
||||
}
|
||||
@@ -817,6 +825,9 @@ def load_detail(
|
||||
"reactions": reactions,
|
||||
"bookmarked": bookmarked,
|
||||
"poll": get_poll_for_post(item["uid"], user) if target_type == "post" else None,
|
||||
"war": war_store.get_war_serialized_for_post(item["uid"], user)
|
||||
if target_type == "post"
|
||||
else None,
|
||||
"project_link": get_project_by_uid(item.get("project_uid")) if target_type == "post" else None,
|
||||
"maturity": get_maturity(target_type, item["uid"])["level"],
|
||||
}
|
||||
@@ -854,6 +865,10 @@ def enrich_items(
|
||||
return enriched
|
||||
|
||||
|
||||
def count_project_devlog(project_uid: str) -> int:
|
||||
return get_table("posts").count(project_uid=project_uid, deleted_at=None)
|
||||
|
||||
|
||||
def get_project_devlog(
|
||||
project_uid: str, before: str | None = None, viewer: dict | None = None
|
||||
) -> tuple[list, str | None]:
|
||||
|
||||
@@ -12,7 +12,9 @@ PRAGMA cache_size=-8000; -- 8MB page cache
|
||||
PRAGMA temp_store=MEMORY; -- temp tables in memory
|
||||
```
|
||||
|
||||
Configured via `dataset.connect(engine_kwargs={"connect_args": {"timeout": 30, "check_same_thread": False}}, on_connect_statements=[...])`. In addition to the pragmas above, the connection is tuned with a 30s busy timeout, an 8MB page cache, and a 256MB mmap.
|
||||
Configured via `dataset.connect(engine_kwargs={"connect_args": {"timeout": 30, "check_same_thread": False}, "poolclass": NullPool}, on_connect_statements=[...])`. In addition to the pragmas above, the connection is tuned with a 30s busy timeout, an 8MB page cache, and a 256MB mmap.
|
||||
|
||||
**`poolclass=NullPool` is load-bearing - never revert it to SQLAlchemy's default `QueuePool` (caused a production outage).** `dataset.Database.executable` caches ONE DBAPI connection per OS thread ID **forever** and never returns it to the pool except via `db.close()`, which nothing in this codebase calls (`dataset/database.py`: `self.connections[tid] = self.engine.connect()`). That is fine as long as the same handful of threads ever touch the DB - but FastAPI runs every sync route dependency (`get_setting` and friends, hit on nearly every request) through `anyio.to_thread.run_sync`, whose worker pool scales up and recycles threads elastically under load, and container sync (`asyncio.to_thread`) adds more. Each new thread's first query permanently claims one pool slot. With the default bounded `QueuePool` (`pool_size=5, max_overflow=10` = 15 total), a burst of concurrent load creates enough new threads that the pool fills for good within minutes, and every request thereafter - including the Docker healthcheck's own probe - blocks the full 30s pool timeout and then raises `sqlalchemy.exc.TimeoutError: QueuePool limit of size 5 overflow 10 reached`, wedging the whole app (nginx waits on an app that is waiting on itself; every external caller sees a bare connection timeout, not an HTTP error). `NullPool` removes the artificial ceiling: each `engine.connect()` opens a real, unpooled SQLite connection, so the existing "one connection cached per thread forever" behavior just works, exactly as WAL mode is designed to support. Never pass `pool_size`/`max_overflow` alongside `NullPool` (SQLAlchemy rejects them). Do not "fix" the underlying thread churn instead - that means touching the sync-dependency/threadpool model, which the hard rule below forbids.
|
||||
|
||||
`init_db()` is idempotent - every index is created via `CREATE INDEX IF NOT EXISTS` (through the `_index()` helper, wrapped in try/except so it is safe to run on every startup regardless of table state), and seed defaults for `site_settings` only insert when the key doesn't already exist.
|
||||
|
||||
@@ -196,8 +198,8 @@ Site settings are seeded on startup (`site_settings` table):
|
||||
| `site_name` / `site_description` / `site_tagline` | DevPlace branding | General site metadata |
|
||||
| `news_grade_threshold` | `"7"` | Minimum AI grade for auto-publish |
|
||||
| `news_api_url` | `"https://news.app.molodetz.nl/api"` | News source API |
|
||||
| `news_ai_url` | `"https://openai.app.molodetz.nl/v1/chat/completions"` | AI grading endpoint |
|
||||
| `news_ai_model` | `"molodetz"` | AI model identifier |
|
||||
| `news_ai_url` | `"https://aquality.cloud.pravda.education/v1/chat/completions"` | AI grading endpoint - the free, local aquality quality model by default (see `devplacepy/services/news/CLAUDE.md`) |
|
||||
| `news_ai_model` | `"aquality"` | AI model identifier |
|
||||
| `max_upload_size_mb` / `allowed_file_types` / `max_attachments_per_resource` | `"10"` / `""` / `"10"` | Upload limits |
|
||||
| `rate_limit_per_minute` | `"60"` | Mutating requests per IP per window (`main.py` middleware); a `429` carries a `Retry-After: <window>` header |
|
||||
| `rate_limit_window_seconds` | `"60"` | Rolling window for the rate limit (also the `Retry-After` value) |
|
||||
@@ -216,11 +218,12 @@ Site settings are seeded on startup (`site_settings` table):
|
||||
| `moderation_mature_default_hidden` | `"1"` | Hide mature-labelled content behind an interstitial by default |
|
||||
| `account_deletion_grace_hours` | `"24"` | Reversible window before a deleted account is purged |
|
||||
| `contact_email` / `contact_phone` / `contact_address` | `""` | Published contact details, rendered on `/docs/contact.html` |
|
||||
| `ios_app_url` | `"https://apps.apple.com/app/devplace/id6797215143"` | Official iOS app listing; the App Store badges in the footer, topnav and mobile menu link here and hide when the value is empty |
|
||||
| `terms_version` / `privacy_version` / `guidelines_version` | `"1"` | Bumping `terms_version` forces re-acceptance before the next write. **Every reader uses `get_setting(key, "1") or "1"`** - an empty stored value must read as the default or the gate 403s every write |
|
||||
| `ai_third_party_provider` | `""` | Named in the consent copy and the privacy policy |
|
||||
| `extra_head` | `""` | Raw HTML emitted verbatim into every page `<head>` by `templating.extra_head_tag()`; site-wide trusted-admin input, not sanitized |
|
||||
|
||||
Besides the site/news/upload keys above, the **Operational** group is admin-editable at `/admin/settings`: `site_url` (public origin for absolute links incl. container ingress; resolved by `seo.public_base_url()` = setting -> `DEVPLACE_SITE_URL` env -> request origin), `rate_limit_per_minute`, `rate_limit_window_seconds`, `news_service_interval`, `session_max_age_days`, `session_remember_days`, `registration_open`, `maintenance_mode`, `maintenance_message`, `docs_search_mode` (`agent`|`bm25`, default `agent` - picks the `/docs/search.html` surface: the in-page Devii chat or the classic BM25 list; a viewer over their daily AI limit, or a guest when Devii is disabled, auto-falls-back to BM25 via `routers/docs/views.py` `_agent_search_state`), `outbound_proxy_url` (empty by default - when set, every `stealth.stealth_async_client`/`stealth_sync_client` call across the whole app routes through it via `stealth.configured_proxy_url()`; validated as `http(s)://`/`socks5(h)://` with a host in `AdminSettingsForm`; falls back to `DEVPLACE_OUTBOUND_PROXY_URL` when unset - see the "Outbound HTTP" note in the root `CLAUDE.md`). The **Custom Code** key `extra_head` is the sole key in the settings handler's `CLEARABLE_SETTINGS` set, so saving an empty textarea removes it (the default loop skips empty values).
|
||||
Besides the site/news/upload keys above, the **Operational** group is admin-editable at `/admin/settings`: `site_url` (public origin for absolute links incl. container ingress; resolved by `seo.public_base_url()` = setting -> `DEVPLACE_SITE_URL` env -> request origin), `rate_limit_per_minute`, `rate_limit_window_seconds`, `news_service_interval`, `session_max_age_days`, `session_remember_days`, `registration_open`, `maintenance_mode`, `maintenance_message`, `docs_search_mode` (`agent`|`bm25`, default `agent` - picks the `/docs/search.html` surface: the in-page Devii chat or the classic BM25 list; a viewer over their daily AI limit, or a guest when Devii is disabled, auto-falls-back to BM25 via `routers/docs/views.py` `_agent_search_state`), `outbound_proxy_url` (empty by default - when set, every `stealth.stealth_async_client`/`stealth_sync_client` call across the whole app routes through it via `stealth.configured_proxy_url()`; validated as `http(s)://`/`socks5(h)://` with a host in `AdminSettingsForm`; falls back to `DEVPLACE_OUTBOUND_PROXY_URL` when unset - see the "Outbound HTTP" note in the root `CLAUDE.md`). The **Custom Code** key `extra_head` and the `ios_app_url` badge link are the keys in the settings handler's `CLEARABLE_SETTINGS` set, so saving an empty textarea/field removes them (the default loop skips empty values).
|
||||
|
||||
The seed block in `database.py` is guarded by `if "site_settings" in tables:` - on a brand-new DB the table does not exist yet (dataset creates tables lazily on first insert), so none of these rows are written until the table exists. Correct runtime behavior therefore relies on every consumer passing the production default to `get_setting`/`get_int_setting`, not on the seed.
|
||||
|
||||
@@ -238,3 +241,5 @@ Operational settings - read sites and rules:
|
||||
| `registration_open` | `auth.py` `signup_page` (GET) and `signup` (POST) | POST returns before any DB write when closed |
|
||||
|
||||
**Booleans are `<select>`, never checkboxes.** The settings save handler (`admin.py`) skips empty form values so empty fields don't clobber existing rows. An unchecked checkbox submits nothing, so it could never be turned off - `registration_open` and `maintenance_mode` use `<option value="1">`/`<option value="0">` so a value is always submitted.
|
||||
|
||||
|
||||
|
||||
@@ -74,7 +74,7 @@ from .moderation import (
|
||||
years_between,
|
||||
)
|
||||
from .comments import _drop_blocked, _build_comment_items, load_comments, get_recent_comments_by_target_uids, get_recent_comments_by_post_uids, load_comments_by_target_uids
|
||||
from .content import resolve_by_slug, resolve_object_url, get_uids_by_username_match, text_search_clause, get_daily_topic, get_featured_news, get_trending_topics
|
||||
from .content import resolve_by_slug, resolve_object_url, get_projects_by_uids, get_uids_by_username_match, text_search_clause, get_daily_topic, get_featured_news, get_trending_topics
|
||||
from .attachments_data import get_attachments, get_attachments_by_type, get_news_images_by_uids, delete_attachment_record, delete_attachments, _delete_attachment_file, get_user_media, get_user_attachments, get_user_attachment, get_deleted_media
|
||||
from .stats import _stats_cache, get_site_stats, _analytics_cache, get_platform_analytics, _gist_languages_cache, get_gist_languages
|
||||
from .schema import BUG_TABLE_RENAMES, migrate_bug_tables_to_issue_tables, init_db, _refresh_query_planner_stats, OLD_GATEWAY_URL, migrate_ai_gateway_settings, backfill_api_keys, _backfill_gamification
|
||||
@@ -296,6 +296,7 @@ __all__ = [
|
||||
"load_comments_by_target_uids",
|
||||
"resolve_by_slug",
|
||||
"resolve_object_url",
|
||||
"get_projects_by_uids",
|
||||
"get_uids_by_username_match",
|
||||
"text_search_clause",
|
||||
"get_daily_topic",
|
||||
|
||||
@@ -89,6 +89,11 @@ def resolve_object_url(target_type: str, target_uid: str) -> str:
|
||||
if not poll:
|
||||
return "/feed"
|
||||
return resolve_object_url("post", poll.get("post_uid", ""))
|
||||
if target_type == "battle":
|
||||
war = get_table("opinion_wars").find_one(uid=target_uid)
|
||||
if not war:
|
||||
return "/battles"
|
||||
return resolve_object_url("post", war.get("post_uid", ""))
|
||||
if target_type == "workspace":
|
||||
instance = get_table("instances").find_one(uid=target_uid)
|
||||
return f"/admin/containers/{instance['uid']}" if instance else "/admin/containers"
|
||||
@@ -97,6 +102,17 @@ def resolve_object_url(target_type: str, target_uid: str) -> str:
|
||||
return "/feed"
|
||||
|
||||
|
||||
def get_projects_by_uids(uids):
|
||||
if not uids or "projects" not in db.tables:
|
||||
return {}
|
||||
projects = get_table("projects")
|
||||
if "uid" not in projects.columns:
|
||||
return {}
|
||||
seen = set()
|
||||
unique = [u for u in uids if u not in seen and not seen.add(u)]
|
||||
return {p["uid"]: p for p in projects.find(projects.table.columns.uid.in_(unique))}
|
||||
|
||||
|
||||
def get_uids_by_username_match(search, limit=200):
|
||||
term = (search or "").strip()
|
||||
if not term or "users" not in db.tables:
|
||||
|
||||
@@ -4,10 +4,13 @@ import dataset
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from sqlalchemy import or_
|
||||
from sqlalchemy.pool import NullPool
|
||||
from collections import defaultdict
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from devplacepy.cache import TTLCache
|
||||
from devplacepy.config import (
|
||||
AQUALITY_NEWS_GRADING_MODEL,
|
||||
AQUALITY_NEWS_GRADING_URL,
|
||||
DATABASE_URL,
|
||||
DEFAULT_CORRECTION_PROMPT,
|
||||
DEFAULT_MODIFIER_PROMPT,
|
||||
@@ -30,6 +33,7 @@ db = dataset.connect(
|
||||
"timeout": 30,
|
||||
"check_same_thread": False,
|
||||
},
|
||||
"poolclass": NullPool,
|
||||
},
|
||||
on_connect_statements=[
|
||||
"PRAGMA journal_mode=WAL",
|
||||
|
||||
@@ -17,6 +17,7 @@ REPORTABLE_TARGETS: dict[str, str] = {
|
||||
"message": "messages",
|
||||
"quiz": "quizzes",
|
||||
"poll": "polls",
|
||||
"battle": "opinion_wars",
|
||||
"award": "awards",
|
||||
"user": "users",
|
||||
"issue": "issue_tickets",
|
||||
@@ -48,6 +49,8 @@ UNREPORTABLE_TABLES: dict[str, str] = {
|
||||
"bookmarks": "private to the owner",
|
||||
"follows": "relationship rows, carry no authored content",
|
||||
"poll_votes": "private ballots",
|
||||
"opinion_war_fighters": "membership and damage counters, carry no authored content",
|
||||
"opinion_war_events": "server-composed battle log rows, not authored content",
|
||||
"quiz_attempts": "private to the participant",
|
||||
"quiz_answers": "private to the participant",
|
||||
"sessions": "authentication state",
|
||||
@@ -65,6 +68,10 @@ UNREPORTABLE_TABLES: dict[str, str] = {
|
||||
"email_accounts": "private mailbox credentials",
|
||||
"instance_schedules": "child rows of a reportable workspace instance",
|
||||
"workspace_flags": "moderation records, not authored content",
|
||||
"workspace_quota_rules": "administrator-set limits, not authored content",
|
||||
"workspace_editor_prefs": (
|
||||
"private per-user editor configuration, never shown to another member"
|
||||
),
|
||||
"content_reports": "moderation records, readable only by the reporter and moderators",
|
||||
"moderation_actions": "moderation records, not authored content",
|
||||
"content_maturity": "moderation labels, not authored content",
|
||||
|
||||
@@ -8,6 +8,7 @@ from .soft_delete import soft_delete
|
||||
NOTIFICATION_TYPES = [
|
||||
{"key": "comment", "label": "Comments", "description": "Someone comments on your post"},
|
||||
{"key": "reply", "label": "Replies", "description": "Someone replies to your comment"},
|
||||
{"key": "thread", "label": "Thread activity", "description": "Someone else comments on a post you've commented on"},
|
||||
{"key": "mention", "label": "Mentions", "description": "Someone mentions you with @username"},
|
||||
{"key": "vote", "label": "Upvotes", "description": "Someone ++'d your content"},
|
||||
{"key": "follow", "label": "Followers", "description": "Someone starts following you"},
|
||||
@@ -19,6 +20,7 @@ NOTIFICATION_TYPES = [
|
||||
{"key": "harvest_stolen", "label": "Farm raids", "description": "Someone steals a ready build from your Code Farm"},
|
||||
{"key": "award", "label": "Awards", "description": "Someone gives you an award on your profile"},
|
||||
{"key": "quiz_attempt", "label": "Quiz attempts", "description": "Someone completes one of your quizzes"},
|
||||
{"key": "battle", "label": "Opinion Wars", "description": "Lead changes, results and fight-ready alerts for battles you joined"},
|
||||
{"key": "workspace", "label": "Dev workspaces", "description": "Idle, quota, retention and moderation notices for your dev workspaces"},
|
||||
{"key": "moderation", "label": "Moderation", "description": "Acknowledgements for reports you file and decisions taken on your content"},
|
||||
{"key": "system", "label": "System alerts", "description": "Platform infrastructure alerts (e.g. the AI gateway going down)"},
|
||||
|
||||
@@ -4,7 +4,7 @@ import os
|
||||
|
||||
from .core import TTLCache, _in_clause, _now_iso, db, get_table
|
||||
from .users import get_users_by_uids
|
||||
from .soft_delete import soft_delete, soft_delete_in
|
||||
from .soft_delete import soft_delete_in
|
||||
|
||||
|
||||
VOTABLE_TARGETS: dict[str, str] = {
|
||||
@@ -133,6 +133,26 @@ def update_target_stars(target_type: str, target_uid: str, net_stars: int) -> No
|
||||
get_table(table_name).update({"uid": target_uid, "stars": net_stars}, ["uid"])
|
||||
|
||||
|
||||
def _child_uids(table_name, parent_column, parent_uids, live_only=False):
|
||||
if table_name not in db.tables:
|
||||
return []
|
||||
table = db[table_name]
|
||||
if parent_column not in table.columns:
|
||||
return []
|
||||
clause = table.table.columns[parent_column].in_(parent_uids)
|
||||
if live_only:
|
||||
return [row["uid"] for row in table.find(clause, deleted_at=None)]
|
||||
return [row["uid"] for row in table.find(clause)]
|
||||
|
||||
|
||||
def _delete_in(table_name, column, uids):
|
||||
placeholders, params = _in_clause(uids)
|
||||
with db:
|
||||
db.query(
|
||||
f"DELETE FROM {table_name} WHERE {column} IN ({placeholders})", **params
|
||||
)
|
||||
|
||||
|
||||
def soft_delete_engagement(target_type: str, target_uids: list, deleted_by: str) -> None:
|
||||
uids = [uid for uid in (target_uids or []) if uid]
|
||||
if not uids:
|
||||
@@ -145,11 +165,15 @@ def soft_delete_engagement(target_type: str, target_uids: list, deleted_by: str)
|
||||
"bookmarks", "target_uid", uids, deleted_by, stamp=stamp, target_type=target_type
|
||||
)
|
||||
if target_type == "post" and "polls" in db.tables:
|
||||
for uid in uids:
|
||||
for poll in db["polls"].find(post_uid=uid, deleted_at=None):
|
||||
soft_delete("poll_votes", deleted_by, stamp=stamp, poll_uid=poll["uid"])
|
||||
soft_delete("poll_options", deleted_by, stamp=stamp, poll_uid=poll["uid"])
|
||||
soft_delete("polls", deleted_by, stamp=stamp, post_uid=uid)
|
||||
poll_uids = _child_uids("polls", "post_uid", uids, live_only=True)
|
||||
soft_delete_in("poll_votes", "poll_uid", poll_uids, deleted_by, stamp=stamp)
|
||||
soft_delete_in("poll_options", "poll_uid", poll_uids, deleted_by, stamp=stamp)
|
||||
soft_delete_in("polls", "post_uid", uids, deleted_by, stamp=stamp)
|
||||
if target_type == "post" and "opinion_wars" in db.tables:
|
||||
war_uids = _child_uids("opinion_wars", "post_uid", uids, live_only=True)
|
||||
soft_delete_in("opinion_war_fighters", "war_uid", war_uids, deleted_by, stamp=stamp)
|
||||
soft_delete_in("opinion_war_events", "war_uid", war_uids, deleted_by, stamp=stamp)
|
||||
soft_delete_in("opinion_wars", "post_uid", uids, deleted_by, stamp=stamp)
|
||||
|
||||
|
||||
def delete_engagement(target_type: str, target_uids: list) -> None:
|
||||
@@ -174,13 +198,21 @@ def delete_engagement(target_type: str, target_uids: list) -> None:
|
||||
**params,
|
||||
)
|
||||
if target_type == "post" and "polls" in tables:
|
||||
for uid in uids:
|
||||
for poll in db["polls"].find(post_uid=uid):
|
||||
if "poll_votes" in tables:
|
||||
db["poll_votes"].delete(poll_uid=poll["uid"])
|
||||
if "poll_options" in tables:
|
||||
db["poll_options"].delete(poll_uid=poll["uid"])
|
||||
db["polls"].delete(post_uid=uid)
|
||||
poll_uids = _child_uids("polls", "post_uid", uids)
|
||||
if poll_uids:
|
||||
if "poll_votes" in tables:
|
||||
_delete_in("poll_votes", "poll_uid", poll_uids)
|
||||
if "poll_options" in tables:
|
||||
_delete_in("poll_options", "poll_uid", poll_uids)
|
||||
_delete_in("polls", "post_uid", uids)
|
||||
if target_type == "post" and "opinion_wars" in tables:
|
||||
war_uids = _child_uids("opinion_wars", "post_uid", uids)
|
||||
if war_uids:
|
||||
if "opinion_war_fighters" in tables:
|
||||
_delete_in("opinion_war_fighters", "war_uid", war_uids)
|
||||
if "opinion_war_events" in tables:
|
||||
_delete_in("opinion_war_events", "war_uid", war_uids)
|
||||
_delete_in("opinion_wars", "post_uid", uids)
|
||||
|
||||
|
||||
def get_target_owner_uid(target_type: str, target_uid: str) -> str | None:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from .core import DEFAULT_CORRECTION_PROMPT, DEFAULT_MODIFIER_PROMPT, INTERNAL_GATEWAY_URL, _drop_index, _index, _uid_index, db, defaultdict, get_table, logger
|
||||
from .core import AQUALITY_NEWS_GRADING_MODEL, AQUALITY_NEWS_GRADING_URL, DEFAULT_CORRECTION_PROMPT, DEFAULT_MODIFIER_PROMPT, INTERNAL_GATEWAY_URL, _drop_index, _index, _uid_index, db, defaultdict, get_table, logger
|
||||
from .settings import get_setting, set_setting
|
||||
from .soft_delete import SOFT_DELETE_TABLES, ensure_soft_delete_columns
|
||||
from .ranking import _authors_cache
|
||||
@@ -30,6 +30,54 @@ def migrate_bug_tables_to_issue_tables() -> None:
|
||||
logger.info("Dropped table %s after migration", source_name)
|
||||
|
||||
|
||||
_TUNNEL_STATUS_RANK = {
|
||||
"active": 4,
|
||||
"provisioning": 3,
|
||||
"pending": 2,
|
||||
"failed": 1,
|
||||
"suspended": 0,
|
||||
}
|
||||
|
||||
|
||||
def _dedupe_tunnel_hostnames() -> None:
|
||||
if "tunnels" not in db.tables:
|
||||
return
|
||||
table = get_table("tunnels")
|
||||
if not table.has_column("hostname") or not table.has_column("uid"):
|
||||
return
|
||||
groups = list(
|
||||
db.query(
|
||||
"SELECT hostname FROM tunnels "
|
||||
"WHERE hostname IS NOT NULL AND hostname != '' "
|
||||
"GROUP BY hostname HAVING COUNT(*) > 1"
|
||||
)
|
||||
)
|
||||
for group in groups:
|
||||
hostname = group["hostname"]
|
||||
dupes = list(table.find(hostname=hostname))
|
||||
dupes.sort(
|
||||
key=lambda r: (
|
||||
_TUNNEL_STATUS_RANK.get(r.get("status") or "", -1),
|
||||
r.get("deleted_at") is None,
|
||||
r.get("created_at") or "",
|
||||
r.get("id") or 0,
|
||||
),
|
||||
reverse=True,
|
||||
)
|
||||
losers = [r["uid"] for r in dupes[1:]]
|
||||
if not losers:
|
||||
continue
|
||||
with db:
|
||||
for uid in losers:
|
||||
db.query("DELETE FROM tunnels WHERE uid = :uid", uid=uid)
|
||||
logger.warning(
|
||||
"Removed %d duplicate tunnel row(s) for hostname %s, kept %s",
|
||||
len(losers),
|
||||
hostname,
|
||||
dupes[0]["uid"],
|
||||
)
|
||||
|
||||
|
||||
def init_db():
|
||||
tables = db.tables
|
||||
_index(db, "users", "idx_users_username", ["username"])
|
||||
@@ -38,6 +86,7 @@ def init_db():
|
||||
_index(db, "users", "idx_users_role", ["role", "created_at"])
|
||||
_index(db, "users", "idx_users_last_seen", ["last_seen"])
|
||||
_index(db, "users", "idx_users_created_at", ["created_at"])
|
||||
_ensure_users_uid()
|
||||
_index(db, "posts", "idx_posts_user_uid", ["user_uid"])
|
||||
_index(db, "posts", "idx_posts_created_at", ["created_at"])
|
||||
_index(db, "posts", "idx_posts_topic", ["topic"])
|
||||
@@ -121,6 +170,18 @@ def init_db():
|
||||
_index(db, "comments", "idx_comments_user_uid", ["user_uid"])
|
||||
_index(db, "comments", "idx_comments_created_at", ["created_at"])
|
||||
_index(db, "votes", "idx_votes_target", ["target_uid", "target_type"])
|
||||
messages = get_table("messages")
|
||||
for column, example in (
|
||||
("uid", ""),
|
||||
("sender_uid", ""),
|
||||
("receiver_uid", ""),
|
||||
("content", ""),
|
||||
("read", False),
|
||||
("created_at", ""),
|
||||
("updated_at", ""),
|
||||
):
|
||||
if not messages.has_column(column):
|
||||
messages.create_column_by_example(column, example)
|
||||
_index(db, "messages", "idx_messages_sender", ["sender_uid"])
|
||||
_index(db, "messages", "idx_messages_receiver", ["receiver_uid"])
|
||||
_index(
|
||||
@@ -132,6 +193,7 @@ def init_db():
|
||||
"idx_messages_conversation_rev",
|
||||
["receiver_uid", "sender_uid"],
|
||||
)
|
||||
_index(db, "messages", "idx_messages_updated_at", ["updated_at"])
|
||||
_index(db, "notifications", "idx_notifications_user", ["user_uid"])
|
||||
_index(db, "notifications", "idx_notifications_user_read", ["user_uid", "read"])
|
||||
push_registration = get_table("push_registration")
|
||||
@@ -143,19 +205,38 @@ def init_db():
|
||||
("key_auth", ""),
|
||||
("key_p256dh", ""),
|
||||
("token", ""),
|
||||
("client_id", ""),
|
||||
("environment", ""),
|
||||
("created_at", ""),
|
||||
("registered_at", ""),
|
||||
("deleted_at", ""),
|
||||
):
|
||||
if not push_registration.has_column(column):
|
||||
push_registration.create_column_by_example(column, example)
|
||||
_index(db, "push_registration", "idx_push_registration_user", ["user_uid"])
|
||||
_index(db, "push_registration", "idx_push_registration_provider", ["provider"])
|
||||
_index(
|
||||
db,
|
||||
"push_registration",
|
||||
"idx_push_registration_client",
|
||||
["user_uid", "provider", "client_id"],
|
||||
)
|
||||
_index(
|
||||
db,
|
||||
"push_registration",
|
||||
"idx_push_registration_token",
|
||||
["user_uid", "provider", "token"],
|
||||
)
|
||||
if "push_registration" in db.tables:
|
||||
with db:
|
||||
db.query(
|
||||
"UPDATE push_registration SET provider = 'webpush' "
|
||||
"WHERE provider IS NULL OR provider = ''"
|
||||
)
|
||||
db.query(
|
||||
"UPDATE push_registration SET registered_at = created_at "
|
||||
"WHERE registered_at IS NULL OR registered_at = ''"
|
||||
)
|
||||
_index(db, "sessions", "idx_sessions_token", ["session_token"])
|
||||
projects = get_table("projects")
|
||||
for column, example in (
|
||||
@@ -171,6 +252,13 @@ def init_db():
|
||||
("title", ""),
|
||||
("description", ""),
|
||||
("status", ""),
|
||||
("platforms", ""),
|
||||
("release_date", ""),
|
||||
("demo_date", ""),
|
||||
("website_url", ""),
|
||||
("repo_url", ""),
|
||||
("cover_attachment_uid", ""),
|
||||
("logo_attachment_uid", ""),
|
||||
):
|
||||
if not projects.has_column(column):
|
||||
projects.create_column_by_example(column, example)
|
||||
@@ -206,6 +294,22 @@ def init_db():
|
||||
_index(
|
||||
db, "project_files", "idx_project_files_parent", ["project_uid", "parent_path"]
|
||||
)
|
||||
project_file_sync_state = get_table("project_file_sync_state")
|
||||
for column, example in (
|
||||
("project_uid", ""),
|
||||
("path", ""),
|
||||
("db_epoch", 0.0),
|
||||
("fs_epoch", 0.0),
|
||||
):
|
||||
if not project_file_sync_state.has_column(column):
|
||||
project_file_sync_state.create_column_by_example(column, example)
|
||||
_index(
|
||||
db,
|
||||
"project_file_sync_state",
|
||||
"idx_project_file_sync_state_path",
|
||||
["project_uid", "path"],
|
||||
unique=True,
|
||||
)
|
||||
_index(db, "badges", "idx_badges_user", ["user_uid"])
|
||||
_index(db, "badges", "idx_badges_user_name", ["user_uid", "badge_name"])
|
||||
_drop_index(db, "idx_follows_follower")
|
||||
@@ -492,6 +596,51 @@ def init_db():
|
||||
"idx_user_cust_scope",
|
||||
["owner_kind", "owner_id", "scope", "lang"],
|
||||
)
|
||||
gateway_usage_ledger = get_table("gateway_usage_ledger")
|
||||
for column, example in (
|
||||
("created_at", ""),
|
||||
("owner_kind", ""),
|
||||
("owner_id", ""),
|
||||
("backend", ""),
|
||||
("endpoint", ""),
|
||||
("requested_model", ""),
|
||||
("model", ""),
|
||||
("status_code", 0),
|
||||
("success", 0),
|
||||
("error_category", ""),
|
||||
("upstream_latency_ms", 0.0),
|
||||
("gateway_overhead_ms", 0.0),
|
||||
("queue_wait_ms", 0.0),
|
||||
("connect_ms", 0.0),
|
||||
("total_latency_ms", 0.0),
|
||||
("prompt_tokens", 0),
|
||||
("completion_tokens", 0),
|
||||
("cache_hit_tokens", 0),
|
||||
("cache_miss_tokens", 0),
|
||||
("reasoning_tokens", 0),
|
||||
("total_tokens", 0),
|
||||
("tokens_per_second", 0.0),
|
||||
("context_window", 0),
|
||||
("context_utilization", 0.0),
|
||||
("cost_usd", 0.0),
|
||||
("input_cost_usd", 0.0),
|
||||
("output_cost_usd", 0.0),
|
||||
("native_cost", 0),
|
||||
("stream_requested", 0),
|
||||
("temperature", 0.0),
|
||||
("top_p", 0.0),
|
||||
("max_tokens", 0),
|
||||
("has_tools", 0),
|
||||
("retries_attempted", 0),
|
||||
("retry_succeeded", 0),
|
||||
("circuit_open", 0),
|
||||
("user_agent", ""),
|
||||
("app_reference", ""),
|
||||
("ttft_ms", 0.0),
|
||||
("inter_token_ms", 0.0),
|
||||
):
|
||||
if not gateway_usage_ledger.has_column(column):
|
||||
gateway_usage_ledger.create_column_by_example(column, example)
|
||||
_index(db, "gateway_usage_ledger", "idx_gw_usage_time", ["created_at"])
|
||||
_index(
|
||||
db,
|
||||
@@ -588,6 +737,10 @@ def init_db():
|
||||
("flag_reason", ""),
|
||||
("suspended_at", ""),
|
||||
("suspended_by", ""),
|
||||
("boot_marker", ""),
|
||||
("workspace_cpu_millicores", 0),
|
||||
("workspace_memory_mb", 0),
|
||||
("workspace_disk_quota_mb", 0),
|
||||
):
|
||||
if not instances.has_column(column):
|
||||
instances.create_column_by_example(column, example)
|
||||
@@ -628,12 +781,36 @@ def init_db():
|
||||
("egress_quota_mb", 0),
|
||||
("idle_stop_minutes", 0),
|
||||
("retention_days", 0),
|
||||
("cpu_millicores", 0),
|
||||
("memory_mb", 0),
|
||||
("created_at", ""),
|
||||
("updated_at", ""),
|
||||
):
|
||||
if not quota_rules.has_column(column):
|
||||
quota_rules.create_column_by_example(column, example)
|
||||
|
||||
editor_prefs = get_table("workspace_editor_prefs")
|
||||
for column, example in (
|
||||
("uid", ""),
|
||||
("owner_kind", ""),
|
||||
("owner_id", ""),
|
||||
("font_size", 0),
|
||||
("terminal_font_size", 0),
|
||||
("zoom_level", -99),
|
||||
("theme", ""),
|
||||
("layout", ""),
|
||||
("panel_preset", ""),
|
||||
("window_mode", ""),
|
||||
("window_width", 0),
|
||||
("window_height", 0),
|
||||
("boot_agent", ""),
|
||||
("boot_shell", -1),
|
||||
("created_at", ""),
|
||||
("updated_at", ""),
|
||||
):
|
||||
if not editor_prefs.has_column(column):
|
||||
editor_prefs.create_column_by_example(column, example)
|
||||
|
||||
flags = get_table("workspace_flags")
|
||||
for column, example in (
|
||||
("uid", ""),
|
||||
@@ -656,7 +833,9 @@ def init_db():
|
||||
_index(db, "instances", "idx_instances_workspace", ["is_workspace", "status"])
|
||||
_index(db, "instances", "idx_instances_workspace_owner", ["workspace_owner_uid"])
|
||||
_index(db, "instances", "idx_instances_tunnel_name", ["tunnel_name"])
|
||||
_index(db, "tunnels", "idx_tunnels_hostname", ["hostname"])
|
||||
_dedupe_tunnel_hostnames()
|
||||
_drop_index(db, "idx_tunnels_hostname")
|
||||
_index(db, "tunnels", "idx_tunnels_hostname", ["hostname"], unique=True)
|
||||
_index(db, "tunnels", "idx_tunnels_instance", ["instance_uid"])
|
||||
_index(db, "tunnels", "idx_tunnels_user", ["user_uid"])
|
||||
_index(db, "tunnels", "idx_tunnels_state", ["desired_state", "status"])
|
||||
@@ -666,6 +845,12 @@ def init_db():
|
||||
"idx_workspace_quota_owner",
|
||||
["owner_kind", "owner_id"],
|
||||
)
|
||||
_index(
|
||||
db,
|
||||
"workspace_editor_prefs",
|
||||
"idx_workspace_editor_prefs_owner",
|
||||
["owner_kind", "owner_id"],
|
||||
)
|
||||
_index(db, "workspace_flags", "idx_workspace_flags_open", ["status", "created_at"])
|
||||
_index(
|
||||
db,
|
||||
@@ -1528,6 +1713,98 @@ def init_db():
|
||||
)
|
||||
_index(db, "quiz_answers", "idx_quiz_answers_quiz", ["quiz_uid"])
|
||||
|
||||
opinion_wars = get_table("opinion_wars")
|
||||
for column, example in (
|
||||
("uid", ""),
|
||||
("post_uid", ""),
|
||||
("user_uid", ""),
|
||||
("faction_a", ""),
|
||||
("faction_b", ""),
|
||||
("hp_a", 0),
|
||||
("hp_b", 0),
|
||||
("leader", ""),
|
||||
("status", "active"),
|
||||
("winner", ""),
|
||||
("created_at", ""),
|
||||
("ends_at", ""),
|
||||
("resolved_at", ""),
|
||||
("updated_at", ""),
|
||||
("deleted_at", ""),
|
||||
("deleted_by", ""),
|
||||
):
|
||||
if not opinion_wars.has_column(column):
|
||||
opinion_wars.create_column_by_example(column, example)
|
||||
_index(db, "opinion_wars", "idx_opinion_wars_post", ["post_uid"], unique=True)
|
||||
_index(db, "opinion_wars", "idx_opinion_wars_status_ends", ["status", "ends_at"])
|
||||
_index(
|
||||
db,
|
||||
"opinion_wars",
|
||||
"idx_opinion_wars_live_created",
|
||||
["created_at"],
|
||||
where="deleted_at IS NULL",
|
||||
)
|
||||
|
||||
opinion_war_fighters = get_table("opinion_war_fighters")
|
||||
for column, example in (
|
||||
("uid", ""),
|
||||
("war_uid", ""),
|
||||
("user_uid", ""),
|
||||
("faction", ""),
|
||||
("hp_a", 0),
|
||||
("hp_b", 0),
|
||||
("fight_count", 0),
|
||||
("last_fight_at", ""),
|
||||
("cooldown_notified_at", ""),
|
||||
("created_at", ""),
|
||||
("updated_at", ""),
|
||||
("deleted_at", ""),
|
||||
("deleted_by", ""),
|
||||
):
|
||||
if not opinion_war_fighters.has_column(column):
|
||||
opinion_war_fighters.create_column_by_example(column, example)
|
||||
_index(
|
||||
db,
|
||||
"opinion_war_fighters",
|
||||
"idx_opinion_war_fighters_war_user",
|
||||
["war_uid", "user_uid"],
|
||||
unique=True,
|
||||
)
|
||||
_index(
|
||||
db,
|
||||
"opinion_war_fighters",
|
||||
"idx_opinion_war_fighters_war_faction",
|
||||
["war_uid", "faction"],
|
||||
)
|
||||
_index(
|
||||
db,
|
||||
"opinion_war_fighters",
|
||||
"idx_opinion_war_fighters_last_fight",
|
||||
["last_fight_at"],
|
||||
)
|
||||
|
||||
opinion_war_events = get_table("opinion_war_events")
|
||||
for column, example in (
|
||||
("uid", ""),
|
||||
("war_uid", ""),
|
||||
("seq", 0),
|
||||
("kind", ""),
|
||||
("message", ""),
|
||||
("payload", ""),
|
||||
("actor_uid", ""),
|
||||
("created_at", ""),
|
||||
("deleted_at", ""),
|
||||
("deleted_by", ""),
|
||||
):
|
||||
if not opinion_war_events.has_column(column):
|
||||
opinion_war_events.create_column_by_example(column, example)
|
||||
_index(
|
||||
db,
|
||||
"opinion_war_events",
|
||||
"idx_opinion_war_events_war_seq",
|
||||
["war_uid", "seq"],
|
||||
unique=True,
|
||||
)
|
||||
|
||||
_index(db, "posts", "idx_posts_user_created", ["user_uid", "created_at"])
|
||||
_index(
|
||||
db,
|
||||
@@ -1654,8 +1931,8 @@ def init_db():
|
||||
news_defaults = {
|
||||
"news_grade_threshold": "7",
|
||||
"news_api_url": "https://news.app.molodetz.nl/api",
|
||||
"news_ai_url": INTERNAL_GATEWAY_URL,
|
||||
"news_ai_model": "molodetz",
|
||||
"news_ai_url": AQUALITY_NEWS_GRADING_URL,
|
||||
"news_ai_model": AQUALITY_NEWS_GRADING_MODEL,
|
||||
}
|
||||
for key, value in news_defaults.items():
|
||||
existing = db["site_settings"].find_one(key=key)
|
||||
@@ -1691,6 +1968,7 @@ def init_db():
|
||||
"statistics_tracking_enabled": "1",
|
||||
"docs_search_mode": "agent",
|
||||
"outbound_proxy_url": "",
|
||||
"ios_app_url": "https://apps.apple.com/app/devplace/id6797215143",
|
||||
"devii_lessons_max_per_owner": "500",
|
||||
"devii_lessons_max_age_days": "90",
|
||||
"moderation_sla_hours": "24",
|
||||
@@ -1900,6 +2178,12 @@ def migrate_ai_gateway_settings() -> None:
|
||||
if get_setting(key, "") == OLD_GATEWAY_URL:
|
||||
set_setting(key, INTERNAL_GATEWAY_URL)
|
||||
logger.info(f"Migrated {key} to the internal gateway")
|
||||
if get_setting("news_ai_url", "") == INTERNAL_GATEWAY_URL:
|
||||
set_setting("news_ai_url", AQUALITY_NEWS_GRADING_URL)
|
||||
logger.info("Migrated news_ai_url to the free aquality grading model")
|
||||
if get_setting("news_ai_model", "") in ("molodetz", ""):
|
||||
set_setting("news_ai_model", AQUALITY_NEWS_GRADING_MODEL)
|
||||
logger.info("Migrated news_ai_model to aquality")
|
||||
if get_setting("bot_model", "") == "deepseek-chat":
|
||||
set_setting("bot_model", "molodetz")
|
||||
logger.info("Migrated bot_model to molodetz")
|
||||
@@ -1914,6 +2198,26 @@ def migrate_ai_gateway_settings() -> None:
|
||||
migrate_retired_image_gateway()
|
||||
|
||||
|
||||
def _ensure_users_uid() -> int:
|
||||
if "users" not in db.tables:
|
||||
return 0
|
||||
users = get_table("users")
|
||||
if not users.has_column("uid"):
|
||||
users.create_column_by_example("uid", "")
|
||||
import uuid_utils
|
||||
|
||||
updated = 0
|
||||
for user in users.find():
|
||||
if not user.get("uid"):
|
||||
users.update(
|
||||
{"id": user["id"], "uid": str(uuid_utils.uuid7())}, ["id"]
|
||||
)
|
||||
updated += 1
|
||||
if updated:
|
||||
logger.info("Backfilled uid for %s user(s)", updated)
|
||||
return updated
|
||||
|
||||
|
||||
def backfill_api_keys() -> int:
|
||||
users = get_table("users")
|
||||
if not users.has_column("api_key"):
|
||||
@@ -2090,3 +2394,4 @@ def _backfill_gamification():
|
||||
f"{len(checked)} with milestone-eligible activity"
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -25,6 +25,8 @@ SOFT_DELETE_TABLES = [
|
||||
"instance_schedules",
|
||||
"tunnels",
|
||||
"workspace_flags",
|
||||
"workspace_quota_rules",
|
||||
"workspace_editor_prefs",
|
||||
"backup_schedules",
|
||||
"devii_conversations",
|
||||
"devii_tasks",
|
||||
@@ -49,6 +51,9 @@ SOFT_DELETE_TABLES = [
|
||||
"quiz_options",
|
||||
"quiz_attempts",
|
||||
"quiz_answers",
|
||||
"opinion_wars",
|
||||
"opinion_war_fighters",
|
||||
"opinion_war_events",
|
||||
"content_reports",
|
||||
"moderation_actions",
|
||||
"content_maturity",
|
||||
|
||||
@@ -15,8 +15,6 @@ def get_users_by_uids(uids):
|
||||
|
||||
|
||||
_admins_cache = TTLCache(ttl=300, max_size=4)
|
||||
# The primary administrator must be an account that can actually authenticate, so scan a
|
||||
# few of the earliest admins and skip any that are soft-deleted or deactivated.
|
||||
PRIMARY_ADMIN_CANDIDATES = 50
|
||||
|
||||
|
||||
@@ -32,6 +30,9 @@ def get_admin_uids():
|
||||
return list(cached)
|
||||
if "users" not in db.tables:
|
||||
return []
|
||||
users = db["users"]
|
||||
if "uid" not in users.columns or "role" not in users.columns:
|
||||
return []
|
||||
rows = db.query("SELECT uid FROM users WHERE role = 'Admin'")
|
||||
uids = [row["uid"] for row in rows]
|
||||
_admins_cache.set("uids", uids)
|
||||
@@ -92,6 +93,9 @@ def get_primary_admin_uid():
|
||||
return cached or None
|
||||
if "users" not in db.tables:
|
||||
return None
|
||||
users = db["users"]
|
||||
if "uid" not in users.columns or "role" not in users.columns:
|
||||
return None
|
||||
rows = list(
|
||||
db.query(
|
||||
"SELECT * FROM users WHERE role = 'Admin' "
|
||||
|
||||
@@ -1,9 +1,4 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
"""
|
||||
Generic FastAPI dependency that accepts JSON or form-encoded data,
|
||||
validated against a Pydantic model.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import Any, TypeVar, get_origin
|
||||
@@ -17,21 +12,10 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
_TModel = TypeVar("_TModel", bound=BaseModel)
|
||||
|
||||
# Container origins recognised as sequence fields that may receive
|
||||
# multiple values from form data.
|
||||
_SEQUENCE_ORIGINS = frozenset({list, set, tuple, frozenset})
|
||||
|
||||
|
||||
def _formdata_to_dict(form: FormData, model: type[BaseModel]) -> dict[str, Any]:
|
||||
"""Convert FormData to a dict suitable for Pydantic validation.
|
||||
|
||||
* Sequence-typed model fields collect every submitted value via
|
||||
``getlist()``; a lone empty string is dropped (browsers emit empty
|
||||
hidden inputs by default).
|
||||
* Scalar fields use ``get()`` (the last value).
|
||||
* Fields absent from the form are omitted so that Pydantic applies
|
||||
the model default.
|
||||
"""
|
||||
body: dict[str, Any] = {}
|
||||
for field_name, field_info in model.model_fields.items():
|
||||
origin = get_origin(field_info.annotation)
|
||||
@@ -50,8 +34,6 @@ def _formdata_to_dict(form: FormData, model: type[BaseModel]) -> dict[str, Any]:
|
||||
|
||||
|
||||
class _JsonOrForm:
|
||||
"""Internal callable that parses JSON or form data and validates."""
|
||||
|
||||
def __init__(self, model: type[BaseModel]):
|
||||
self.model = model
|
||||
|
||||
@@ -70,7 +52,6 @@ class _JsonOrForm:
|
||||
status_code=400, detail="JSON body must be an object"
|
||||
)
|
||||
return self.model.model_validate(body)
|
||||
# Default: form-encoded (multipart or url-encoded)
|
||||
try:
|
||||
form = await request.form()
|
||||
except Exception as exc:
|
||||
@@ -85,11 +66,4 @@ class _JsonOrForm:
|
||||
|
||||
|
||||
def json_or_form(model: type[_TModel]) -> _JsonOrForm:
|
||||
"""Dependency factory: accept JSON or form-encoded data for a Pydantic model.
|
||||
|
||||
Usage:
|
||||
@router.post("/create")
|
||||
async def create(data: Annotated[PostForm, Depends(json_or_form(PostForm))]):
|
||||
...
|
||||
"""
|
||||
return _JsonOrForm(model)
|
||||
|
||||
@@ -22,6 +22,7 @@ from . import (
|
||||
admin,
|
||||
game,
|
||||
quizzes,
|
||||
battles,
|
||||
)
|
||||
|
||||
ORDERED_GROUPS = [
|
||||
@@ -46,4 +47,5 @@ ORDERED_GROUPS = [
|
||||
admin.GROUP,
|
||||
game.GROUP,
|
||||
quizzes.GROUP,
|
||||
battles.GROUP,
|
||||
]
|
||||
|
||||
@@ -265,6 +265,29 @@ four ways to sign requests.
|
||||
),
|
||||
],
|
||||
),
|
||||
endpoint(
|
||||
id="admin-issues-planning",
|
||||
method="GET",
|
||||
path="/admin/issues/planning",
|
||||
title="Ticket planning report",
|
||||
summary=(
|
||||
"Admin page listing every open Gitea ticket so an admin can pick a subset "
|
||||
"and generate a grouped, ordered planning document for a coding agent."
|
||||
),
|
||||
auth="admin",
|
||||
interactive=False,
|
||||
sample_response={
|
||||
"configured": True,
|
||||
"tickets": [
|
||||
{"number": 42, "title": "Fix login redirect", "labels": ["bug"]},
|
||||
],
|
||||
"tickets_error": False,
|
||||
},
|
||||
notes=[
|
||||
"`configured` is false when the issue tracker (Gitea) has not been set up in Services yet - `tickets` is then empty.",
|
||||
"`tickets_error` is true when the tracker is configured but the live fetch failed - retry shortly.",
|
||||
],
|
||||
),
|
||||
endpoint(
|
||||
id="admin-ai-usage",
|
||||
method="GET",
|
||||
@@ -300,7 +323,7 @@ four ways to sign requests.
|
||||
),
|
||||
],
|
||||
notes=[
|
||||
"TTFT and inter-token latency are not reported: the gateway forwards non-streaming to the upstream."
|
||||
"TTFT and inter-token latency (latency.ttft_ms/latency.inter_token_ms) are measured only for calls that requested streaming (stream: true); a zero count means no streaming calls occurred in the window, not that the metric is unavailable."
|
||||
],
|
||||
),
|
||||
endpoint(
|
||||
@@ -613,6 +636,85 @@ four ways to sign requests.
|
||||
auth="admin",
|
||||
destructive=True,
|
||||
),
|
||||
endpoint(
|
||||
id="admin-gateway-page",
|
||||
method="GET",
|
||||
path="/admin/gateway",
|
||||
title="Gateway routing dashboard",
|
||||
summary="Admin HTML page for managing AI gateway providers and per-model routing.",
|
||||
auth="admin",
|
||||
interactive=False,
|
||||
),
|
||||
endpoint(
|
||||
id="admin-gateway-providers",
|
||||
method="GET",
|
||||
path="/admin/gateway/providers",
|
||||
title="List AI gateway providers",
|
||||
summary="List every configured upstream provider plus the default provider summary.",
|
||||
auth="admin",
|
||||
interactive=True,
|
||||
),
|
||||
endpoint(
|
||||
id="admin-gateway-provider-set",
|
||||
method="POST",
|
||||
path="/admin/gateway/providers",
|
||||
title="Create or update an AI gateway provider",
|
||||
summary="Save an upstream provider (base URL, model, and API key) by name. Pass name to update an existing provider.",
|
||||
auth="admin",
|
||||
params=[
|
||||
field("name", "json", "string", True, "openrouter", "Provider name; existing name updates in place."),
|
||||
field("base_url", "json", "string", True, "https://openrouter.ai/api/v1", "Upstream chat completions base URL."),
|
||||
field("model", "json", "string", True, "x-ai/grok-4.3", "Default model for this provider."),
|
||||
field("api_key", "json", "string", False, "", "Upstream API key. Blank keeps the current key."),
|
||||
],
|
||||
),
|
||||
endpoint(
|
||||
id="admin-gateway-provider-delete",
|
||||
method="DELETE",
|
||||
path="/admin/gateway/providers/{name}",
|
||||
title="Delete an AI gateway provider",
|
||||
summary="Delete a configured upstream provider by name.",
|
||||
auth="admin",
|
||||
destructive=True,
|
||||
params=[
|
||||
field("name", "path", "string", True, "openrouter", "Provider name."),
|
||||
],
|
||||
),
|
||||
endpoint(
|
||||
id="admin-gateway-models",
|
||||
method="GET",
|
||||
path="/admin/gateway/models",
|
||||
title="List AI gateway model routes",
|
||||
summary="List every source-to-target model route plus the configured provider names.",
|
||||
auth="admin",
|
||||
interactive=True,
|
||||
),
|
||||
endpoint(
|
||||
id="admin-gateway-model-set",
|
||||
method="POST",
|
||||
path="/admin/gateway/models",
|
||||
title="Create or update an AI gateway model route",
|
||||
summary="Route a source model name to a target model, optionally on a specific provider. Pass source_model to update an existing route.",
|
||||
auth="admin",
|
||||
params=[
|
||||
field("source_model", "json", "string", True, "gpt-4", "Model name callers request."),
|
||||
field("target_model", "json", "string", True, "x-ai/grok-4.3", "Model actually sent upstream."),
|
||||
field("provider", "json", "string", False, "openrouter", "Provider name to route through. Blank = the default provider."),
|
||||
field("fallback_model", "json", "string", False, "molodetz-pro", "Another already-configured source_model of the same kind, tried once automatically when this route fails after its own retries are exhausted. Blank = no fallback."),
|
||||
],
|
||||
),
|
||||
endpoint(
|
||||
id="admin-gateway-model-delete",
|
||||
method="DELETE",
|
||||
path="/admin/gateway/models/{source_model}",
|
||||
title="Delete an AI gateway model route",
|
||||
summary="Delete a source-to-target model route by source model name.",
|
||||
auth="admin",
|
||||
destructive=True,
|
||||
params=[
|
||||
field("source_model", "path", "string", True, "gpt-4", "Source model name."),
|
||||
],
|
||||
),
|
||||
endpoint(
|
||||
id="admin-gateway-quota-rules",
|
||||
method="GET",
|
||||
@@ -975,5 +1077,190 @@ four ways to sign requests.
|
||||
destructive=True,
|
||||
sample_response={"ok": True, "redirect": "/admin/game"},
|
||||
),
|
||||
endpoint(
|
||||
id="admin-workspaces-page",
|
||||
method="GET",
|
||||
path="/admin/workspaces",
|
||||
title="Workspaces dashboard",
|
||||
summary="Admin HTML page listing every dev workspace across all projects, with owner, project, and moderation flags.",
|
||||
auth="admin",
|
||||
interactive=False,
|
||||
),
|
||||
endpoint(
|
||||
id="admin-workspaces-data",
|
||||
method="GET",
|
||||
path="/admin/workspaces/data",
|
||||
title="List workspaces",
|
||||
summary="Every dev workspace across all projects plus open moderation flags, as JSON.",
|
||||
auth="admin",
|
||||
interactive=True,
|
||||
sample_response={"workspaces": [], "flags": []},
|
||||
),
|
||||
endpoint(
|
||||
id="admin-workspaces-suspend",
|
||||
method="POST",
|
||||
path="/admin/workspaces/{uid}/suspend",
|
||||
title="Suspend a workspace",
|
||||
summary="Suspend a workspace with a reason shown to its owner. The owner is notified.",
|
||||
auth="admin",
|
||||
destructive=True,
|
||||
params=[
|
||||
field("uid", "path", "string", True, "INSTANCE_UID", "Workspace instance uid."),
|
||||
field("reason", "form", "string", True, "Excessive resource usage", "Shown to the workspace owner."),
|
||||
],
|
||||
),
|
||||
endpoint(
|
||||
id="admin-workspaces-unsuspend",
|
||||
method="POST",
|
||||
path="/admin/workspaces/{uid}/unsuspend",
|
||||
title="Unsuspend a workspace",
|
||||
summary="Lift a suspension. The owner is notified the workspace is available again.",
|
||||
auth="admin",
|
||||
params=[
|
||||
field("uid", "path", "string", True, "INSTANCE_UID", "Workspace instance uid."),
|
||||
],
|
||||
),
|
||||
endpoint(
|
||||
id="admin-workspaces-stop",
|
||||
method="POST",
|
||||
path="/admin/workspaces/{uid}/stop",
|
||||
title="Stop a workspace",
|
||||
summary="Stop the workspace container. Files and tunnels are kept.",
|
||||
auth="admin",
|
||||
destructive=True,
|
||||
params=[
|
||||
field("uid", "path", "string", True, "INSTANCE_UID", "Workspace instance uid."),
|
||||
],
|
||||
),
|
||||
endpoint(
|
||||
id="admin-workspaces-start",
|
||||
method="POST",
|
||||
path="/admin/workspaces/{uid}/start",
|
||||
title="Start a workspace",
|
||||
summary="Resume a stopped workspace container.",
|
||||
auth="admin",
|
||||
params=[
|
||||
field("uid", "path", "string", True, "INSTANCE_UID", "Workspace instance uid."),
|
||||
],
|
||||
),
|
||||
endpoint(
|
||||
id="admin-workspaces-delete",
|
||||
method="POST",
|
||||
path="/admin/workspaces/{uid}/delete",
|
||||
title="Delete a workspace",
|
||||
summary="Remove a workspace and its tunnels.",
|
||||
auth="admin",
|
||||
destructive=True,
|
||||
params=[
|
||||
field("uid", "path", "string", True, "INSTANCE_UID", "Workspace instance uid."),
|
||||
],
|
||||
),
|
||||
endpoint(
|
||||
id="admin-workspaces-flag",
|
||||
method="POST",
|
||||
path="/admin/workspaces/{uid}/flag",
|
||||
title="Flag a workspace",
|
||||
summary="Raise a moderation flag on a workspace. The owner is notified.",
|
||||
auth="admin",
|
||||
params=[
|
||||
field("uid", "path", "string", True, "INSTANCE_UID", "Workspace instance uid."),
|
||||
field("kind", "form", "string", False, "manual", "Flag kind."),
|
||||
field("severity", "form", "string", False, "warn", "Flag severity."),
|
||||
field("detail", "form", "string", False, "", "Detail shown to the owner."),
|
||||
],
|
||||
),
|
||||
endpoint(
|
||||
id="admin-workspaces-flag-resolve",
|
||||
method="POST",
|
||||
path="/admin/workspaces/flags/{flag_uid}/resolve",
|
||||
title="Resolve or dismiss a workspace flag",
|
||||
summary="Set a moderation flag's status.",
|
||||
auth="admin",
|
||||
params=[
|
||||
field("flag_uid", "path", "string", True, "FLAG_UID", "Flag uid."),
|
||||
field("status", "query", "string", False, "resolved", "resolved or dismissed."),
|
||||
],
|
||||
),
|
||||
endpoint(
|
||||
id="admin-workspaces-editor",
|
||||
method="POST",
|
||||
path="/admin/workspaces/{uid}/editor",
|
||||
title="Set a workspace owner's editor preferences",
|
||||
summary="Change the workspace owner's editor preferences on their behalf, or reset them. Subject to admin seniority.",
|
||||
auth="admin",
|
||||
params=[
|
||||
field("uid", "path", "string", True, "INSTANCE_UID", "Workspace instance uid."),
|
||||
field("theme", "form", "string", False, "devplace-dark", "devplace-dark, devplace-light or system."),
|
||||
field("layout", "form", "string", False, "standard", "standard, terminal-focus or zen."),
|
||||
field("panel_preset", "form", "string", False, "tall", "short, normal, tall or maximized."),
|
||||
field("font_size", "form", "integer", False, "14", "Editor font size in pixels."),
|
||||
field("terminal_font_size", "form", "integer", False, "13", "Terminal font size in pixels."),
|
||||
field("zoom_level", "form", "integer", False, "0", "Window zoom, -5 to 5."),
|
||||
field("boot_agent", "form", "string", False, "dpc", "dpc or none."),
|
||||
field("boot_shell", "form", "integer", False, "1", "1 opens a shell on boot, 0 skips it."),
|
||||
field("window_mode", "form", "string", False, "tab", "tab, window or fullscreen."),
|
||||
field("window_width", "form", "integer", False, "1600", "Editor window width in pixels."),
|
||||
field("window_height", "form", "integer", False, "1000", "Editor window height in pixels."),
|
||||
field("reset", "form", "boolean", False, "false", "Drop every preference for this owner."),
|
||||
],
|
||||
),
|
||||
endpoint(
|
||||
id="admin-workspaces-quota",
|
||||
method="POST",
|
||||
path="/admin/workspaces/quota",
|
||||
title="Set a user's workspace quota override",
|
||||
summary="Set or update a per-user override of the workspace count/disk/egress/idle/retention/CPU/memory limits.",
|
||||
auth="admin",
|
||||
params=[
|
||||
field("owner_id", "form", "string", True, "USER_UID", "Target user uid."),
|
||||
field("label", "form", "string", False, "", "Optional admin-facing note."),
|
||||
field("max_workspaces", "form", "int", False, "0", "0 = use the site default."),
|
||||
field("max_tunnels", "form", "int", False, "0", "0 = use the site default."),
|
||||
field("disk_quota_mb", "form", "int", False, "0", "0 = use the site default."),
|
||||
field("egress_quota_mb", "form", "int", False, "0", "0 = use the site default."),
|
||||
field("idle_stop_minutes", "form", "int", False, "0", "0 = use the site default."),
|
||||
field("retention_days", "form", "int", False, "0", "0 = use the site default."),
|
||||
field("cpu_millicores", "form", "int", False, "0", "0 = use the site default."),
|
||||
field("memory_mb", "form", "int", False, "0", "0 = use the site default."),
|
||||
],
|
||||
),
|
||||
endpoint(
|
||||
id="admin-trash-list",
|
||||
method="GET",
|
||||
path="/admin/trash",
|
||||
title="Trash",
|
||||
summary="Admin HTML page listing soft-deleted rows for one table, with restore/purge controls per row.",
|
||||
auth="admin",
|
||||
interactive=True,
|
||||
params=[
|
||||
field("table", "query", "string", False, "posts", "Trash table key (posts, comments, gists, projects, news, awards, quizzes, project_files, attachments)."),
|
||||
field("page", "query", "int", False, "1", "Page number."),
|
||||
],
|
||||
),
|
||||
endpoint(
|
||||
id="admin-trash-restore",
|
||||
method="POST",
|
||||
path="/admin/trash/{table}/{uid}/restore",
|
||||
title="Restore a soft-deleted row",
|
||||
summary="Restore every row soft-deleted under the same event timestamp as the given row (a whole delete cascade at once).",
|
||||
auth="admin",
|
||||
params=[
|
||||
field("table", "path", "string", True, "posts", "Trash table key."),
|
||||
field("uid", "path", "string", True, "ROW_UID", "Row uid."),
|
||||
],
|
||||
),
|
||||
endpoint(
|
||||
id="admin-trash-purge",
|
||||
method="POST",
|
||||
path="/admin/trash/{table}/{uid}/purge",
|
||||
title="Purge a soft-deleted row",
|
||||
summary="Permanently delete every row soft-deleted under the same event timestamp as the given row, unlinking any attachment/project-file blobs.",
|
||||
auth="admin",
|
||||
destructive=True,
|
||||
params=[
|
||||
field("table", "path", "string", True, "posts", "Trash table key."),
|
||||
field("uid", "path", "string", True, "ROW_UID", "Row uid."),
|
||||
],
|
||||
),
|
||||
],
|
||||
}
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from devplacepy.services.opinionwar import rules
|
||||
|
||||
from .._shared import endpoint, field
|
||||
|
||||
FILTER_KEYS = ["active", "ended", "mine"]
|
||||
|
||||
SAMPLE_WAR = {
|
||||
"uid": "0198f2c0-2222-7aaa-8bbb-000000000002",
|
||||
"post_uid": "0198f2c0-3333-7aaa-8bbb-000000000003",
|
||||
"post_url": "/posts/8bbb000000000003-tabs-or-spaces",
|
||||
"post_title": "Tabs or spaces?",
|
||||
"faction_a": "Tabs",
|
||||
"faction_b": "Spaces",
|
||||
"hp_a": 12548,
|
||||
"hp_b": 7362,
|
||||
"pct_a": 63,
|
||||
"pct_b": 37,
|
||||
"leader": "a",
|
||||
"fighter_count": 42,
|
||||
"status": "active",
|
||||
"winner": "",
|
||||
"ends_at": "2026-08-27T12:00:00+00:00",
|
||||
"ends_in": "2d 14h 32m",
|
||||
"last_seq": 87,
|
||||
"fight_cost": rules.FIGHT_COST_COINS,
|
||||
"top_contributors": [
|
||||
{"username": "code_warrior", "faction": "a", "hp": 982},
|
||||
],
|
||||
"recent_events": [
|
||||
{"seq": 87, "kind": "fight", "message": "code_warrior dealt 300 HP for Tabs", "faction": "a"},
|
||||
],
|
||||
"viewer": {
|
||||
"faction": "a",
|
||||
"hp": 256,
|
||||
"rank": 7,
|
||||
"can_fight": True,
|
||||
"next_fight_at": "",
|
||||
},
|
||||
}
|
||||
|
||||
GROUP = {
|
||||
"slug": "battles",
|
||||
"title": "Opinion Wars",
|
||||
"intro": f"""
|
||||
# Opinion Wars
|
||||
|
||||
An Opinion War is a week-long two-faction battle attached to a post. The creator names
|
||||
exactly two factions when creating the post (the `war_faction_a` / `war_faction_b` fields
|
||||
on `POST /posts/create`); from that moment the battle runs for exactly
|
||||
{rules.WAR_DURATION_DAYS} days.
|
||||
|
||||
Any signed-in member joins one of the two factions and may **fight** once every
|
||||
{rules.FIGHT_COOLDOWN_HOURS} hours per battle. A fight costs {rules.FIGHT_COST_COINS}
|
||||
Code Farm coins and deals deterministic, level-weighted damage for the fighter's faction:
|
||||
`{rules.BASE_DAMAGE} + {rules.LEVEL_DAMAGE_STEP} * min(level, {rules.LEVEL_DAMAGE_CAP})`
|
||||
HP, so a level 1 member deals {rules.damage_for(1)} HP and the bonus caps at
|
||||
{rules.damage_for(rules.LEVEL_DAMAGE_CAP)} HP. There is no randomness. Switching factions
|
||||
is allowed at any time; damage already dealt stays with the faction it was dealt to.
|
||||
|
||||
When the week is over the faction with more HP wins. Resolution is evaluated lazily on
|
||||
read (no background clock): the first read after the deadline freezes the totals, awards
|
||||
XP (participation for every fighter with at least one fight, a bonus for the winning
|
||||
side, a bonus for the single top damage dealer) and notifies every fighter. Equal totals
|
||||
are a draw with participation XP only.
|
||||
|
||||
Every battle keeps an ordered event log (kinds `join`, `switch`, `fight`, `lead_change`,
|
||||
`result`) replayable with the `after` cursor; live frames are also published on the
|
||||
pub/sub topic `public.battle.{{uid}}`.
|
||||
|
||||
All endpoints negotiate HTML or JSON. POST bodies are form encoded. Action POSTs answer
|
||||
`{{"ok": true, "redirect": "...", "data": {{...}}}}`; a refused action (cooldown, missing
|
||||
coins, ended battle) answers `400` as `{{"error": {{"status": 400, "message": "..."}}}}`.
|
||||
""",
|
||||
"endpoints": [
|
||||
endpoint(
|
||||
id="battles-list",
|
||||
method="GET",
|
||||
path="/battles",
|
||||
title="Battle listing",
|
||||
summary="Opinion Wars with HP totals, filter counts and the viewer's faction state.",
|
||||
auth="public",
|
||||
negotiation=True,
|
||||
params=[
|
||||
field("search", "query", "string", False, "tabs", "Match a faction name or the creator's username."),
|
||||
field("filter", "query", "enum", False, "active", "Which battles to list.", options=FILTER_KEYS),
|
||||
field("page", "query", "integer", False, "1", "1-based page number."),
|
||||
],
|
||||
sample_response={"battles": [SAMPLE_WAR], "counts": {"active": 3, "ended": 12, "mine": 1}},
|
||||
),
|
||||
endpoint(
|
||||
id="battles-get",
|
||||
method="GET",
|
||||
path="/battles/{uid}",
|
||||
title="Battle state",
|
||||
summary="One battle's full serialized state, resolving it first when its week is over.",
|
||||
auth="public",
|
||||
params=[
|
||||
field("uid", "path", "string", True, SAMPLE_WAR["uid"], "Battle uid."),
|
||||
],
|
||||
sample_response=SAMPLE_WAR,
|
||||
),
|
||||
endpoint(
|
||||
id="battles-events",
|
||||
method="GET",
|
||||
path="/battles/{uid}/events",
|
||||
title="Battle events",
|
||||
summary="The ordered battle event log, replayable incrementally with the after cursor.",
|
||||
auth="public",
|
||||
params=[
|
||||
field("uid", "path", "string", True, SAMPLE_WAR["uid"], "Battle uid."),
|
||||
field("after", "query", "integer", False, "0", "Return only events with a seq greater than this."),
|
||||
field("limit", "query", "integer", False, "500", "Maximum events to return."),
|
||||
],
|
||||
sample_response={"events": SAMPLE_WAR["recent_events"], "status": "active"},
|
||||
),
|
||||
endpoint(
|
||||
id="battles-join",
|
||||
method="POST",
|
||||
path="/battles/{uid}/join",
|
||||
title="Join or switch faction",
|
||||
summary="Join faction a or b, or switch an existing fighter to the other side.",
|
||||
auth="user",
|
||||
encoding="form",
|
||||
params=[
|
||||
field("uid", "path", "string", True, SAMPLE_WAR["uid"], "Battle uid."),
|
||||
field("faction", "form", "enum", True, "a", "Which side to join or switch to.", options=["a", "b"]),
|
||||
],
|
||||
sample_response={"ok": True, "redirect": SAMPLE_WAR["post_url"], "data": {"war": SAMPLE_WAR}},
|
||||
),
|
||||
endpoint(
|
||||
id="battles-fight",
|
||||
method="POST",
|
||||
path="/battles/{uid}/fight",
|
||||
title="Fight",
|
||||
summary=(
|
||||
f"Spend {rules.FIGHT_COST_COINS} Code Farm coins and deal level-weighted HP damage "
|
||||
f"for your faction. Once per {rules.FIGHT_COOLDOWN_HOURS} hours per battle."
|
||||
),
|
||||
auth="user",
|
||||
encoding="form",
|
||||
params=[
|
||||
field("uid", "path", "string", True, SAMPLE_WAR["uid"], "Battle uid."),
|
||||
],
|
||||
sample_response={"ok": True, "redirect": SAMPLE_WAR["post_url"], "data": {"war": SAMPLE_WAR, "damage": 300}},
|
||||
),
|
||||
],
|
||||
}
|
||||
@@ -64,6 +64,33 @@ four ways to sign requests.
|
||||
field("before", "query", "string", False, "", "Pagination cursor."),
|
||||
],
|
||||
),
|
||||
endpoint(
|
||||
id="topics-hub",
|
||||
method="GET",
|
||||
path="/topics",
|
||||
title="Browse topics",
|
||||
summary="The topics hub - every post topic with its live post count, linking to its own crawlable listing page.",
|
||||
auth="public",
|
||||
interactive=True,
|
||||
),
|
||||
endpoint(
|
||||
id="topics-list",
|
||||
method="GET",
|
||||
path="/topics/{topic}",
|
||||
title="Browse one topic",
|
||||
summary=(
|
||||
"A single topic's post listing, on its own permanent, crawlable URL (unlike /feed?topic=, "
|
||||
"whose canonical collapses back to /feed). Same author-interleaved pagination as the feed."
|
||||
),
|
||||
auth="public",
|
||||
interactive=True,
|
||||
params=[
|
||||
field(
|
||||
"topic", "path", "enum", True, "devlog", "Topic key.", TOPICS
|
||||
),
|
||||
field("before", "query", "string", False, "", "Pagination cursor."),
|
||||
],
|
||||
),
|
||||
endpoint(
|
||||
id="posts-create",
|
||||
method="POST",
|
||||
@@ -117,6 +144,22 @@ four ways to sign requests.
|
||||
"",
|
||||
"Repeat the field for each poll option, or send a single newline- or comma-separated string (2-6 options).",
|
||||
),
|
||||
field(
|
||||
"war_faction_a",
|
||||
"form",
|
||||
"string",
|
||||
False,
|
||||
"",
|
||||
"Optional Opinion War faction A name (max 30 chars). Both faction names start the week-long battle.",
|
||||
),
|
||||
field(
|
||||
"war_faction_b",
|
||||
"form",
|
||||
"string",
|
||||
False,
|
||||
"",
|
||||
"Optional Opinion War faction B name (max 30 chars). Must differ from faction A.",
|
||||
),
|
||||
],
|
||||
notes=["Returns a `302` redirect to `/posts/{slug}` on success."],
|
||||
),
|
||||
@@ -362,7 +405,7 @@ four ways to sign requests.
|
||||
method="GET",
|
||||
path="/projects/{project_slug}",
|
||||
title="View a project",
|
||||
summary="Render a project with comments. Returns an HTML page.",
|
||||
summary="Render the project overview with its devlog, screenshots and comments. Returns an HTML page.",
|
||||
auth="public",
|
||||
interactive=True,
|
||||
params=[
|
||||
@@ -373,7 +416,42 @@ four ways to sign requests.
|
||||
True,
|
||||
"PROJECT_SLUG",
|
||||
"Slug or UID of the project.",
|
||||
)
|
||||
),
|
||||
field(
|
||||
"before",
|
||||
"query",
|
||||
"string",
|
||||
False,
|
||||
"",
|
||||
"Devlog pagination cursor (devlog_next_cursor from the previous page).",
|
||||
),
|
||||
],
|
||||
),
|
||||
endpoint(
|
||||
id="projects-screenshots",
|
||||
method="POST",
|
||||
path="/projects/{project_slug}/screenshots",
|
||||
title="Add screenshots to a project",
|
||||
summary="Link uploaded image attachments to an owned project's Screenshots gallery. Redirects to the gallery.",
|
||||
auth="user",
|
||||
encoding="form",
|
||||
params=[
|
||||
field(
|
||||
"project_slug",
|
||||
"path",
|
||||
"string",
|
||||
True,
|
||||
"my-project-1a2b3c4d",
|
||||
"Project slug or uid.",
|
||||
),
|
||||
field(
|
||||
"attachment_uids",
|
||||
"form",
|
||||
"string",
|
||||
True,
|
||||
"ATTACHMENT_UID",
|
||||
"Comma-separated attachment uids from POST /uploads/upload or /uploads/upload-url.",
|
||||
),
|
||||
],
|
||||
),
|
||||
endpoint(
|
||||
@@ -443,6 +521,38 @@ four ways to sign requests.
|
||||
"31/12/2026",
|
||||
"Optional demo date in DD/MM/YYYY format.",
|
||||
),
|
||||
field(
|
||||
"website_url",
|
||||
"form",
|
||||
"string",
|
||||
False,
|
||||
"https://myproject.dev",
|
||||
"Optional official website URL (http/https).",
|
||||
),
|
||||
field(
|
||||
"repo_url",
|
||||
"form",
|
||||
"string",
|
||||
False,
|
||||
"https://github.com/me/project",
|
||||
"Optional source repository URL (http/https).",
|
||||
),
|
||||
field(
|
||||
"cover_attachment_uid",
|
||||
"form",
|
||||
"string",
|
||||
False,
|
||||
"ATTACHMENT_UID",
|
||||
"Optional attachment uid of an uploaded cover image.",
|
||||
),
|
||||
field(
|
||||
"logo_attachment_uid",
|
||||
"form",
|
||||
"string",
|
||||
False,
|
||||
"ATTACHMENT_UID",
|
||||
"Optional attachment uid of an uploaded project logo.",
|
||||
),
|
||||
],
|
||||
),
|
||||
endpoint(
|
||||
@@ -520,6 +630,38 @@ four ways to sign requests.
|
||||
"31/12/2026",
|
||||
"Optional demo date in DD/MM/YYYY format.",
|
||||
),
|
||||
field(
|
||||
"website_url",
|
||||
"form",
|
||||
"string",
|
||||
False,
|
||||
"https://myproject.dev",
|
||||
"Optional official website URL (http/https).",
|
||||
),
|
||||
field(
|
||||
"repo_url",
|
||||
"form",
|
||||
"string",
|
||||
False,
|
||||
"https://github.com/me/project",
|
||||
"Optional source repository URL (http/https).",
|
||||
),
|
||||
field(
|
||||
"cover_attachment_uid",
|
||||
"form",
|
||||
"string",
|
||||
False,
|
||||
"ATTACHMENT_UID",
|
||||
"Optional attachment uid of an uploaded cover image.",
|
||||
),
|
||||
field(
|
||||
"logo_attachment_uid",
|
||||
"form",
|
||||
"string",
|
||||
False,
|
||||
"ATTACHMENT_UID",
|
||||
"Optional attachment uid of an uploaded project logo.",
|
||||
),
|
||||
],
|
||||
),
|
||||
endpoint(
|
||||
|
||||
@@ -77,9 +77,16 @@ managed by administrators on the **Gateway** page (`/admin/gateway`).
|
||||
|
||||
## Per-call cost and usage headers
|
||||
|
||||
Every gateway response - chat, embeddings, and passthrough, on both success and error - carries
|
||||
`X-Gateway-*` response headers describing that single call, so a client can read its own token usage
|
||||
and dollar cost directly from the response with no extra request:
|
||||
Every NON-STREAMING gateway response - chat, embeddings, images, and passthrough, on both success
|
||||
and error - carries `X-Gateway-*` response headers describing that single call, so a client can read
|
||||
its own token usage and dollar cost directly from the response with no extra request. A streaming
|
||||
chat response (`"stream": true`) is the one exception: it carries only `X-Gateway-Model`,
|
||||
`X-Gateway-Backend`, and `X-App-Reference` - HTTP headers must be sent before the body, and cost/token
|
||||
counts for a streamed call are only known once the stream ends, so they cannot be response headers on
|
||||
that same response. The call is still fully metered server-side, and a client that sends
|
||||
`"stream_options": {"include_usage": true}` still receives the upstream's real `usage` object on the
|
||||
final SSE chunk, exactly as the underlying provider's own streaming API works - it is just not
|
||||
summarized into headers.
|
||||
|
||||
| Header | Meaning |
|
||||
|--------|---------|
|
||||
@@ -161,11 +168,20 @@ for signing DevPlace's own requests.
|
||||
"false",
|
||||
"Set true for a streamed SSE response.",
|
||||
),
|
||||
field(
|
||||
"think",
|
||||
"json",
|
||||
"string",
|
||||
False,
|
||||
"false",
|
||||
"Optional thinking override. Omitted: the gateway disables thinking (fast path). true / high / medium / low enables it; false disables it. DeepSeek-native `thinking.type` and OpenRouter `reasoning.effort` are also accepted.",
|
||||
),
|
||||
],
|
||||
notes=[
|
||||
"Returns `503` when the gateway service is not running.",
|
||||
"Every response carries the `X-Gateway-*` token and dollar-cost headers (see Per-call cost and usage headers above), including the streamed SSE response.",
|
||||
"A non-streaming response carries the `X-Gateway-*` token and dollar-cost headers (see Per-call cost and usage headers above). A streaming response (`stream: true`) is forwarded from the upstream in real time and carries only `X-Gateway-Model`/`X-Gateway-Backend`/`X-App-Reference` - request `stream_options: {\"include_usage\": true}` to receive the real token usage on the final SSE chunk instead.",
|
||||
"If `model` matches a configured model route it is forwarded to that route's provider, upstream model, and per-model pricing (with an optional vision model); otherwise it falls through to the default upstream (see Model routing and providers above).",
|
||||
"Thinking is disabled by default. Pass `think: true` (or `thinking: {\"type\": \"enabled\"}`) to turn it on for that call.",
|
||||
],
|
||||
),
|
||||
endpoint(
|
||||
|
||||
@@ -41,6 +41,14 @@ four ways to sign requests.
|
||||
"",
|
||||
"Jump to a conversation by username.",
|
||||
),
|
||||
field(
|
||||
"before",
|
||||
"query",
|
||||
"string",
|
||||
False,
|
||||
"",
|
||||
"ISO timestamp. When set, return the page of messages strictly older than this instant (for loading earlier history).",
|
||||
),
|
||||
],
|
||||
),
|
||||
endpoint(
|
||||
|
||||
@@ -14,9 +14,11 @@ implements the Web Push protocol: fetch the public VAPID key, then register a
|
||||
Push Notification service device token and is only offered when an administrator has
|
||||
configured it.
|
||||
|
||||
`GET /push.json` lists the providers that currently accept registrations. A registration
|
||||
body without a `provider` field is a `webpush` registration, so existing clients need no
|
||||
change.
|
||||
`GET /push.json` lists the providers that currently accept registrations. When `apns` is
|
||||
active it includes `environment` (`production` or `sandbox`) so a native client can match
|
||||
its build. A registration body without a `provider` field is a `webpush` registration, so
|
||||
existing clients need no change. An APNs body may include a stable `client_id` so a later
|
||||
token rotation updates the same device instead of inserting another row.
|
||||
|
||||
There is no server-side unsubscribe endpoint: unsubscription is handled entirely in the
|
||||
browser by calling `PushManager.unsubscribe()` on the subscription. The server stops delivering
|
||||
@@ -37,7 +39,10 @@ four ways to sign requests.
|
||||
auth="public",
|
||||
sample_response={
|
||||
"publicKey": "BASE64_VAPID_KEY",
|
||||
"providers": {"webpush": {"publicKey": "BASE64_VAPID_KEY"}},
|
||||
"providers": {
|
||||
"webpush": {"publicKey": "BASE64_VAPID_KEY"},
|
||||
"apns": {"environment": "production"},
|
||||
},
|
||||
},
|
||||
),
|
||||
endpoint(
|
||||
@@ -80,15 +85,24 @@ four ways to sign requests.
|
||||
"string",
|
||||
False,
|
||||
"a1b2c3...",
|
||||
"Hexadecimal device token. Required for apns.",
|
||||
"Hexadecimal device token. Required for apns. Spaces and angle brackets are stripped.",
|
||||
),
|
||||
field(
|
||||
"client_id",
|
||||
"json",
|
||||
"string",
|
||||
False,
|
||||
"vendor-uuid",
|
||||
"Stable per-device id for apns. When present, a new token updates this device instead of inserting a row.",
|
||||
),
|
||||
],
|
||||
notes=[
|
||||
'A webpush body is JSON: `{"endpoint": "...", "keys": {"p256dh": "...", "auth": "..."}}`.',
|
||||
'An APNs body is JSON: `{"provider": "apns", "token": "..."}`.',
|
||||
'An APNs body is JSON: `{"provider": "apns", "token": "...", "client_id": "..."}`. `client_id` is optional; token-only bodies keep working and revive a previously dead token.',
|
||||
"A provider that is unknown, disabled or unconfigured returns 400.",
|
||||
"A newly created or revived registration is probed immediately. The response then includes `delivered` and, on failure, `error` with the provider reason. `registered` stays true so existing clients keep working.",
|
||||
],
|
||||
sample_response={"registered": True},
|
||||
sample_response={"registered": True, "delivered": True},
|
||||
),
|
||||
],
|
||||
}
|
||||
|
||||
@@ -226,6 +226,7 @@ status and report.
|
||||
{"title": "Invention", "detail": "...", "confidence": 0.8, "citations": [1]}
|
||||
],
|
||||
"sources": [{"url": "https://example.com", "title": "Example", "source": "httpx"}],
|
||||
"follow_up_questions": ["Who else worked on the transistor?", "How did it replace vacuum tubes?"],
|
||||
"chat_ws_url": "/tools/deepsearch/DEEPSEARCH_JOB_UID/chat",
|
||||
"export_md_url": "/tools/deepsearch/DEEPSEARCH_JOB_UID/export.md",
|
||||
"export_json_url": "/tools/deepsearch/DEEPSEARCH_JOB_UID/export.json",
|
||||
|
||||
@@ -8,14 +8,22 @@ GROUP = {
|
||||
"intro": """
|
||||
# Dev Workspaces
|
||||
|
||||
A workspace is a browser VS Code environment attached to one of your projects. It runs your project
|
||||
files, a terminal, and preinstalled Python, Rust, Nim and Swift toolchains. `sudo` and
|
||||
`apt install` work with no extra setup; ports below 1024 cannot bind, so use a high port and publish
|
||||
it through a tunnel.
|
||||
A workspace is a browser editor attached to one of your projects. It runs your project files, a
|
||||
terminal, and preinstalled Python, Rust, Nim and Swift toolchains. `sudo` and `apt install` work
|
||||
with no extra setup; ports below 1024 cannot bind, so use a high port and publish it through a
|
||||
tunnel.
|
||||
|
||||
The editor opens with a **DevPlace Code** terminal already running the `dpc` coding agent and a
|
||||
plain shell beside it, and it trusts every folder, so nothing opens in Restricted Mode. Its
|
||||
appearance and boot behaviour are your own preferences, readable and writable through the two
|
||||
`/workspace/editor` endpoints below and explained on
|
||||
[the workspace editor page](/docs/workspace-editor.html). Editor preferences apply on the next
|
||||
workspace start.
|
||||
|
||||
A **tunnel** publishes one port from inside your container on a public HTTPS hostname of the form
|
||||
`<port>-<name>.tunnel.pravda.education`. **Tunnel URLs are public and unauthenticated** - anyone with
|
||||
the link can reach whatever you are serving.
|
||||
the link can reach whatever you are serving. Forwarding a port in the editor's **Ports** view creates
|
||||
the tunnel for you through the same endpoint; un-forwarding it does not remove the tunnel.
|
||||
|
||||
Workspaces are bounded: a count limit per user, a disk quota, an egress quota, and a tunnel limit.
|
||||
An idle workspace is warned about, then stopped, then warned again, then removed. Every warning
|
||||
@@ -29,7 +37,10 @@ arrives as a `workspace` notification and states exactly what happens next and w
|
||||
title="Read workspace",
|
||||
summary=(
|
||||
"State, quota usage, idle countdown, tunnels and open moderation flags "
|
||||
"for your workspace on this project."
|
||||
"for your workspace on this project. The phase (stopped, starting, ready, "
|
||||
"stopping, crashed, suspended) is derived from the desired state, the "
|
||||
"container status and a live probe of the editor port, so editor_ready "
|
||||
"is true only when the editor will actually open."
|
||||
),
|
||||
auth="user",
|
||||
params=[
|
||||
@@ -43,7 +54,12 @@ arrives as a `workspace` notification and states exactly what happens next and w
|
||||
"editor_url": "/projects/my-project/containers/instances/INSTANCE_UID/code/",
|
||||
"workspace": {
|
||||
"uid": "INSTANCE_UID",
|
||||
"owner_uid": "USER_UID",
|
||||
"status": "running",
|
||||
"desired_state": "running",
|
||||
"phase": "ready",
|
||||
"phase_label": "Ready",
|
||||
"editor_ready": True,
|
||||
"suspended": False,
|
||||
"tunnel_name": "brave-otter",
|
||||
"primary_url": "https://brave-otter.tunnel.pravda.education",
|
||||
@@ -101,6 +117,88 @@ arrives as a `workspace` notification and states exactly what happens next and w
|
||||
],
|
||||
sample_response={"ok": True, "redirect": "/projects/my-project/workspace"},
|
||||
),
|
||||
endpoint(
|
||||
id="workspace-editor-get",
|
||||
method="GET",
|
||||
path="/projects/{slug}/workspace/editor",
|
||||
title="Read editor profile",
|
||||
summary=(
|
||||
"The resolved DevPlace editor profile for this workspace: theme, layout, "
|
||||
"panel preset, font sizes, zoom, boot terminals, how the editor opens, the "
|
||||
"container size, and where each value comes from."
|
||||
),
|
||||
auth="user",
|
||||
params=[
|
||||
field("slug", "path", "string", True, "my-project", "Project slug or uid."),
|
||||
],
|
||||
sample_response={
|
||||
"editor": {
|
||||
"trust_all": True,
|
||||
"theme": "devplace-dark",
|
||||
"font_size": 14,
|
||||
"terminal_font_size": 13,
|
||||
"zoom_level": 0,
|
||||
"layout": "standard",
|
||||
"panel_preset": "normal",
|
||||
"boot_agent": "dpc",
|
||||
"boot_shell": True,
|
||||
"window_mode": "tab",
|
||||
"window_width": 1600,
|
||||
"window_height": 1000,
|
||||
"cpu_millicores": 2000,
|
||||
"cpu_cores": 2.0,
|
||||
"memory_mb": 2048,
|
||||
"disk_quota_mb": 2048,
|
||||
"sources": {"theme": "user", "font_size": "site"},
|
||||
},
|
||||
"restart_required": False,
|
||||
},
|
||||
),
|
||||
endpoint(
|
||||
id="workspace-editor-set",
|
||||
method="POST",
|
||||
path="/projects/{slug}/workspace/editor",
|
||||
title="Set editor preferences",
|
||||
summary=(
|
||||
"Change your own editor preferences. Only the fields you send are "
|
||||
"changed; within those, an empty string or zero means inherit the site "
|
||||
"default, and `reset` drops every preference. Applies on the next "
|
||||
"workspace start, and the response says whether a restart is needed."
|
||||
),
|
||||
auth="user",
|
||||
params=[
|
||||
field("slug", "path", "string", True, "my-project", "Project slug or uid."),
|
||||
field("theme", "body", "string", False, "devplace-dark",
|
||||
"devplace-dark, devplace-light or system."),
|
||||
field("layout", "body", "string", False, "standard",
|
||||
"standard, terminal-focus or zen."),
|
||||
field("panel_preset", "body", "string", False, "tall",
|
||||
"short, normal, tall or maximized."),
|
||||
field("font_size", "body", "integer", False, "14",
|
||||
"Editor font size in pixels. Zero inherits."),
|
||||
field("terminal_font_size", "body", "integer", False, "13",
|
||||
"Terminal font size in pixels. Zero inherits."),
|
||||
field("zoom_level", "body", "integer", False, "0",
|
||||
"Window zoom, -5 to 5. Send -99 to inherit."),
|
||||
field("boot_agent", "body", "string", False, "dpc",
|
||||
"dpc or none."),
|
||||
field("boot_shell", "body", "integer", False, "1",
|
||||
"1 opens a shell on boot, 0 skips it, -1 inherits."),
|
||||
field("window_mode", "body", "string", False, "tab",
|
||||
"tab, window or fullscreen."),
|
||||
field("window_width", "body", "integer", False, "1600",
|
||||
"Editor window width in pixels. Zero inherits."),
|
||||
field("window_height", "body", "integer", False, "1000",
|
||||
"Editor window height in pixels. Zero inherits."),
|
||||
field("reset", "body", "boolean", False, "false",
|
||||
"Drop every preference and fall back to the site defaults."),
|
||||
],
|
||||
sample_response={
|
||||
"ok": True,
|
||||
"redirect": "/projects/my-project/workspace",
|
||||
"data": {"restart_required": True},
|
||||
},
|
||||
),
|
||||
endpoint(
|
||||
id="workspace-tunnels-list",
|
||||
method="GET",
|
||||
@@ -133,7 +231,9 @@ arrives as a `workspace` notification and states exactly what happens next and w
|
||||
title="Create tunnel",
|
||||
summary=(
|
||||
"Publish a container port on a public HTTPS hostname. The URL is public "
|
||||
"and unauthenticated. Refused past the tunnel limit."
|
||||
"and unauthenticated. Refused past the tunnel limit. The certificate is "
|
||||
"ordered right away, so the hostname answers plain HTTP for a few seconds "
|
||||
"before it serves HTTPS. Forwarding a port in the editor calls this for you."
|
||||
),
|
||||
auth="user",
|
||||
params=[
|
||||
|
||||
@@ -117,6 +117,25 @@ def build_services_group(services, base):
|
||||
)
|
||||
)
|
||||
control_endpoints = [
|
||||
endpoint(
|
||||
id="services-page",
|
||||
method="GET",
|
||||
path="/admin/services",
|
||||
title="Services dashboard",
|
||||
summary="Admin HTML index of every registered background service, its status, and controls.",
|
||||
auth="admin",
|
||||
interactive=False,
|
||||
),
|
||||
endpoint(
|
||||
id="services-detail-page",
|
||||
method="GET",
|
||||
path="/admin/services/{name}",
|
||||
title="Service detail page",
|
||||
summary="Admin HTML detail page for a single service: overview, configuration form, and logs.",
|
||||
auth="admin",
|
||||
interactive=False,
|
||||
params=[field("name", "path", "string", True, "news", "Service name.")],
|
||||
),
|
||||
endpoint(
|
||||
id="services-data",
|
||||
method="GET",
|
||||
|
||||
+21
-2
@@ -49,8 +49,10 @@ 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.routers import (
|
||||
auth,
|
||||
battles,
|
||||
feed,
|
||||
posts,
|
||||
topics,
|
||||
comments,
|
||||
projects,
|
||||
profile,
|
||||
@@ -106,6 +108,7 @@ 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
|
||||
@@ -119,6 +122,7 @@ 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.audit import record as audit
|
||||
from devplacepy.services.push import PushService
|
||||
from devplacepy.services.telegram import TelegramService
|
||||
@@ -271,6 +275,7 @@ async def lifespan(app: FastAPI):
|
||||
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())
|
||||
@@ -283,6 +288,7 @@ async def lifespan(app: FastAPI):
|
||||
service_manager.register(XmlrpcService())
|
||||
service_manager.register(AuditService())
|
||||
service_manager.register(ModerationService())
|
||||
service_manager.register(AcceptanceService())
|
||||
service_manager.register(PushService())
|
||||
service_manager.register(TelegramService())
|
||||
service_manager.register(TelegramOutboxService())
|
||||
@@ -310,6 +316,9 @@ async def lifespan(app: FastAPI):
|
||||
from devplacepy.services.containers import forward
|
||||
|
||||
await forward.close_client()
|
||||
from devplacepy import push
|
||||
|
||||
await push.shutdown_providers()
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
@@ -466,6 +475,7 @@ async def on_validation_error(request: Request, exc: RequestValidationError):
|
||||
app.include_router(auth.router, prefix="/auth")
|
||||
app.include_router(feed.router, prefix="/feed")
|
||||
app.include_router(posts.router, prefix="/posts")
|
||||
app.include_router(topics.router, prefix="/topics")
|
||||
app.include_router(comments.router, prefix="/comments")
|
||||
app.include_router(projects.router, prefix="/projects")
|
||||
app.include_router(profile.router, prefix="/profile")
|
||||
@@ -502,6 +512,7 @@ app.include_router(dbapi.router, prefix="/dbapi")
|
||||
app.include_router(pubsub.router, prefix="/pubsub")
|
||||
app.include_router(game.router, prefix="/game")
|
||||
app.include_router(quizzes.router, prefix="/quizzes")
|
||||
app.include_router(battles.router, prefix="/battles")
|
||||
app.include_router(workspaces.router, prefix="/workspaces")
|
||||
|
||||
|
||||
@@ -521,6 +532,15 @@ async def await_pending_corrections(request: Request, call_next):
|
||||
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)
|
||||
@@ -530,10 +550,9 @@ async def add_security_headers(request: Request, call_next):
|
||||
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["X-Frame-Options"] = "DENY"
|
||||
response.headers["Content-Security-Policy"] = (
|
||||
"object-src 'none'; base-uri 'self'; "
|
||||
"frame-ancestors 'none'; form-action '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"
|
||||
|
||||
+72
-1
@@ -5,7 +5,7 @@ import re
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Literal, Optional
|
||||
from urllib.parse import urlsplit
|
||||
from urllib.parse import urlsplit, urlparse
|
||||
from pydantic import BaseModel, Field, field_validator, model_validator
|
||||
from devplacepy.constants import TOPICS
|
||||
from devplacepy.rendering import is_single_emoji
|
||||
@@ -33,6 +33,20 @@ def normalize_european_date(value):
|
||||
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 []
|
||||
@@ -162,6 +176,8 @@ class PostForm(BaseModel):
|
||||
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
|
||||
@@ -184,6 +200,17 @@ class PostForm(BaseModel):
|
||||
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)
|
||||
@@ -241,6 +268,10 @@ class ProjectForm(BaseModel):
|
||||
)
|
||||
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] = []
|
||||
|
||||
@@ -249,6 +280,11 @@ class ProjectForm(BaseModel):
|
||||
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)
|
||||
@@ -260,12 +296,21 @@ class ProjectEditForm(BaseModel):
|
||||
)
|
||||
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 BackupRunForm(BaseModel):
|
||||
target: Literal["database", "uploads", "keys", "full"] = "full"
|
||||
@@ -290,6 +335,10 @@ class ProjectFlagForm(BaseModel):
|
||||
value: bool = False
|
||||
|
||||
|
||||
class ProjectScreenshotsForm(BaseModel):
|
||||
attachment_uids: list[str] = []
|
||||
|
||||
|
||||
class CustomizationToggleForm(BaseModel):
|
||||
value: bool = False
|
||||
|
||||
@@ -621,6 +670,7 @@ class AdminSettingsForm(BaseModel):
|
||||
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)
|
||||
@@ -645,6 +695,9 @@ class AdminSettingsForm(BaseModel):
|
||||
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)
|
||||
|
||||
@field_validator("moderation_filter_mode")
|
||||
@@ -959,6 +1012,21 @@ class TunnelForm(BaseModel):
|
||||
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
|
||||
|
||||
|
||||
class WorkspaceQuotaForm(BaseModel):
|
||||
owner_id: str = Field(default="", max_length=36)
|
||||
label: str = Field(default="", max_length=64)
|
||||
@@ -968,6 +1036,8 @@ class WorkspaceQuotaForm(BaseModel):
|
||||
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):
|
||||
@@ -1066,3 +1136,4 @@ class MaturePreferenceForm(BaseModel):
|
||||
class AccountDeleteForm(BaseModel):
|
||||
password: str = Field(min_length=1, max_length=128)
|
||||
confirm_text: str = Field(default="", max_length=40)
|
||||
|
||||
|
||||
+154
-32
@@ -477,6 +477,7 @@ def delete_node(project_uid: str, raw_path: str, deleted_by: str = "system") ->
|
||||
def soft_delete_all_project_files(project_uid: str, deleted_by: str) -> None:
|
||||
if "project_files" not in db.tables:
|
||||
return
|
||||
clear_sync_state(project_uid)
|
||||
stamp = _now()
|
||||
for row in _table().find(project_uid=project_uid, deleted_at=None):
|
||||
_table().update(
|
||||
@@ -486,6 +487,7 @@ def soft_delete_all_project_files(project_uid: str, deleted_by: str) -> None:
|
||||
|
||||
|
||||
_SYNC_CLOCK_SKEW_SECONDS = 1.0
|
||||
SYNC_STATE_TABLE = "project_file_sync_state"
|
||||
|
||||
|
||||
def _epoch_of(iso_timestamp) -> float:
|
||||
@@ -497,6 +499,48 @@ def _epoch_of(iso_timestamp) -> float:
|
||||
return 0.0
|
||||
|
||||
|
||||
def _sync_state_table():
|
||||
return get_table(SYNC_STATE_TABLE)
|
||||
|
||||
|
||||
def _load_sync_manifest(project_uid: str) -> dict:
|
||||
if SYNC_STATE_TABLE not in db.tables:
|
||||
return {}
|
||||
return {
|
||||
row["path"]: {"db_epoch": row["db_epoch"], "fs_epoch": row["fs_epoch"]}
|
||||
for row in _sync_state_table().find(project_uid=project_uid)
|
||||
}
|
||||
|
||||
|
||||
def _save_sync_manifest(project_uid: str, old: dict, new: dict) -> None:
|
||||
table = _sync_state_table()
|
||||
for path in old:
|
||||
if path not in new:
|
||||
table.delete(project_uid=project_uid, path=path)
|
||||
for path, entry in new.items():
|
||||
if old.get(path) == entry:
|
||||
continue
|
||||
table.upsert(
|
||||
{"project_uid": project_uid, "path": path, **entry},
|
||||
["project_uid", "path"],
|
||||
)
|
||||
|
||||
|
||||
def clear_sync_state(project_uid: str) -> None:
|
||||
if SYNC_STATE_TABLE not in db.tables:
|
||||
return
|
||||
_sync_state_table().delete(project_uid=project_uid)
|
||||
|
||||
|
||||
def _delete_db_row_for_sync(row: dict, deleted_by: str) -> None:
|
||||
_table().update(
|
||||
{"uid": row["uid"], "deleted_at": _now(), "deleted_by": deleted_by},
|
||||
["uid"],
|
||||
)
|
||||
if row.get("is_binary"):
|
||||
_unlink_blob(row)
|
||||
|
||||
|
||||
def _file_records(project_uid: str) -> dict:
|
||||
records: dict = {}
|
||||
for row in _table().find(project_uid=project_uid, deleted_at=None):
|
||||
@@ -536,48 +580,121 @@ def _workspace_records(workspace) -> dict:
|
||||
|
||||
|
||||
def sync_dir_bidirectional(project_uid: str, workspace, user: dict) -> dict:
|
||||
empty = {
|
||||
"exported": 0,
|
||||
"imported": 0,
|
||||
"deleted_in_project": 0,
|
||||
"deleted_in_workspace": 0,
|
||||
}
|
||||
if "project_files" not in db.tables:
|
||||
return {"exported": 0, "imported": 0}
|
||||
return empty
|
||||
readonly = is_readonly(project_uid)
|
||||
dest = Path(workspace).resolve()
|
||||
dest.mkdir(parents=True, exist_ok=True)
|
||||
db_files = _file_records(project_uid)
|
||||
fs_files = _workspace_records(dest)
|
||||
exported = 0
|
||||
imported = 0
|
||||
manifest = _load_sync_manifest(project_uid)
|
||||
new_manifest: dict = {}
|
||||
counts = dict(empty)
|
||||
|
||||
for path, row in db_files.items():
|
||||
for path in set(manifest) | set(db_files) | set(fs_files):
|
||||
row = db_files.get(path)
|
||||
fs_full = fs_files.get(path)
|
||||
if fs_full is None:
|
||||
_export_node(row, dest)
|
||||
exported += 1
|
||||
continue
|
||||
try:
|
||||
fs_mtime = fs_full.stat().st_mtime
|
||||
except OSError:
|
||||
continue
|
||||
db_mtime = _epoch_of(row.get("updated_at"))
|
||||
if db_mtime >= fs_mtime - _SYNC_CLOCK_SKEW_SECONDS:
|
||||
_export_node(row, dest)
|
||||
exported += 1
|
||||
elif not readonly:
|
||||
if _import_file(project_uid, user, path, fs_full):
|
||||
imported += 1
|
||||
entry = manifest.get(path)
|
||||
|
||||
if not readonly:
|
||||
for path, fs_full in fs_files.items():
|
||||
if path in db_files:
|
||||
if row is not None and fs_full is not None:
|
||||
try:
|
||||
fs_epoch = fs_full.stat().st_mtime
|
||||
except OSError:
|
||||
continue
|
||||
if _import_file(project_uid, user, path, fs_full):
|
||||
imported += 1
|
||||
db_epoch = _epoch_of(row.get("updated_at"))
|
||||
if (
|
||||
entry is not None
|
||||
and abs(db_epoch - entry["db_epoch"]) <= _SYNC_CLOCK_SKEW_SECONDS
|
||||
and abs(fs_epoch - entry["fs_epoch"]) <= _SYNC_CLOCK_SKEW_SECONDS
|
||||
):
|
||||
new_manifest[path] = entry
|
||||
continue
|
||||
if db_epoch >= fs_epoch - _SYNC_CLOCK_SKEW_SECONDS or readonly:
|
||||
recorded = _record_export(row, dest)
|
||||
if recorded:
|
||||
counts["exported"] += 1
|
||||
new_manifest[path] = recorded
|
||||
else:
|
||||
recorded = _record_import(project_uid, user, path, fs_full, fs_epoch)
|
||||
if recorded:
|
||||
counts["imported"] += 1
|
||||
new_manifest[path] = recorded
|
||||
continue
|
||||
|
||||
return {"exported": exported, "imported": imported}
|
||||
if row is not None and fs_full is None:
|
||||
db_epoch = _epoch_of(row.get("updated_at"))
|
||||
if (
|
||||
entry is None
|
||||
or readonly
|
||||
or db_epoch > entry["db_epoch"] + _SYNC_CLOCK_SKEW_SECONDS
|
||||
):
|
||||
recorded = _record_export(row, dest)
|
||||
if recorded:
|
||||
counts["exported"] += 1
|
||||
new_manifest[path] = recorded
|
||||
else:
|
||||
_delete_db_row_for_sync(row, user["uid"])
|
||||
counts["deleted_in_project"] += 1
|
||||
continue
|
||||
|
||||
if row is None and fs_full is not None:
|
||||
try:
|
||||
fs_epoch = fs_full.stat().st_mtime
|
||||
except OSError:
|
||||
continue
|
||||
if entry is None:
|
||||
if not readonly:
|
||||
recorded = _record_import(project_uid, user, path, fs_full, fs_epoch)
|
||||
if recorded:
|
||||
counts["imported"] += 1
|
||||
new_manifest[path] = recorded
|
||||
continue
|
||||
if not readonly and fs_epoch > entry["fs_epoch"] + _SYNC_CLOCK_SKEW_SECONDS:
|
||||
recorded = _record_import(project_uid, user, path, fs_full, fs_epoch)
|
||||
if recorded:
|
||||
counts["imported"] += 1
|
||||
new_manifest[path] = recorded
|
||||
continue
|
||||
try:
|
||||
fs_full.unlink()
|
||||
counts["deleted_in_workspace"] += 1
|
||||
except OSError:
|
||||
pass
|
||||
continue
|
||||
|
||||
_save_sync_manifest(project_uid, manifest, new_manifest)
|
||||
return counts
|
||||
|
||||
|
||||
def _export_node(row: dict, dest: Path) -> None:
|
||||
def _record_export(row: dict, dest: Path):
|
||||
target = _export_node(row, dest)
|
||||
if target is None:
|
||||
return None
|
||||
db_epoch = _epoch_of(row.get("updated_at"))
|
||||
try:
|
||||
fs_epoch = target.stat().st_mtime
|
||||
except OSError:
|
||||
fs_epoch = db_epoch
|
||||
return {"db_epoch": db_epoch, "fs_epoch": fs_epoch}
|
||||
|
||||
|
||||
def _record_import(project_uid: str, user: dict, path: str, fs_full: Path, fs_epoch: float):
|
||||
imported = _import_file(project_uid, user, path, fs_full)
|
||||
if imported is None:
|
||||
return None
|
||||
return {"db_epoch": _epoch_of(imported.get("updated_at")), "fs_epoch": fs_epoch}
|
||||
|
||||
|
||||
def _export_node(row: dict, dest: Path):
|
||||
target = (dest / row["path"]).resolve()
|
||||
if target != dest and not target.is_relative_to(dest):
|
||||
return
|
||||
return None
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
if target.is_symlink():
|
||||
target.unlink()
|
||||
@@ -587,20 +704,21 @@ def _export_node(row: dict, dest: Path) -> None:
|
||||
shutil.copyfile(src, target)
|
||||
except (FileNotFoundError, OSError):
|
||||
logger.warning("Blob file missing during export: %s", src)
|
||||
return None
|
||||
else:
|
||||
target.write_text(row.get("content") or "", encoding="utf-8")
|
||||
return target
|
||||
|
||||
|
||||
def _import_file(project_uid: str, user: dict, path: str, fs_full: Path) -> bool:
|
||||
def _import_file(project_uid: str, user: dict, path: str, fs_full: Path):
|
||||
try:
|
||||
data = fs_full.read_bytes()
|
||||
except OSError:
|
||||
return False
|
||||
return None
|
||||
try:
|
||||
store_upload(project_uid, user, _parent_of(path), _name_of(path), data)
|
||||
return True
|
||||
return store_upload(project_uid, user, _parent_of(path), _name_of(path), data)
|
||||
except ProjectFileError:
|
||||
return False
|
||||
return None
|
||||
|
||||
|
||||
def node_to_dict(row: dict) -> dict:
|
||||
@@ -666,6 +784,9 @@ IMPORT_SKIP_NAMES = {
|
||||
SYNC_SKIP_NAMES = IMPORT_SKIP_NAMES | {
|
||||
".devplace_boot.py",
|
||||
".devplace_boot.sh",
|
||||
".devplace",
|
||||
".dpc",
|
||||
"dpc.log",
|
||||
}
|
||||
IMPORT_MAX_FILE_BYTES = 25 * 1024 * 1024
|
||||
|
||||
@@ -872,6 +993,7 @@ def append_lines(project_uid: str, raw_path: str, content: str) -> dict:
|
||||
def delete_all_project_files(project_uid: str) -> None:
|
||||
if "project_files" not in db.tables:
|
||||
return
|
||||
clear_sync_state(project_uid)
|
||||
for row in _table().find(project_uid=project_uid):
|
||||
if row.get("is_binary"):
|
||||
_unlink_blob(row)
|
||||
|
||||
+32
-12
@@ -2,33 +2,35 @@ This file documents `devplacepy/push/` - push notification delivery and its prov
|
||||
|
||||
## What this package is
|
||||
|
||||
One delivery library behind one public surface. `devplacepy.push` exports `notify_user`, `register`, `ensure_certificates`, `public_key_standard_b64` and the Web Push crypto helpers; every caller in the codebase (`main.py`, `utils/notifications.py`, `routers/push.py`) imports only those names. Everything else is internal to the package.
|
||||
One delivery library behind one public surface. `devplacepy.push` exports `notify_user`, `notify_registration`, `register`, `ensure_certificates`, `public_key_standard_b64` and the Web Push crypto helpers; every caller in the codebase (`main.py`, `utils/notifications.py`, `routers/push.py`) imports only those names. Everything else is internal to the package.
|
||||
|
||||
| Module | Role |
|
||||
|---|---|
|
||||
| `providers/base.py` | `PushProvider` protocol, the `Delivery` outcome and the three outcome constants |
|
||||
| `providers/webpush.py` | VAPID key material, `aesgcm` payload encryption, the Web Push provider |
|
||||
| `providers/apns.py` | Apple Push Notification service provider (token based, HTTP/2) |
|
||||
| `providers/apns.py` | Apple Push Notification service provider (token based, dedicated HTTP/2 client) |
|
||||
| `providers/__init__.py` | `PROVIDERS` registry, `get`, `active`, `is_active`, `admin_fields`, `client_config` |
|
||||
| `store.py` | Every `push_registration` read and write |
|
||||
| `delivery.py` | `notify_user`: group by provider, one shared client, one prepared body per provider |
|
||||
| `store.py` | Every `push_registration` read and write, including identity upsert and revive |
|
||||
| `delivery.py` | `notify_user` / `notify_registration`: group by provider, one client per provider, one prepared body per provider |
|
||||
|
||||
The admin configuration surface lives in `devplacepy/services/push/service.py` (`PushService`), not here.
|
||||
|
||||
## Adding a provider
|
||||
|
||||
1. Write `providers/<name>.py` with a `PushProvider` subclass: `name`, `label`, `config_fields`, `is_configured`, `parse_registration`, `prepare`, `deliver`, optionally `client_config`.
|
||||
1. Write `providers/<name>.py` with a `PushProvider` subclass: `name`, `label`, `config_fields`, `is_configured`, `parse_registration`, `prepare`, `deliver`, optionally `client_config`, `stamp_registration`, `delivery_client`.
|
||||
2. Add one entry to `PROVIDERS` in `providers/__init__.py`.
|
||||
|
||||
That is the whole change. The registration route, the delivery loop, the admin page, the audit record, the metrics and the docs are written against the protocol and need no edit. The `Enabled` toggle (`push_<name>_enabled`) comes from the base class, so a provider never declares its own.
|
||||
|
||||
The default `delivery_client` is `stealth_async_client`. Override it only when the destination is a first-party API that must not see Chrome impersonation, PRIORITY frames, extra browser headers, or the outbound proxy. APNs is that case.
|
||||
|
||||
## Invariants
|
||||
|
||||
- **Zero cost for the request.** Delivery is reached only through `utils/notifications.py` `_schedule_push`, a fire-and-forget task. Never make a route await `notify_user`, and never add a queue or a table to this path.
|
||||
- **Zero cost for the request, except the welcome probe.** Delivery of real notifications is reached only through `utils/notifications.py` `_schedule_push`, a fire-and-forget task. `POST /push.json` awaits `notify_registration` only for a newly created or revived row, so the client can see `delivered` / `error`. Never add a queue or a table to this path.
|
||||
- **`deliver` never raises.** Return `Delivery(REJECTED, detail)` instead. `delivery.py` guards anyway, but a raising provider costs a log line per subscription.
|
||||
- **A provider that is not configured is inert, never an error.** `is_configured()` is false, `is_active()` is false, the delivery loop skips it, and `POST /push.json` refuses a registration for it with 400. Nothing else in the platform notices.
|
||||
- **`DEAD` is the only outcome that touches the database.** It soft-deletes the registration (`deleted_at`), exactly like a `404`/`410` Web Push endpoint always did. `REJECTED` keeps the row.
|
||||
- **Every insert writes `deleted_at: None`,** and every read filters `deleted_at IS NULL`. `push_registration` deliberately stays out of `SOFT_DELETE_TABLES` (no `deleted_by`, not restorable from Trash) - a dead device token has no owner action to undo.
|
||||
- **`DEAD` is the only outcome that touches the database.** It soft-deletes the registration (`deleted_at`), exactly like a `404`/`410` Web Push endpoint always did - unless `Delivery.dead_before` names an instant the row was proven live again after (APNs 410 `timestamp` vs. `registered_at`, see "APNs specifics"), in which case the delete is skipped. `REJECTED` keeps the row. The Apple/Web Push reason is logged at WARNING (`Push dead via ...: <detail>`); do not drop `outcome.detail`.
|
||||
- **Every insert writes `deleted_at: None`,** and every read of the live set filters `deleted_at IS NULL`. Identity lookups for upsert/revive intentionally ignore `deleted_at` so a client that re-registers a previously dead token comes back live. `push_registration` deliberately stays out of `SOFT_DELETE_TABLES` (no `deleted_by`, not restorable from Trash) - a dead device token has no owner action to undo.
|
||||
- **A row without a provider is a Web Push row.** `store.provider_of` resolves `None`/`""` to `DEFAULT_PROVIDER`, so a row written by an old worker during a deploy still delivers. `init_db` backfills the column once with a single converging `UPDATE`.
|
||||
|
||||
## Storage
|
||||
@@ -40,13 +42,31 @@ That is the whole change. The registration route, the delivery loop, the admin p
|
||||
| `provider` | `webpush` | `apns` |
|
||||
| `endpoint`, `key_auth`, `key_p256dh` | set | `NULL` |
|
||||
| `token` | `NULL` | device token |
|
||||
| `client_id` | unused | optional stable per-device id |
|
||||
| `environment` | unused | `production` or `sandbox`, stamped server-side at register |
|
||||
| `registered_at` | stamped on insert/merge | stamped on insert/merge |
|
||||
|
||||
Deduplication is generic: `store.register` looks up `user_uid` + `provider` + exactly the fields the provider's `parse_registration` returned, so a provider never writes its own identity rule.
|
||||
`registered_at` (ISO, both providers) is stamped on insert and on every `_merge` that actually changes a row (including revival). It exists solely to arbitrate the dead-token race described below - it is not a general "last seen" field.
|
||||
|
||||
`store.register` returns `RegistrationWrite(record, created, revived)`. Identity, in order:
|
||||
|
||||
1. `user_uid` + `provider` + `client_id` (live or dead), if `client_id` is present.
|
||||
2. `user_uid` + `provider` + `token` (or `endpoint` for Web Push), live or dead.
|
||||
3. Exact live match on the remaining fields.
|
||||
4. Insert.
|
||||
|
||||
A match **updates** the row (token, client_id, environment) and clears `deleted_at` if it was dead. Token rotation with the same `client_id` therefore replaces the token on one row. Sibling live rows that share the new token, the previous token, or the same `client_id` are marked dead so one device cannot accumulate duplicates. A body without `client_id` still works: the same token revives, a new token inserts. `push.update` is a real update, not a no-op of an identical POST.
|
||||
|
||||
## APNs specifics
|
||||
|
||||
- `POST https://{host}/3/device/{token}` over HTTP/2, host from `push_apns_environment` (`api.push.apple.com` or `api.sandbox.push.apple.com`; an unrecognised value falls back to production). HTTP/2 comes from `stealth_async_client` because the origin is `https` - the cleartext downgrade in `curl_transport` does not apply.
|
||||
- Provider token: `ES256`, header `kid` = key id, claims `iss` = team id and `iat`. Cached per credential fingerprint for 45 minutes, so a worker signs at most one token per 45 minutes; Apple refuses tokens regenerated faster than every 20 minutes. Editing any credential changes the fingerprint and takes effect on the next delivery, with no restart.
|
||||
- **Persistent HTTP/2 connection, not one per notification.** `apns.cached_client(timeout)` lazily creates ONE module-level `httpx.AsyncClient(http2=True, ...)` and reuses it across every `notify_user`/`notify_registration` call for the lifetime of the process, following Apple's explicit guidance to keep the connection open rather than repeatedly opening/closing (`sending-notification-requests-to-apns`). `ApnsProvider.closes_delivery_client()` returns `False` so `delivery.py` never closes it after a batch (Web Push still opens/closes per call via `stealth_async_client`, `closes_delivery_client()` defaulting `True` on the base class). Closed once, gracefully, in `main.py`'s shutdown via `push.shutdown_providers()` -> `ApnsProvider.aclose()` -> `apns.close_client()`, mirroring the identical `services/containers/forward.py` `client()`/`close_client()` pattern. Not stealth, not curl_cffi Chrome impersonation, not the outbound proxy - Apple's provider API is a first-party HTTP/2 service; browser PRIORITY frames, HPACK indexing of `:path`/`authorization`, extra `sec-ch-ua` headers, and a scraping proxy all violate that contract. This is the second documented exception to the stealth-only outbound rule (the other is container reverse-proxy forwarding). If the admin-configured delivery timeout changes, the cached client is rebuilt with the new timeout on next use and the old one is left for GC (not explicitly closed) - a deliberate, rare-path simplification.
|
||||
- `POST https://{host}/3/device/{token}` over HTTP/2. Host comes from the **row's** `environment` if set, else `push_apns_environment` (`api.push.apple.com` or `api.sandbox.push.apple.com`; an unrecognised value falls back to production). Stamping environment per row lets a sandbox debug token and a production TestFlight token coexist.
|
||||
- `GET /push.json` advertises `providers.apns.environment` when APNs is active so a native client can refuse to register a sandbox token against production.
|
||||
- `parse_registration` requires a hexadecimal `token` (64-200 digits after stripping spaces and `<>`) and optionally `client_id` (string, max 128). A `client_id` that is not a string is a 400, not a silent drop. Server stamps `environment`; the client cannot pick the host.
|
||||
- **Provider token (JWT):** `ES256`, header `kid` = key id, claims `iss` = team id and `iat`. Cached per credential fingerprint for 45 minutes (Apple refuses tokens regenerated faster than every 20 minutes, and requires a fresh one at least once an hour). **Shared cross-worker via `site_settings` key `push_apns_shared_token`** (JSON `{fingerprint, token, issued_at}`, read/written through the existing `get_setting`/`set_setting` cache-version machinery, propagating to sibling workers within ~1s like every other settings key) - a worker that finds no valid in-process cache checks the shared copy before signing a new one, so `make prod`'s multiple uvicorn workers converge on presenting Apple the SAME token instead of each independently re-signing on its own clock (which could otherwise interleave closer together than Apple's per-credential minimum spacing, worst case when workers boot near-simultaneously). Editing any credential changes the fingerprint and takes effect on the next delivery, with no restart.
|
||||
- **A rejected provider token is evicted immediately, not left to expire from cache.** A 403 status, or any reason in `AUTH_REASONS` (`InvalidProviderToken`, `ExpiredProviderToken`, `BadCertificate`, `BadCertificateEnvironment`, `Forbidden`, `MissingProviderToken` - all provider-credential-shaped per Apple's reason table, never about a specific device), calls `invalidate_provider_token()`: clears the in-process cache AND the shared DB copy, so the very next delivery attempt (any worker) re-signs instead of retrying the same rejected token for up to 45 minutes.
|
||||
- A `.p8` that does not parse is cached as a failure for the same window, so a misconfiguration costs one error log per window rather than one parse per notification.
|
||||
- `410`, or any status carrying reason `BadDeviceToken`, `Unregistered`, `ExpiredToken`, `DeviceTokenNotForTopic` or `TopicDisallowed`, is `DEAD`. Everything else is `REJECTED`.
|
||||
- **`DEAD_REASONS` is deliberately narrow: only `BadDeviceToken` (400), `Unregistered` (410) and `ExpiredToken` (410).** These are the only reasons in Apple's documented table that describe the TOKEN itself as permanently invalid. `DeviceTokenNotForTopic` and `TopicDisallowed` are 400 errors about the **topic/provisioning matching the connection**, not the token - they fire identically for every token when `push_apns_topic` is misconfigured or the certificate/entitlements don't match, so treating them as dead would soft-delete the entire APNs subscriber base (a table deliberately kept OUT of `SOFT_DELETE_TABLES`, hence unrestorable) on the first delivery after a one-field admin typo. Never add a topic/provisioning-shaped reason to `DEAD_REASONS`.
|
||||
- **A 410's `timestamp` is honored before deleting.** Apple's 410 body carries `{"reason": "Unregistered", "timestamp": <ms epoch>}` - the instant Apple last confirmed the token dead, which can trail real device state by "several days" per Apple engineering guidance (410 delivery is intentionally non-deterministic; do not use it to infer app-uninstall timing). `apns._dead_before` converts it to ISO and it rides on `Delivery.dead_before`; `store.mark_dead(id, dead_before)` compares it against the row's `registered_at` and **skips the delete** (logs at INFO instead) if the registration was re-registered/revived after that instant - closing the race where an in-flight delivery against a stale row state would otherwise undo a concurrent revival. `dead_before` is only ever set for a 410; the `BadDeviceToken`/`ExpiredToken` paths pass `None` (unconditional delete, as before), since those reasons carry no `timestamp`.
|
||||
- The shared payload dict (`title`, `message`, `icon`, `url`) is translated once per batch into `aps.alert` plus the custom `url`/`icon` keys, mirroring what `service-worker.js` does for Web Push. `thread-id` mirrors the service worker's notification `tag`.
|
||||
- `POST /push.json` probes a created or revived row immediately via `notify_registration`. The JSON is `{registered: true, delivered: bool, error?: string}`. `registered` stays true so existing clients keep working; `delivered`/`error` surface Apple's reason instead of killing the row silently from the client's point of view. The row is still marked dead on a `DEAD` outcome (subject to the `dead_before` guard above) so later `notify_user` calls do not keep hitting a known-bad token.
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from devplacepy.push.delivery import notify_user
|
||||
from devplacepy.push.delivery import notify_registration, notify_user
|
||||
from devplacepy.push.providers import shutdown as shutdown_providers
|
||||
from devplacepy.push.providers.webpush import (
|
||||
browser_base64,
|
||||
create_notification_authorization,
|
||||
@@ -23,7 +24,9 @@ __all__ = [
|
||||
"generate_private_key",
|
||||
"generate_public_key",
|
||||
"hkdf",
|
||||
"notify_registration",
|
||||
"notify_user",
|
||||
"public_key_standard_b64",
|
||||
"register",
|
||||
"shutdown_providers",
|
||||
]
|
||||
|
||||
+71
-29
@@ -3,9 +3,9 @@
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from devplacepy import stealth
|
||||
from devplacepy.database import get_int_setting
|
||||
from devplacepy.push import providers, store
|
||||
from devplacepy.push.providers.base import Delivery
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -29,6 +29,10 @@ def group_by_provider(
|
||||
return grouped
|
||||
|
||||
|
||||
def _open_client(provider, timeout: float):
|
||||
return provider.delivery_client(timeout)
|
||||
|
||||
|
||||
async def notify_user(user_uid: str, payload: dict[str, Any]) -> None:
|
||||
registrations = store.active_for_user(user_uid)
|
||||
if not registrations:
|
||||
@@ -36,48 +40,86 @@ async def notify_user(user_uid: str, payload: dict[str, Any]) -> None:
|
||||
return
|
||||
|
||||
grouped = group_by_provider(registrations)
|
||||
async with stealth.stealth_async_client(timeout=timeout_seconds()) as client:
|
||||
for name, rows in grouped.items():
|
||||
provider = providers.PROVIDERS.get(name)
|
||||
if provider is None:
|
||||
logger.warning(
|
||||
"Unknown push provider %s on %s subscriptions of user %s",
|
||||
name,
|
||||
len(rows),
|
||||
user_uid,
|
||||
)
|
||||
continue
|
||||
if not providers.is_active(provider):
|
||||
logger.debug(
|
||||
"Push provider %s is not active; skipping %s subscriptions",
|
||||
name,
|
||||
len(rows),
|
||||
)
|
||||
continue
|
||||
try:
|
||||
prepared = provider.prepare(payload)
|
||||
except Exception as exc:
|
||||
logger.error("Push provider %s could not build a payload: %s", name, exc)
|
||||
continue
|
||||
timeout = timeout_seconds()
|
||||
for name, rows in grouped.items():
|
||||
provider = providers.PROVIDERS.get(name)
|
||||
if provider is None:
|
||||
logger.warning(
|
||||
"Unknown push provider %s on %s subscriptions of user %s",
|
||||
name,
|
||||
len(rows),
|
||||
user_uid,
|
||||
)
|
||||
continue
|
||||
if not providers.is_active(provider):
|
||||
logger.debug(
|
||||
"Push provider %s is not active; skipping %s subscriptions",
|
||||
name,
|
||||
len(rows),
|
||||
)
|
||||
continue
|
||||
try:
|
||||
prepared = provider.prepare(payload)
|
||||
except Exception as exc:
|
||||
logger.error("Push provider %s could not build a payload: %s", name, exc)
|
||||
continue
|
||||
try:
|
||||
client = _open_client(provider, timeout)
|
||||
except Exception as exc:
|
||||
logger.error("Push provider %s could not open a client: %s", name, exc)
|
||||
continue
|
||||
try:
|
||||
for registration in rows:
|
||||
await _deliver_one(provider, client, registration, prepared, user_uid)
|
||||
finally:
|
||||
if provider.closes_delivery_client():
|
||||
await client.aclose()
|
||||
|
||||
|
||||
async def _deliver_one(provider, client, registration, prepared, user_uid) -> None:
|
||||
async def notify_registration(
|
||||
registration: dict[str, Any], payload: dict[str, Any]
|
||||
) -> Delivery:
|
||||
name = store.provider_of(registration)
|
||||
provider = providers.PROVIDERS.get(name)
|
||||
if provider is None:
|
||||
return Delivery(providers.REJECTED, f"unknown provider {name}")
|
||||
if not providers.is_active(provider):
|
||||
return Delivery(providers.REJECTED, f"provider {name} is not active")
|
||||
try:
|
||||
prepared = provider.prepare(payload)
|
||||
except Exception as exc:
|
||||
return Delivery(providers.REJECTED, str(exc))
|
||||
user_uid = registration.get("user_uid") or ""
|
||||
try:
|
||||
client = _open_client(provider, timeout_seconds())
|
||||
except Exception as exc:
|
||||
return Delivery(providers.REJECTED, str(exc))
|
||||
try:
|
||||
return await _deliver_one(provider, client, registration, prepared, user_uid)
|
||||
finally:
|
||||
if provider.closes_delivery_client():
|
||||
await client.aclose()
|
||||
|
||||
|
||||
async def _deliver_one(provider, client, registration, prepared, user_uid) -> Delivery:
|
||||
try:
|
||||
outcome = await provider.deliver(client, registration, prepared)
|
||||
except Exception as exc:
|
||||
logger.error("Push provider %s raised for %s: %s", provider.name, user_uid, exc)
|
||||
return
|
||||
return Delivery(providers.REJECTED, str(exc))
|
||||
if outcome.status == providers.ACCEPTED:
|
||||
logger.debug("Push delivered to %s via %s", user_uid, provider.name)
|
||||
return
|
||||
return outcome
|
||||
if outcome.status == providers.DEAD:
|
||||
logger.warning(
|
||||
"Push dead via %s for %s: %s", provider.name, user_uid, outcome.detail
|
||||
)
|
||||
try:
|
||||
store.mark_dead(registration["id"])
|
||||
store.mark_dead(registration["id"], outcome.dead_before)
|
||||
except Exception as exc:
|
||||
logger.error("Could not soft-delete push subscription: %s", exc)
|
||||
return
|
||||
return outcome
|
||||
logger.warning(
|
||||
"Push rejected by %s for %s: %s", provider.name, user_uid, outcome.detail
|
||||
)
|
||||
return outcome
|
||||
|
||||
@@ -35,6 +35,7 @@ __all__ = [
|
||||
"get",
|
||||
"is_active",
|
||||
"names",
|
||||
"shutdown",
|
||||
]
|
||||
|
||||
|
||||
@@ -74,3 +75,11 @@ def _client_config(provider: PushProvider) -> dict[str, Any]:
|
||||
except Exception as exc:
|
||||
logger.error("Push provider %s failed to describe itself: %s", provider.name, exc)
|
||||
return {}
|
||||
|
||||
|
||||
async def shutdown() -> None:
|
||||
for provider in PROVIDERS.values():
|
||||
try:
|
||||
await provider.aclose()
|
||||
except Exception as exc:
|
||||
logger.error("Push provider %s failed to close: %s", provider.name, exc)
|
||||
|
||||
@@ -5,13 +5,14 @@ import json
|
||||
import logging
|
||||
import string
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
import jwt
|
||||
|
||||
from devplacepy.config import SECONDS_PER_DAY
|
||||
from devplacepy.database import get_setting
|
||||
from devplacepy.database import get_setting, set_setting
|
||||
from devplacepy.push.providers.base import (
|
||||
ACCEPTED,
|
||||
DEAD,
|
||||
@@ -44,20 +45,27 @@ ENVIRONMENT_OPTIONS = [
|
||||
TOKEN_REFRESH_SECONDS = 45 * 60
|
||||
TOKEN_MIN_LENGTH = 64
|
||||
TOKEN_MAX_LENGTH = 200
|
||||
CLIENT_ID_MAX_LENGTH = 128
|
||||
THREAD_ID = "devplace-notification"
|
||||
PUSH_TYPE = "alert"
|
||||
PRIORITY = "10"
|
||||
DEAD_REASONS = frozenset(
|
||||
SHARED_TOKEN_KEY = "push_apns_shared_token"
|
||||
|
||||
DEAD_REASONS = frozenset({"BadDeviceToken", "ExpiredToken", "Unregistered"})
|
||||
|
||||
AUTH_REASONS = frozenset(
|
||||
{
|
||||
"BadDeviceToken",
|
||||
"DeviceTokenNotForTopic",
|
||||
"ExpiredToken",
|
||||
"Unregistered",
|
||||
"TopicDisallowed",
|
||||
"BadCertificate",
|
||||
"BadCertificateEnvironment",
|
||||
"ExpiredProviderToken",
|
||||
"Forbidden",
|
||||
"InvalidProviderToken",
|
||||
"MissingProviderToken",
|
||||
}
|
||||
)
|
||||
|
||||
_token_state: dict[str, Any] = {}
|
||||
_client: httpx.AsyncClient | None = None
|
||||
|
||||
|
||||
def _setting(key: str) -> str:
|
||||
@@ -70,13 +78,78 @@ def _environment() -> str:
|
||||
|
||||
|
||||
def host() -> str:
|
||||
return HOSTS[_environment()]
|
||||
return host_for(_environment())
|
||||
|
||||
|
||||
def host_for(environment: str | None) -> str:
|
||||
value = (environment or "").strip() or _environment()
|
||||
return HOSTS[value] if value in HOSTS else HOSTS[DEFAULT_ENVIRONMENT]
|
||||
|
||||
|
||||
def gateway_client(timeout: float) -> httpx.AsyncClient:
|
||||
return httpx.AsyncClient(
|
||||
http2=True,
|
||||
timeout=timeout,
|
||||
trust_env=False,
|
||||
verify=True,
|
||||
follow_redirects=False,
|
||||
)
|
||||
|
||||
|
||||
def cached_client(timeout: float) -> httpx.AsyncClient:
|
||||
global _client
|
||||
if _client is None or _client.is_closed:
|
||||
_client = gateway_client(timeout)
|
||||
return _client
|
||||
|
||||
|
||||
async def close_client() -> None:
|
||||
global _client
|
||||
if _client is not None and not _client.is_closed:
|
||||
await _client.aclose()
|
||||
_client = None
|
||||
|
||||
|
||||
def _normalize_token(token: str) -> str:
|
||||
return "".join(
|
||||
character.lower() for character in token if character in string.hexdigits
|
||||
)
|
||||
|
||||
|
||||
def _fingerprint(team_id: str, key_id: str, auth_key: str) -> str:
|
||||
return hashlib.sha256(f"{team_id}:{key_id}:{auth_key}".encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def _read_shared_token() -> dict[str, Any] | None:
|
||||
raw = get_setting(SHARED_TOKEN_KEY, "")
|
||||
if not raw:
|
||||
return None
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
except ValueError:
|
||||
return None
|
||||
if (
|
||||
not isinstance(data, dict)
|
||||
or not isinstance(data.get("fingerprint"), str)
|
||||
or not isinstance(data.get("token"), str)
|
||||
or not isinstance(data.get("issued_at"), int)
|
||||
):
|
||||
return None
|
||||
return data
|
||||
|
||||
|
||||
def _write_shared_token(fingerprint: str, token: str, issued_at: int) -> None:
|
||||
set_setting(
|
||||
SHARED_TOKEN_KEY,
|
||||
json.dumps({"fingerprint": fingerprint, "token": token, "issued_at": issued_at}),
|
||||
)
|
||||
|
||||
|
||||
def invalidate_provider_token() -> None:
|
||||
_token_state.pop("current", None)
|
||||
set_setting(SHARED_TOKEN_KEY, "")
|
||||
|
||||
|
||||
def provider_token(team_id: str, key_id: str, auth_key: str) -> str:
|
||||
fingerprint = _fingerprint(team_id, key_id, auth_key)
|
||||
issued_at = int(time.time())
|
||||
@@ -89,6 +162,21 @@ def provider_token(team_id: str, key_id: str, auth_key: str) -> str:
|
||||
if state["token"] is None:
|
||||
raise ValueError(state["error"])
|
||||
return state["token"]
|
||||
|
||||
shared = _read_shared_token()
|
||||
if (
|
||||
shared
|
||||
and shared["fingerprint"] == fingerprint
|
||||
and issued_at - shared["issued_at"] < TOKEN_REFRESH_SECONDS
|
||||
):
|
||||
_token_state["current"] = {
|
||||
"token": shared["token"],
|
||||
"error": "",
|
||||
"issued_at": shared["issued_at"],
|
||||
"fingerprint": fingerprint,
|
||||
}
|
||||
return shared["token"]
|
||||
|
||||
try:
|
||||
token = jwt.encode(
|
||||
{"iss": team_id, "iat": issued_at},
|
||||
@@ -112,6 +200,7 @@ def provider_token(team_id: str, key_id: str, auth_key: str) -> str:
|
||||
"issued_at": issued_at,
|
||||
"fingerprint": fingerprint,
|
||||
}
|
||||
_write_shared_token(fingerprint, token, issued_at)
|
||||
return token
|
||||
|
||||
|
||||
@@ -125,6 +214,22 @@ def _reason(response: httpx.Response) -> str:
|
||||
return ""
|
||||
|
||||
|
||||
def _dead_before(response: httpx.Response) -> str | None:
|
||||
try:
|
||||
body = response.json()
|
||||
except ValueError:
|
||||
return None
|
||||
if not isinstance(body, dict):
|
||||
return None
|
||||
raw = body.get("timestamp")
|
||||
if not isinstance(raw, (int, float)) or isinstance(raw, bool):
|
||||
return None
|
||||
try:
|
||||
return datetime.fromtimestamp(raw / 1000, tz=timezone.utc).isoformat()
|
||||
except (OverflowError, OSError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
class ApnsProvider(PushProvider):
|
||||
name = "apns"
|
||||
label = PROVIDER_LABEL
|
||||
@@ -181,16 +286,41 @@ class ApnsProvider(PushProvider):
|
||||
and _setting(TOPIC_KEY)
|
||||
)
|
||||
|
||||
def client_config(self) -> dict[str, Any]:
|
||||
return {"environment": _environment()}
|
||||
|
||||
def stamp_registration(self, fields: dict[str, Any]) -> dict[str, Any]:
|
||||
return {**fields, "environment": _environment()}
|
||||
|
||||
def delivery_client(self, timeout: float) -> httpx.AsyncClient:
|
||||
return cached_client(timeout)
|
||||
|
||||
def closes_delivery_client(self) -> bool:
|
||||
return False
|
||||
|
||||
async def aclose(self) -> None:
|
||||
await close_client()
|
||||
|
||||
def parse_registration(self, body: dict[str, Any]) -> dict[str, Any] | None:
|
||||
token = body.get("token")
|
||||
if not isinstance(token, str):
|
||||
return None
|
||||
token = token.strip()
|
||||
token = _normalize_token(token)
|
||||
if not TOKEN_MIN_LENGTH <= len(token) <= TOKEN_MAX_LENGTH:
|
||||
return None
|
||||
if any(character not in string.hexdigits for character in token):
|
||||
fields: dict[str, Any] = {"token": token}
|
||||
client_id = body.get("client_id")
|
||||
if client_id is None:
|
||||
return fields
|
||||
if not isinstance(client_id, str):
|
||||
return None
|
||||
return {"token": token}
|
||||
client_id = client_id.strip()
|
||||
if not client_id:
|
||||
return fields
|
||||
if len(client_id) > CLIENT_ID_MAX_LENGTH:
|
||||
return None
|
||||
fields["client_id"] = client_id
|
||||
return fields
|
||||
|
||||
def prepare(self, payload: dict[str, Any]) -> str:
|
||||
return json.dumps(
|
||||
@@ -228,7 +358,7 @@ class ApnsProvider(PushProvider):
|
||||
try:
|
||||
headers = self.headers()
|
||||
response = await client.post(
|
||||
f"https://{host()}/3/device/{token}",
|
||||
f"https://{host_for(registration.get('environment'))}/3/device/{token}",
|
||||
headers=headers,
|
||||
content=prepared.encode("utf-8"),
|
||||
)
|
||||
@@ -238,6 +368,9 @@ class ApnsProvider(PushProvider):
|
||||
return Delivery(ACCEPTED)
|
||||
reason = _reason(response)
|
||||
detail = f"{response.status_code} {reason}".strip()
|
||||
if response.status_code == 403 or reason in AUTH_REASONS:
|
||||
invalidate_provider_token()
|
||||
if response.status_code == 410 or reason in DEAD_REASONS:
|
||||
return Delivery(DEAD, detail)
|
||||
dead_before = _dead_before(response) if response.status_code == 410 else None
|
||||
return Delivery(DEAD, detail, dead_before)
|
||||
return Delivery(REJECTED, detail)
|
||||
|
||||
@@ -18,6 +18,7 @@ REJECTED = "rejected"
|
||||
class Delivery:
|
||||
status: str
|
||||
detail: str = ""
|
||||
dead_before: str | None = None
|
||||
|
||||
|
||||
class PushProvider(ABC):
|
||||
@@ -51,6 +52,20 @@ class PushProvider(ABC):
|
||||
def client_config(self) -> dict[str, Any]:
|
||||
return {}
|
||||
|
||||
def stamp_registration(self, fields: dict[str, Any]) -> dict[str, Any]:
|
||||
return fields
|
||||
|
||||
def delivery_client(self, timeout: float) -> httpx.AsyncClient:
|
||||
from devplacepy import stealth
|
||||
|
||||
return stealth.stealth_async_client(timeout=timeout)
|
||||
|
||||
def closes_delivery_client(self) -> bool:
|
||||
return True
|
||||
|
||||
async def aclose(self) -> None:
|
||||
return None
|
||||
|
||||
@abstractmethod
|
||||
def is_configured(self) -> bool: ...
|
||||
|
||||
|
||||
+151
-9
@@ -1,6 +1,7 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
@@ -13,6 +14,17 @@ logger = logging.getLogger(__name__)
|
||||
TABLE = "push_registration"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RegistrationWrite:
|
||||
record: dict[str, Any]
|
||||
created: bool
|
||||
revived: bool
|
||||
|
||||
@property
|
||||
def probe(self) -> bool:
|
||||
return self.created or self.revived
|
||||
|
||||
|
||||
def table():
|
||||
return get_table(TABLE)
|
||||
|
||||
@@ -25,31 +37,161 @@ def active_for_user(user_uid: str) -> list[dict[str, Any]]:
|
||||
return list(table().find(user_uid=user_uid, deleted_at=None))
|
||||
|
||||
|
||||
def _filled(fields: dict[str, Any]) -> dict[str, Any]:
|
||||
filled: dict[str, Any] = {}
|
||||
for key, value in fields.items():
|
||||
if value is None:
|
||||
continue
|
||||
if isinstance(value, str):
|
||||
value = value.strip()
|
||||
if not value:
|
||||
continue
|
||||
filled[key] = value
|
||||
return filled
|
||||
|
||||
|
||||
def _prefer_live(rows: list[dict[str, Any]]) -> dict[str, Any] | None:
|
||||
if not rows:
|
||||
return None
|
||||
live = [row for row in rows if not row.get("deleted_at")]
|
||||
return (live or rows)[-1]
|
||||
|
||||
|
||||
def _lookup(user_uid: str, provider: str, **identity: Any) -> dict[str, Any] | None:
|
||||
return _prefer_live(
|
||||
list(table().find(user_uid=user_uid, provider=provider, **identity))
|
||||
)
|
||||
|
||||
|
||||
def _with_id(record: dict[str, Any]) -> dict[str, Any]:
|
||||
if record.get("id"):
|
||||
return record
|
||||
found = table().find_one(uid=record.get("uid"))
|
||||
return found or record
|
||||
|
||||
|
||||
def _retire_duplicates(
|
||||
user_uid: str,
|
||||
provider: str,
|
||||
keep_id: int,
|
||||
fields: dict[str, Any],
|
||||
previous_token: str | None = None,
|
||||
) -> None:
|
||||
token = fields.get("token")
|
||||
client_id = fields.get("client_id")
|
||||
for row in table().find(user_uid=user_uid, provider=provider, deleted_at=None):
|
||||
if row["id"] == keep_id:
|
||||
continue
|
||||
if client_id and row.get("client_id") == client_id:
|
||||
mark_dead(row["id"])
|
||||
continue
|
||||
if token and row.get("token") == token:
|
||||
mark_dead(row["id"])
|
||||
continue
|
||||
if previous_token and row.get("token") == previous_token:
|
||||
mark_dead(row["id"])
|
||||
|
||||
|
||||
def _merge(
|
||||
existing: dict[str, Any], fields: dict[str, Any]
|
||||
) -> RegistrationWrite:
|
||||
revived = bool(existing.get("deleted_at"))
|
||||
patch: dict[str, Any] = {"id": existing["id"]}
|
||||
if revived:
|
||||
patch["deleted_at"] = None
|
||||
changed = revived
|
||||
for key, value in fields.items():
|
||||
if existing.get(key) != value:
|
||||
patch[key] = value
|
||||
changed = True
|
||||
if not changed:
|
||||
return RegistrationWrite(_with_id(existing), False, False)
|
||||
patch["registered_at"] = datetime.now(timezone.utc).isoformat()
|
||||
previous_token = existing.get("token")
|
||||
table().update(patch, ["id"])
|
||||
merged = {**existing, **patch}
|
||||
if revived:
|
||||
merged["deleted_at"] = None
|
||||
merged = _with_id(merged)
|
||||
_retire_duplicates(
|
||||
existing["user_uid"],
|
||||
existing["provider"],
|
||||
merged["id"],
|
||||
fields,
|
||||
previous_token=previous_token if previous_token != fields.get("token") else None,
|
||||
)
|
||||
logger.info(
|
||||
"Updated %s push subscription for user %s%s",
|
||||
existing.get("provider"),
|
||||
existing.get("user_uid"),
|
||||
" (revived)" if revived else "",
|
||||
)
|
||||
return RegistrationWrite(merged, False, revived)
|
||||
|
||||
|
||||
def register(
|
||||
user_uid: str, provider: str, fields: dict[str, Any]
|
||||
) -> tuple[dict[str, Any], bool]:
|
||||
) -> RegistrationWrite:
|
||||
fields = _filled(fields)
|
||||
registrations = table()
|
||||
existing = registrations.find_one(
|
||||
client_id = fields.get("client_id")
|
||||
token = fields.get("token")
|
||||
endpoint = fields.get("endpoint")
|
||||
|
||||
if client_id:
|
||||
existing = _lookup(user_uid, provider, client_id=client_id)
|
||||
if existing:
|
||||
return _merge(existing, fields)
|
||||
|
||||
if token:
|
||||
existing = _lookup(user_uid, provider, token=token)
|
||||
if existing:
|
||||
return _merge(existing, fields)
|
||||
|
||||
if endpoint:
|
||||
existing = _lookup(user_uid, provider, endpoint=endpoint)
|
||||
if existing:
|
||||
return _merge(existing, fields)
|
||||
|
||||
live = registrations.find_one(
|
||||
user_uid=user_uid, provider=provider, deleted_at=None, **fields
|
||||
)
|
||||
if existing:
|
||||
logger.debug("Push subscription already registered for user %s", user_uid)
|
||||
return existing, False
|
||||
if live:
|
||||
return RegistrationWrite(_with_id(live), False, False)
|
||||
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
record = {
|
||||
"uid": generate_uid(),
|
||||
"user_uid": user_uid,
|
||||
"provider": provider,
|
||||
"created_at": datetime.now(timezone.utc).isoformat(),
|
||||
"created_at": now,
|
||||
"registered_at": now,
|
||||
"deleted_at": None,
|
||||
**fields,
|
||||
}
|
||||
registrations.insert(record)
|
||||
inserted = registrations.insert(record)
|
||||
if isinstance(inserted, int):
|
||||
record["id"] = inserted
|
||||
record = _with_id(record)
|
||||
if record.get("id"):
|
||||
_retire_duplicates(user_uid, provider, record["id"], fields)
|
||||
logger.info("Registered %s push subscription for user %s", provider, user_uid)
|
||||
return record, True
|
||||
return RegistrationWrite(record, True, False)
|
||||
|
||||
|
||||
def mark_dead(registration_id: int) -> None:
|
||||
def mark_dead(registration_id: int, dead_before: str | None = None) -> None:
|
||||
if dead_before is not None:
|
||||
row = table().find_one(id=registration_id)
|
||||
registered_at = row.get("registered_at") if row else None
|
||||
if registered_at and registered_at > dead_before:
|
||||
logger.info(
|
||||
"Skipped marking push subscription id=%s dead: registered again at %s "
|
||||
"after the provider confirmed it dead at %s",
|
||||
registration_id,
|
||||
registered_at,
|
||||
dead_before,
|
||||
)
|
||||
return
|
||||
table().update(
|
||||
{"id": registration_id, "deleted_at": datetime.now(timezone.utc).isoformat()},
|
||||
["id"],
|
||||
|
||||
@@ -11,11 +11,12 @@ Prefixes are wired in `main.py`:
|
||||
| `/auth` | auth/ package - one leaf per flow (`signup`, `login`, `logout`, `forgotpassword`, `resetpassword`) |
|
||||
| `/feed` | feed.py |
|
||||
| `/posts` | posts.py |
|
||||
| `/topics` | topics.py - crawlable per-topic category index pages (`GET /topics` hub, `GET /topics/{topic}` per-topic post listing over the same `TOPICS` set as the feed sidebar filter). Reuses `feed.py`'s `get_feed_posts`/`enrich_post_cards` so a topic page is a fully-enriched `_post_card.html` listing, not a stripped-down duplicate. Unlike `/feed?topic=X` (whose canonical strips the query string back to bare `/feed`, so it is never indexed as a distinct page), each `/topics/{topic}` page has its own canonical URL, unique title/description, breadcrumbs, and a sitemap entry - see "SEO implementation" below |
|
||||
| `/comments` | comments.py |
|
||||
| `/projects` | projects/ package - `index.py` (listing/detail/create/delete plus owner-only visibility toggles `POST /{slug}/private` and `POST /{slug}/readonly`, and async `POST /{slug}/fork`), `files.py`, and the `containers/` subpackage (below). `main.py` mounts the whole `/projects` tree from this one package. See `routers/projects/CLAUDE.md` for the deep detail on this tree |
|
||||
| `/projects/{slug}/files` | projects/files.py - per-project virtual filesystem (CRUD dirs/files, upload, inline edit, plus line-range ops: `lines` read, `replace-lines`, `insert-lines`, `delete-lines`, `append` for large text files) |
|
||||
| `/profile` | profile/ package - `index.py` (page/search/followers/update/api-key; the **Posts** tab shows only the 10 most recent posts, while the sidebar `posts_count` stat always uses the real `count()` so it is never capped to the rendered 10), `customization.py`, `notifications.py`, `ai_correction.py` (`POST /{username}/ai-correction`, owner-or-admin AI content correction toggle + prompt), `ai_modifier.py` (`POST /{username}/ai-modifier`, owner-or-admin AI modifier toggle + prompt), `interactions.py` (`POST /{username}/interactions`, owner-or-admin Devii interactive widgets preference; admin default via `devii_interactions_default`), `telegram.py` (`POST /{username}/telegram`, owner-or-admin Telegram pairing-code request/unpair), `usage.py` (`_ai_quota`, re-exported from the package) |
|
||||
| `/messages` | messages.py - real-time DM chat. `GET ""` (page; the conversation sidebar is one windowed SQL aggregate and an opened thread loads only the newest `CONVERSATION_MESSAGE_LIMIT` = 500 messages - keep these queries bounded), `GET /search`, `GET /conversations` (flat JSON conversation list, same `get_conversations` helper as the page), `POST /send` (no-JS fallback, also accepts an attachment-only empty-content message), `POST /ws-ticket` (issues a 30s single-use WS auth ticket so a browser WebSocket can authenticate cross-context, since a native `WebSocket` cannot set an `Authorization`/`X-API-KEY` header), and `WS /messages/ws` (NOT lock-owner gated - accepts on every worker; closes `1008` for guests). The WS adds live bidirectional delivery, typing, and read receipts on top of the existing `messages` table - presence is NOT part of this WS (it is the generic `PresenceManager`/pub/sub mechanism, see `devplacepy/services/CLAUDE.md`). Both the WS `send` and HTTP `POST /send` route through the shared `services/messaging/persist.py` `persist_message` choke point (identical audit/notification/mention) and then through `_finalize_and_broadcast`, which awaits any pending SYNC AI correction/modifier futures (DM content runs through both, so typing `@ai <instruction>` executes live), broadcasts the FINAL corrected/modified content with an additive `ai_processed` frame flag (the WS path passes `request=websocket` and awaits directly since HTTP middleware does not run for websockets; the frontend swaps the sender's optimistic bubble to the authoritative content). Same-worker delivery is instant via the per-worker `message_hub`; cross-worker delivery is filled by `services/messaging/relay.py` `message_relay` (per-worker ~1s DB poll on the `messages.id` watermark, deduped against the hub's delivered set). See `devplacepy/services/messaging/CLAUDE.md` |
|
||||
| `/messages` | messages.py - real-time DM chat. `GET ""` (page; the conversation sidebar is one windowed SQL aggregate and an opened thread loads only the newest `CONVERSATION_MESSAGE_LIMIT` = 500 messages - keep these queries bounded), `GET /search`, `GET /conversations` (flat JSON conversation list, same `get_conversations` helper as the page), `POST /send` (no-JS fallback, also accepts an attachment-only empty-content message), `POST /ws-ticket` (issues a 30s single-use WS auth ticket so a browser WebSocket can authenticate cross-context, since a native `WebSocket` cannot set an `Authorization`/`X-API-KEY` header), and `WS /messages/ws` (NOT lock-owner gated - accepts on every worker; closes `1008` for guests). The WS adds live bidirectional delivery, typing, and read receipts on top of the existing `messages` table - presence is NOT part of this WS (it is the generic `PresenceManager`/pub/sub mechanism, see `devplacepy/services/CLAUDE.md`). Both the WS `send` and HTTP `POST /send` route through the shared `services/messaging/persist.py` `persist_message` choke point (identical audit/notification/mention) and then through `_finalize_and_broadcast`, which broadcasts the persisted row immediately and then applies SYNC AI correction/modifier (HTTP awaits so the JSON body is final; WS schedules it so the receive loop never blocks). An in-place rewrite stamps `messages.updated_at` and emits a second `ai_processed` frame; other workers pick it up from `message_relay._tick_updates`. Same-worker delivery is instant via the per-worker `message_hub`; cross-worker delivery is filled by `services/messaging/relay.py` `message_relay` (per-worker ~1s DB poll on the `messages.id` watermark, plus `updated_at` for revisions, new rows deduped against the hub's delivered set). See `devplacepy/services/messaging/CLAUDE.md` |
|
||||
| `/notifications` | notifications.py |
|
||||
| `/votes` | votes.py |
|
||||
| `/reactions` | reactions.py - emoji reaction toggle on any target: `POST /reactions/{target_type}/{target_uid}` (optimistic `ReactionBar`) |
|
||||
@@ -46,8 +47,9 @@ Prefixes are wired in `main.py`:
|
||||
| `/api` | devrant/ package - devRant-compatible REST protocol (`auth.py`, `rants.py`, `comments.py`, `notifs.py`). Translates devRant requests onto DevPlace data: rants<->posts, comments/votes onto the native engagement layer, devRant integer ids onto each table's auto-increment `id`. Token auth via `devrant_tokens`. See `routers/devrant/CLAUDE.md` for the deep detail on this tree |
|
||||
| `/dbapi` | dbapi/ package - **primary-administrator-only, strictly READ-ONLY** generic database API over `dataset` (never inserts/updates/deletes/restores; the write surface was removed because it bypassed every per-route admin safeguard). `tables.py` (`GET /tables`, `GET /{table}/schema`), `crud.py` (read only: `GET /{table}` + `GET /{table}/{key}/{value}`, `?include_deleted`), `query.py` (`POST /query` hard SELECT-only validated read-only run; `POST /query/async` + `GET /query/{uid}` + `WS /query/{uid}/ws` via `DbApiJobService` kind `dbquery`), `nl.py` (`POST /nl` natural-language->SQL via the AI gateway, returns validated SELECT, `execute=true` runs it read-only). **Auth = the PRIMARY administrator only** (the earliest-created Admin, `utils.is_primary_admin` / `database.get_primary_admin_uid` - the same identity that gates backup downloads) by session/api_key (`services/dbapi/policy.py`); every other administrator is refused like a member, and there is **no internal-key path** - the gateway `internal_gateway_key()` is NOT accepted (no service-to-service access). SQL validated by `services/dbapi/validate.py` (sqlglot classify + suspicious-flag + read-only `EXPLAIN` dry-run). Devii tools `db_*` are read-only (list/get/query/nl; no write tools) and flagged `requires_primary_admin=True`, so they are added to the LLM tool list ONLY for the primary administrator (every other session never sees them and Devii is unaware they exist). `services/pubsub/policy.py` resolves its own admin/internal actor and does NOT reuse `dbapi.policy.caller_for`, so the primary-admin restriction does not leak onto the pub/sub bus. See `devplacepy/services/dbapi/CLAUDE.md` |
|
||||
| `/quizzes` | quizzes/ package - **Quizzes**: `index.py` (hub, create, import, detail, export, edit, delete, publish, per-quiz leaderboard, cross-quiz scoreboard), `questions.py` (builder page + question CRUD + reorder), `attempts.py` (start/resume, play, answer, finish, results). The hub `GET /quizzes` is the approved three-column `.feed-layout` reused verbatim from `/feed`: filters + search left (`?filter=all|todo|done|mine|drafts`, `?search=`, `?page=`), the quiz list centre with the **New quiz** and **Create quiz with Devii** actions, and the cross-quiz **scoreboard** rail right (score per user, best attempt per quiz, 15s display cache). Publishing is terminal - every write on a published quiz is a 400 and there is no unpublish route. See `devplacepy/services/quiz/CLAUDE.md` |
|
||||
| `/battles` | battles.py - **Opinion Wars**, week-long two-faction battles attached to posts: `GET ""` (listing, quizzes-style filters active/ended/mine + search), `GET /{uid}` (JSON-only full state, runs lazy resolution), `GET /{uid}/events?after=` (durable event trail replay), `POST /{uid}/join` (join or switch faction), `POST /{uid}/fight` (spend 25 Code Farm coins, deal level-weighted damage, 24h cooldown). The battle card renders on the parent post (no battle detail HTML page). See `devplacepy/services/opinionwar/CLAUDE.md` |
|
||||
| `/game` | game/ package - the **Code Farm** idle game (`index.py` + `farm.py`): `GET /game` (page), `GET /game/state`, `GET /game/leaderboard?board=`, `POST /game/{plant,harvest,buy-plot,upgrade,fertilize,daily,grant,perk,prestige,legacy,mastery,quests/claim}`, `POST /game/{defense/upgrade,defense/downgrade,infrastructure/buy,cosmetics/buy,cosmetics/equip}`, plus social `GET /game/farm/{username}` and `POST /game/farm/{username}/{water,steal}`. See `devplacepy/services/game/CLAUDE.md` |
|
||||
| (none) | push.py - push + PWA: `GET /push.json` (VAPID public key + the providers accepting registrations), `POST /push.json` (register with any active provider; a body without `provider` is a `webpush` body, so existing clients are unchanged; an unknown, disabled or unconfigured provider is a 400), `GET /service-worker.js`, `GET /manifest.json`. Provider protocol and delivery live in `devplacepy/push/` - see `devplacepy/push/CLAUDE.md` |
|
||||
| (none) | push.py - push + PWA: `GET /push.json` (VAPID public key + the providers accepting registrations; `apns` includes `environment` when active), `POST /push.json` (register with any active provider; a body without `provider` is a `webpush` body; an APNs body is `token` plus optional `client_id` for device-stable upsert; created/revived rows are probed and the JSON may include `delivered`/`error`; an unknown, disabled or unconfigured provider is a 400), `GET /service-worker.js`, `GET /manifest.json`. Provider protocol and delivery live in `devplacepy/push/` - see `devplacepy/push/CLAUDE.md` |
|
||||
| (none) | docs.py (docs/ package) - the documentation site (prose pages + API reference). See `routers/docs/CLAUDE.md` for the deep detail on this tree (`DOCS_PAGES`, audience tiers, prose rendering pipeline) |
|
||||
| `/pubsub` | pubsub.py - **database-free** publish/subscribe bus. `WS /pubsub/ws` (subscribe/unsubscribe/publish frames, `foo.*` wildcards), `POST /pubsub/publish` + `GET /pubsub/topics` (admin/internal). Lock-owner-gated WS (`4013` retry) so subscribers converge on one worker; topic authz in `services/pubsub/policy.py` (`user.{uid}.*` private, `public.*` shared, admin/internal anywhere, guests opt-in). In-process `services.pubsub.publish(topic, data)` for backends; frontend `app.pubsub`. See `devplacepy/services/pubsub/CLAUDE.md` |
|
||||
| (none) | seo.py - `/robots.txt`, `/sitemap.xml` |
|
||||
@@ -249,6 +251,14 @@ All SEO features are implemented across the following locations:
|
||||
- `devplacepy/seo.py` - JSON-LD schema generators (WebSite, BreadcrumbList, DiscussionForumPosting, ProfilePage, SoftwareApplication), meta description truncation, schema combiner, sitemap XML generator
|
||||
- `routers/seo.py` - robots.txt and sitemap.xml routes
|
||||
|
||||
### DiscussionForumPosting nested comments
|
||||
|
||||
`discussion_forum_posting(post, author, comment_count, star_count, base_url, comments=None)` embeds up to `MAX_SCHEMA_COMMENTS` (20) of the post's comments as nested `comment: [{"@type": "Comment", "text", "author", "datePublished"}, ...]` entities - not just the aggregate `CommentAction` `InteractionCounter`, which stays for the total count. `seo.comment_schema_list(comment_tree, base_url)` flattens the already-loaded comment tree (`content.load_detail`'s `detail["comments"]`, the same nested `{comment, author, children}` shape `_comment.html` renders) depth-first up to the cap - it does not re-query the database. `posts.py::view_post` is the only call site; a future post-like discussion surface (project/gist/news comments) can reuse `comment_schema_list` the same way once/if it gets a `DiscussionForumPosting` schema of its own.
|
||||
|
||||
### Topic category pages (`/topics`)
|
||||
|
||||
`routers/topics.py` gives the feed's `TOPICS` filter (`constants.py`) real, independently-crawlable pages instead of only a `?topic=` query param (whose canonical collapses back to bare `/feed` - see `base_seo_context`, `canonical = f"{base}{request.url.path}"`, which drops the query string on purpose). `GET /topics` is a hub linking every topic (with a live post count); `GET /topics/{topic}` is a full post listing for that topic, built from the exact same `get_feed_posts`/`enrich_post_cards` pair `feed.py` uses (`enrich_post_cards` was extracted out of `feed_page` specifically so this page is not a second, drifting copy of the attachments/reactions/bookmarks/poll/war enrichment loop). Each topic page gets its own canonical URL, unique title/description, breadcrumbs (Home > Topics > {label}), a `rel=next` link when paginated (`list_page_seo`/`next_page_url`, same mechanism as `/feed`/`/news`), and a real crawlable `_load_more.html` link (not JS-only infinite scroll) for reaching older posts. Both `/topics` and every `/topics/{topic}` are in `sitemap.xml`.
|
||||
|
||||
### SEO template context
|
||||
- Every router passes `page_title`, `meta_description`, `meta_robots`, `canonical_url`, `og_title`, `og_description`, `og_image`, `og_type`, `breadcrumbs`, `page_schema` via `base_seo_context()`
|
||||
- Auth pages: `noindex,nofollow`
|
||||
|
||||
@@ -89,6 +89,7 @@ async def admin_backups(request: Request):
|
||||
{"name": "Backups", "url": "/admin/backups"},
|
||||
],
|
||||
schemas=[website_schema(base)],
|
||||
robots="noindex,nofollow",
|
||||
)
|
||||
return respond(
|
||||
request,
|
||||
|
||||
@@ -409,7 +409,7 @@ async def container_instance_page(request: Request, uid: str):
|
||||
"events": store.list_events(inst["uid"]),
|
||||
"schedules": store.list_schedules(inst["uid"]),
|
||||
"stats": api.instance_stats(inst["uid"]),
|
||||
"runtime": api.instance_runtime(inst),
|
||||
"runtime": await api.instance_runtime(inst),
|
||||
"can_manage": can_manage,
|
||||
"admin_section": "containers",
|
||||
},
|
||||
|
||||
@@ -139,6 +139,7 @@ async def admin_devii_tasks(request: Request, state: str = "active"):
|
||||
{"name": "Devii tasks", "url": "/admin/devii-tasks"},
|
||||
],
|
||||
schemas=[website_schema(base)],
|
||||
robots="noindex,nofollow",
|
||||
)
|
||||
return respond(
|
||||
request,
|
||||
|
||||
@@ -150,6 +150,16 @@ async def save_model(request: Request):
|
||||
payload = routing.ModelRouteIn(**body)
|
||||
except ValidationError as exc:
|
||||
return _validation_error(exc)
|
||||
if payload.fallback_model:
|
||||
fallback_route = routing.model_store.get(payload.fallback_model)
|
||||
if fallback_route is None or fallback_route.kind != payload.kind:
|
||||
return JSONResponse(
|
||||
{
|
||||
"ok": False,
|
||||
"error": "Fallback model must be an existing model route of the same kind",
|
||||
},
|
||||
status_code=400,
|
||||
)
|
||||
saved = routing.model_store.set(payload)
|
||||
audit.record(
|
||||
request,
|
||||
|
||||
@@ -5,11 +5,12 @@ import logging
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
|
||||
from devplacepy.responses import respond
|
||||
from devplacepy.schemas import AdminIssuesPlanningOut
|
||||
from devplacepy.seo import base_seo_context
|
||||
from devplacepy.services.gitea import runtime
|
||||
from devplacepy.services.gitea.config import gitea_config
|
||||
from devplacepy.services.gitea.planning import collect_open_issues
|
||||
from devplacepy.templating import templates
|
||||
from devplacepy.utils import require_admin
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -65,4 +66,4 @@ async def admin_issues_planning(request: Request):
|
||||
"tickets": tickets,
|
||||
"tickets_error": tickets_error,
|
||||
}
|
||||
return templates.TemplateResponse(request, "admin_issues_planning.html", context)
|
||||
return respond(request, "admin_issues_planning.html", context, model=AdminIssuesPlanningOut)
|
||||
|
||||
@@ -62,7 +62,7 @@ async def service_detail(request: Request, name: str):
|
||||
request,
|
||||
title=f"{info['title']} - Services",
|
||||
description=info["description"] or f"Configure the {info['title']} service.",
|
||||
robots="noindex",
|
||||
robots="noindex,nofollow",
|
||||
breadcrumbs=[
|
||||
{"name": "Home", "url": "/feed"},
|
||||
{"name": "Admin", "url": "/admin"},
|
||||
|
||||
@@ -16,7 +16,7 @@ from devplacepy.dependencies import json_or_form
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
CLEARABLE_SETTINGS = {"extra_head"}
|
||||
CLEARABLE_SETTINGS = {"extra_head", "ios_app_url"}
|
||||
|
||||
@router.get("/settings", response_class=HTMLResponse)
|
||||
async def admin_settings(request: Request):
|
||||
@@ -85,3 +85,4 @@ async def admin_settings_save(
|
||||
links=[audit.setting(key)],
|
||||
)
|
||||
return action_result(request, "/admin/settings")
|
||||
|
||||
|
||||
@@ -89,6 +89,7 @@ async def admin_trash(request: Request, table: str = "posts", page: int = 1):
|
||||
{"name": "Trash", "url": "/admin/trash"},
|
||||
],
|
||||
schemas=[website_schema(base)],
|
||||
robots="noindex,nofollow",
|
||||
)
|
||||
return respond(
|
||||
request,
|
||||
|
||||
@@ -1,41 +1,45 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import asyncio
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from devplacepy.database import db, get_table, get_users_by_uids
|
||||
from devplacepy.database import get_projects_by_uids, get_table, get_users_by_uids
|
||||
from devplacepy.dependencies import json_or_form
|
||||
from devplacepy.models import (
|
||||
EditorPrefsForm,
|
||||
WorkspaceFlagForm,
|
||||
WorkspaceQuotaForm,
|
||||
WorkspaceSuspendForm,
|
||||
)
|
||||
from devplacepy.responses import action_result, json_error, respond
|
||||
from devplacepy.routers.admin._shared import deny_senior, is_senior_admin
|
||||
from devplacepy.schemas import AdminWorkspacesOut
|
||||
from devplacepy.seo import base_seo_context
|
||||
from devplacepy.services.audit import record as audit
|
||||
from devplacepy.services.containers import store
|
||||
from devplacepy.services.containers.workspace import flags, provision, quota, tunnels
|
||||
from devplacepy.services.containers.workspace import (
|
||||
editor,
|
||||
flags,
|
||||
provision,
|
||||
quota,
|
||||
tunnels,
|
||||
)
|
||||
from devplacepy.utils import create_notification, generate_uid, not_found, require_admin
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _decorate(rows: list[dict]) -> list[dict]:
|
||||
async def _decorate(rows: list[dict]) -> list[dict]:
|
||||
owner_uids = {row.get("workspace_owner_uid") for row in rows if row.get("workspace_owner_uid")}
|
||||
owners = get_users_by_uids(list(owner_uids)) if owner_uids else {}
|
||||
projects = {}
|
||||
if "projects" in db.tables:
|
||||
project_uids = {row.get("project_uid") for row in rows if row.get("project_uid")}
|
||||
for uid in project_uids:
|
||||
found = get_table("projects").find_one(uid=uid)
|
||||
if found:
|
||||
projects[uid] = found
|
||||
project_uids = {row.get("project_uid") for row in rows if row.get("project_uid")}
|
||||
projects = get_projects_by_uids(list(project_uids)) if project_uids else {}
|
||||
views = await asyncio.gather(*(provision.view(row) for row in rows))
|
||||
decorated = []
|
||||
for row in rows:
|
||||
view = provision.view(row)
|
||||
for row, view in zip(rows, views):
|
||||
owner = owners.get(row.get("workspace_owner_uid", "")) or {}
|
||||
project = projects.get(row.get("project_uid", "")) or {}
|
||||
view["owner_username"] = owner.get("username", "")
|
||||
@@ -76,7 +80,7 @@ async def admin_workspaces(request: Request):
|
||||
if not isinstance(admin, dict):
|
||||
return admin
|
||||
context = {
|
||||
"workspaces": _decorate(_all_workspaces()),
|
||||
"workspaces": await _decorate(_all_workspaces()),
|
||||
"flags": flags.list_flags(),
|
||||
"admin_section": "workspaces",
|
||||
"user": admin,
|
||||
@@ -101,7 +105,7 @@ async def admin_workspaces_data(request: Request):
|
||||
if not isinstance(admin, dict):
|
||||
return admin
|
||||
return JSONResponse(
|
||||
{"workspaces": _decorate(_all_workspaces()), "flags": flags.list_flags()}
|
||||
{"workspaces": await _decorate(_all_workspaces()), "flags": flags.list_flags()}
|
||||
)
|
||||
|
||||
|
||||
@@ -237,6 +241,43 @@ async def admin_flag_resolve(request: Request, flag_uid: str, status: str = "res
|
||||
return action_result(request, "/admin/workspaces")
|
||||
|
||||
|
||||
@router.post("/workspaces/{uid}/editor")
|
||||
async def admin_workspace_editor(
|
||||
request: Request,
|
||||
uid: str,
|
||||
data: Annotated[EditorPrefsForm, Depends(json_or_form(EditorPrefsForm))],
|
||||
):
|
||||
admin = require_admin(request)
|
||||
if not isinstance(admin, dict):
|
||||
return admin
|
||||
instance = _instance_or_404(uid)
|
||||
owner_uid = instance.get("workspace_owner_uid", "")
|
||||
owner = get_users_by_uids([owner_uid]).get(owner_uid) if owner_uid else None
|
||||
if is_senior_admin(admin, owner):
|
||||
return deny_senior(
|
||||
request,
|
||||
admin,
|
||||
owner_uid,
|
||||
owner,
|
||||
"container.workspace.editor.update",
|
||||
"/admin/workspaces",
|
||||
)
|
||||
if not owner_uid:
|
||||
return json_error(400, "this workspace has no owner")
|
||||
if data.reset:
|
||||
editor.reset_prefs(owner_uid, admin["uid"])
|
||||
else:
|
||||
editor.save_prefs(
|
||||
owner_uid, data.model_dump(exclude={"reset"}, exclude_unset=True)
|
||||
)
|
||||
_audit(request, admin, "container.workspace.editor.update", instance)
|
||||
return action_result(
|
||||
request,
|
||||
"/admin/workspaces",
|
||||
data={"editor": editor.view(owner_uid, instance)},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/workspaces/quota")
|
||||
async def admin_workspace_quota(
|
||||
request: Request,
|
||||
|
||||
@@ -22,12 +22,6 @@ async def token(
|
||||
request: Request,
|
||||
data: Annotated[LoginForm, Depends(json_or_form(LoginForm))],
|
||||
):
|
||||
"""Issue a DevPlace access token.
|
||||
|
||||
Accepts ``email`` + ``password`` (JSON or form-encoded). Returns a JSON
|
||||
object with ``access_token``, ``token_type``, and ``expires_in`` on success,
|
||||
or a ``401`` error on bad credentials.
|
||||
"""
|
||||
identifier = data.email.strip().lower()
|
||||
password = data.password
|
||||
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from typing import Annotated
|
||||
from urllib.parse import quote
|
||||
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
|
||||
|
||||
from devplacepy.database import resolve_object_url
|
||||
from devplacepy.dependencies import json_or_form
|
||||
from devplacepy.models import WarJoinForm
|
||||
from devplacepy.responses import action_result, json_error, respond, wants_json
|
||||
from devplacepy.schemas import BattlesOut, WarEventsOut, WarOut
|
||||
from devplacepy.seo import base_seo_context, site_url, website_schema
|
||||
from devplacepy.services.opinionwar import WarError, rules, store
|
||||
from devplacepy.utils import get_current_user, not_found, require_user
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _war_error(request: Request, message: str, redirect_url: str):
|
||||
if wants_json(request):
|
||||
return json_error(400, message)
|
||||
separator = "&" if "?" in redirect_url else "?"
|
||||
return RedirectResponse(
|
||||
url=f"{redirect_url}{separator}error={quote(message)}", status_code=302
|
||||
)
|
||||
|
||||
|
||||
def _post_url(war: dict) -> str:
|
||||
return resolve_object_url("post", war["post_uid"])
|
||||
|
||||
|
||||
@router.get("", response_class=HTMLResponse)
|
||||
async def battles_page(
|
||||
request: Request, filter: str = "active", search: str = "", page: int = 1
|
||||
):
|
||||
user = get_current_user(request)
|
||||
current_filter = filter if filter in store.FILTERS else "active"
|
||||
battles, pagination = store.list_wars(
|
||||
viewer=user,
|
||||
war_filter=current_filter,
|
||||
search=search,
|
||||
page=max(1, page),
|
||||
)
|
||||
base = site_url(request)
|
||||
seo_ctx = base_seo_context(
|
||||
request,
|
||||
title="Opinion Wars",
|
||||
description=(
|
||||
"Week-long faction battles between developers. Pick a side, fight once "
|
||||
"a day and carry your faction to victory."
|
||||
),
|
||||
breadcrumbs=[
|
||||
{"name": "Home", "url": "/feed"},
|
||||
{"name": "Battles", "url": "/battles"},
|
||||
],
|
||||
schemas=[website_schema(base)],
|
||||
)
|
||||
return respond(
|
||||
request,
|
||||
"battles.html",
|
||||
{
|
||||
**seo_ctx,
|
||||
"user": user,
|
||||
"battles": battles,
|
||||
"current_filter": current_filter,
|
||||
"counts": store.filter_counts(user, search),
|
||||
"search": search,
|
||||
"pagination": pagination,
|
||||
},
|
||||
model=BattlesOut,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{war_uid}")
|
||||
async def battle_state(request: Request, war_uid: str):
|
||||
user = get_current_user(request)
|
||||
serialized = store.get_war_serialized(store.get_war(war_uid), user)
|
||||
if not serialized:
|
||||
raise not_found("Battle not found")
|
||||
return JSONResponse(WarOut.model_validate(serialized).model_dump())
|
||||
|
||||
|
||||
@router.get("/{war_uid}/events")
|
||||
async def battle_events(
|
||||
request: Request, war_uid: str, after: int = 0, limit: int = rules.EVENT_LIMIT_DEFAULT
|
||||
):
|
||||
war = store.resolve_if_due(store.get_war(war_uid))
|
||||
if not war:
|
||||
raise not_found("Battle not found")
|
||||
events = store.events_for(war_uid, after_seq=after, limit=limit)
|
||||
return JSONResponse(
|
||||
WarEventsOut.model_validate(
|
||||
{"events": events, "status": war.get("status") or "active"}
|
||||
).model_dump()
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{war_uid}/join")
|
||||
async def join_battle(
|
||||
request: Request,
|
||||
war_uid: str,
|
||||
data: Annotated[WarJoinForm, Depends(json_or_form(WarJoinForm))],
|
||||
):
|
||||
user = require_user(request)
|
||||
war = store.get_war(war_uid)
|
||||
if not war:
|
||||
raise not_found("Battle not found")
|
||||
url = _post_url(war)
|
||||
try:
|
||||
war = store.join_war(war, user, data.faction, request)
|
||||
except WarError as exc:
|
||||
return _war_error(request, str(exc), url)
|
||||
serialized = store.get_war_serialized(war, user)
|
||||
return action_result(request, url, data={"war": serialized})
|
||||
|
||||
|
||||
@router.post("/{war_uid}/fight")
|
||||
async def fight_battle(request: Request, war_uid: str):
|
||||
user = require_user(request)
|
||||
war = store.get_war(war_uid)
|
||||
if not war:
|
||||
raise not_found("Battle not found")
|
||||
url = _post_url(war)
|
||||
try:
|
||||
war, damage = store.fight(war, user, request)
|
||||
except WarError as exc:
|
||||
return _war_error(request, str(exc), url)
|
||||
serialized = store.get_war_serialized(war, user)
|
||||
return action_result(request, url, data={"war": serialized, "damage": damage})
|
||||
@@ -1,5 +1,6 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
|
||||
@@ -141,7 +142,8 @@ async def dbapi_query_result(request: Request, uid: str):
|
||||
if not path.is_relative_to(DBAPI_DIR.resolve()) or not path.is_file():
|
||||
return error(404, "Result not available")
|
||||
queue.touch_job(uid, get_int_setting("dbquery_retention_seconds", 604800))
|
||||
return JSONResponse(json.loads(path.read_text(encoding="utf-8")))
|
||||
text = await asyncio.to_thread(path.read_text, encoding="utf-8")
|
||||
return JSONResponse(json.loads(text))
|
||||
|
||||
|
||||
@router.websocket("/query/{uid}/ws")
|
||||
|
||||
@@ -137,8 +137,6 @@ def _validate_target(value: str) -> str:
|
||||
|
||||
@router.get("/adopt")
|
||||
async def devii_adopt(request: Request):
|
||||
# The terminal's agent logged in; adopt the real session it minted into the browser so both
|
||||
# share one session, then return to where the user was.
|
||||
target = _validate_target(request.query_params.get("next", "/"))
|
||||
response = RedirectResponse(target, status_code=303)
|
||||
svc = _service()
|
||||
@@ -189,7 +187,9 @@ async def clippy_proxy(request: Request):
|
||||
"Content-Type": "application/json",
|
||||
"X-App-Reference": "devplace-devii-v-1-0-0",
|
||||
}
|
||||
if cfg.get("devii_ai_key"):
|
||||
if user.get("api_key"):
|
||||
headers["Authorization"] = f"Bearer {user['api_key']}"
|
||||
elif cfg.get("devii_ai_key"):
|
||||
headers["Authorization"] = f"Bearer {cfg['devii_ai_key']}"
|
||||
async with stealth.stealth_async_client(timeout=45.0) as client:
|
||||
upstream = await client.post(cfg["devii_ai_url"], content=body, headers=headers)
|
||||
|
||||
@@ -66,7 +66,7 @@ The `endpoint()` factory derives `min_role` from `auth` (`public` -> Public, `us
|
||||
|
||||
## Admin-only pages
|
||||
|
||||
A group with `"admin": True` (currently `services`, `admin`) is hidden from the sidebar (`docs.py` filters `visible_pages`) and returns 404 for non-admins (`page.get("admin") and not is_admin -> not_found`, same message as unknown slugs). `index.html` wraps the operator links in `{% if user and user.get('role') == 'Admin' %}`.
|
||||
A group with `"admin": True` (currently `services`, `admin`, `containers`) is hidden from the sidebar (`docs.py` filters `visible_pages`) and returns 404 for non-admins (`page.get("admin") and not is_admin -> not_found`, same message as unknown slugs). `index.html` wraps the operator links in `{% if user and user.get('role') == 'Admin' %}`.
|
||||
|
||||
## Dynamic Background Services page
|
||||
|
||||
|
||||
@@ -44,7 +44,6 @@ AUDIENCES = [
|
||||
]
|
||||
|
||||
DOCS_PAGES = [
|
||||
# General - how to use the site and Devii (everyone)
|
||||
{"slug": "index", "title": "Overview", "kind": "prose", "section": SECTION_GENERAL},
|
||||
{
|
||||
"slug": "getting-started",
|
||||
@@ -58,6 +57,12 @@ DOCS_PAGES = [
|
||||
"kind": "prose",
|
||||
"section": SECTION_GENERAL,
|
||||
},
|
||||
{
|
||||
"slug": "workspace-editor",
|
||||
"title": "The workspace editor",
|
||||
"kind": "prose",
|
||||
"section": SECTION_GENERAL,
|
||||
},
|
||||
{
|
||||
"slug": "feed",
|
||||
"title": "The feed",
|
||||
@@ -76,6 +81,12 @@ DOCS_PAGES = [
|
||||
"kind": "prose",
|
||||
"section": SECTION_GENERAL,
|
||||
},
|
||||
{
|
||||
"slug": "opinion-wars",
|
||||
"title": "Opinion Wars",
|
||||
"kind": "prose",
|
||||
"section": SECTION_GENERAL,
|
||||
},
|
||||
{
|
||||
"slug": "block-and-mute",
|
||||
"title": "Block and mute",
|
||||
@@ -148,7 +159,6 @@ DOCS_PAGES = [
|
||||
"kind": "prose",
|
||||
"section": SECTION_GENERAL,
|
||||
},
|
||||
# Legal - the policies the platform is operated under (everyone)
|
||||
{
|
||||
"slug": "terms",
|
||||
"title": "Terms of Service",
|
||||
@@ -192,7 +202,6 @@ DOCS_PAGES = [
|
||||
"section": SECTION_LEGAL,
|
||||
"admin": True,
|
||||
},
|
||||
# Tools - public developer tools (everyone)
|
||||
{
|
||||
"slug": "tools-seo",
|
||||
"title": "SEO Diagnostics",
|
||||
@@ -217,7 +226,6 @@ DOCS_PAGES = [
|
||||
"kind": "prose",
|
||||
"section": SECTION_TOOLS,
|
||||
},
|
||||
# Claude Code - the native subagent, command, and workflow setup under .claude/
|
||||
{
|
||||
"slug": "claude",
|
||||
"title": "Claude Code setup",
|
||||
@@ -248,7 +256,6 @@ DOCS_PAGES = [
|
||||
"kind": "prose",
|
||||
"section": SECTION_CLAUDE,
|
||||
},
|
||||
# Components - custom HTML web components with live examples (everyone)
|
||||
{
|
||||
"slug": "components",
|
||||
"title": "Components overview",
|
||||
@@ -327,7 +334,6 @@ DOCS_PAGES = [
|
||||
"kind": "prose",
|
||||
"section": SECTION_COMPONENTS,
|
||||
},
|
||||
# Styles - the design system: colors, layout, responsiveness, and hard structural rules (everyone)
|
||||
{
|
||||
"slug": "styles",
|
||||
"title": "Design system overview",
|
||||
@@ -358,7 +364,6 @@ DOCS_PAGES = [
|
||||
"kind": "prose",
|
||||
"section": SECTION_STYLES,
|
||||
},
|
||||
# API - developer reference (everyone); admin-only groups are routed to Administration below
|
||||
{
|
||||
"slug": "authentication",
|
||||
"title": "Authentication",
|
||||
@@ -371,7 +376,6 @@ DOCS_PAGES = [
|
||||
"kind": "prose",
|
||||
"section": SECTION_API,
|
||||
},
|
||||
# devRant API - legacy-compatible protocol, spread over focused pages
|
||||
{
|
||||
"slug": "devrant",
|
||||
"title": "Overview",
|
||||
@@ -418,7 +422,6 @@ DOCS_PAGES = [
|
||||
{**page, "section": (SECTION_ADMIN if page.get("admin") else SECTION_API)}
|
||||
for page in api_doc_pages()
|
||||
],
|
||||
# Administration - operational guides (admins only)
|
||||
{
|
||||
"slug": "devii-admin",
|
||||
"title": "Devii for admins",
|
||||
@@ -461,7 +464,6 @@ DOCS_PAGES = [
|
||||
"admin": True,
|
||||
"section": SECTION_ADMIN,
|
||||
},
|
||||
# Devii internals - technical reference for the Devii assistant (admins only)
|
||||
{
|
||||
"slug": "audit-log",
|
||||
"title": "Audit Log",
|
||||
@@ -511,7 +513,6 @@ DOCS_PAGES = [
|
||||
"admin": True,
|
||||
"section": SECTION_DEVII,
|
||||
},
|
||||
# Bots internals - deep technical reference for the autonomous bot fleet (admins only)
|
||||
{
|
||||
"slug": "bots-internals",
|
||||
"title": "Bots internals",
|
||||
@@ -561,7 +562,6 @@ DOCS_PAGES = [
|
||||
"admin": True,
|
||||
"section": SECTION_BOTS,
|
||||
},
|
||||
# Services - the background service framework and every service in detail (admins only)
|
||||
{
|
||||
"slug": "services-overview",
|
||||
"title": "Services overview",
|
||||
@@ -639,7 +639,6 @@ DOCS_PAGES = [
|
||||
"admin": True,
|
||||
"section": SECTION_SERVICES,
|
||||
},
|
||||
# Architecture - platform design, structure, and development process (admins only)
|
||||
{
|
||||
"slug": "architecture",
|
||||
"title": "Architecture overview",
|
||||
@@ -689,7 +688,6 @@ DOCS_PAGES = [
|
||||
"admin": True,
|
||||
"section": SECTION_ARCH,
|
||||
},
|
||||
# Testing - test framework, load testing, and make targets (admins only)
|
||||
{
|
||||
"slug": "testing",
|
||||
"title": "Testing overview",
|
||||
@@ -725,7 +723,6 @@ DOCS_PAGES = [
|
||||
"admin": True,
|
||||
"section": SECTION_TESTING,
|
||||
},
|
||||
# Production - deployment and operations reference (admins only)
|
||||
{
|
||||
"slug": "production",
|
||||
"title": "Production overview",
|
||||
|
||||
+25
-15
@@ -14,6 +14,9 @@ from devplacepy.database import (
|
||||
get_reactions_by_targets,
|
||||
get_user_bookmarks,
|
||||
get_polls_by_post_uids,
|
||||
)
|
||||
from devplacepy.services.opinionwar import store as war_store
|
||||
from devplacepy.database import (
|
||||
paginate_diverse,
|
||||
text_search_clause,
|
||||
)
|
||||
@@ -78,6 +81,27 @@ def get_feed_posts(
|
||||
return result, next_cursor
|
||||
|
||||
|
||||
def enrich_post_cards(posts, user):
|
||||
post_uids_list = [item["post"]["uid"] for item in posts]
|
||||
attachments_map = get_attachments_batch("post", post_uids_list)
|
||||
recent_comments = get_recent_comments_by_post_uids(post_uids_list, 3, user)
|
||||
reactions_map = get_reactions_by_targets("post", post_uids_list, user)
|
||||
bookmark_set = (
|
||||
get_user_bookmarks(user["uid"], "post", post_uids_list) if user else set()
|
||||
)
|
||||
polls_map = get_polls_by_post_uids(post_uids_list, user)
|
||||
wars_map = war_store.get_wars_by_post_uids(post_uids_list, user)
|
||||
for item in posts:
|
||||
uid = item["post"]["uid"]
|
||||
item["attachments"] = attachments_map.get(uid, [])
|
||||
item["recent_comments"] = recent_comments.get(uid, [])
|
||||
item["reactions"] = reactions_map.get(uid, {"counts": {}, "mine": []})
|
||||
item["bookmarked"] = uid in bookmark_set
|
||||
item["poll"] = polls_map.get(uid)
|
||||
item["war"] = wars_map.get(uid)
|
||||
return posts
|
||||
|
||||
|
||||
@router.get("", response_class=HTMLResponse)
|
||||
async def feed_page(
|
||||
request: Request,
|
||||
@@ -93,21 +117,7 @@ async def feed_page(
|
||||
daily_topic = get_daily_topic()
|
||||
online_users = presence.online_users()
|
||||
|
||||
post_uids_list = [item["post"]["uid"] for item in posts]
|
||||
attachments_map = get_attachments_batch("post", post_uids_list)
|
||||
recent_comments = get_recent_comments_by_post_uids(post_uids_list, 3, user)
|
||||
reactions_map = get_reactions_by_targets("post", post_uids_list, user)
|
||||
bookmark_set = (
|
||||
get_user_bookmarks(user["uid"], "post", post_uids_list) if user else set()
|
||||
)
|
||||
polls_map = get_polls_by_post_uids(post_uids_list, user)
|
||||
for item in posts:
|
||||
uid = item["post"]["uid"]
|
||||
item["attachments"] = attachments_map.get(uid, [])
|
||||
item["recent_comments"] = recent_comments.get(uid, [])
|
||||
item["reactions"] = reactions_map.get(uid, {"counts": {}, "mine": []})
|
||||
item["bookmarked"] = uid in bookmark_set
|
||||
item["poll"] = polls_map.get(uid)
|
||||
posts = enrich_post_cards(posts, user)
|
||||
|
||||
seo_ctx = list_page_seo(
|
||||
request,
|
||||
|
||||
@@ -9,6 +9,7 @@ from devplacepy.models import GameSlotForm
|
||||
from devplacepy.responses import respond, wants_json
|
||||
from devplacepy.schemas import GameFarmViewOut
|
||||
from devplacepy.services.game import GameError, store
|
||||
from devplacepy.services.audit import record as audit
|
||||
from devplacepy.utils import (
|
||||
create_notification,
|
||||
get_current_user,
|
||||
@@ -58,10 +59,24 @@ async def water_farm(
|
||||
if not owner:
|
||||
raise HTTPException(status_code=404, detail="Farm not found")
|
||||
try:
|
||||
store.water(viewer, owner, data.slot)
|
||||
result = store.water(viewer, owner, data.slot)
|
||||
except GameError as exc:
|
||||
return action_error(request, str(exc), f"/game/farm/{username}")
|
||||
track_action(viewer["uid"], "water")
|
||||
audit.record(
|
||||
request,
|
||||
"game.water",
|
||||
user=viewer,
|
||||
target_type="user",
|
||||
target_uid=owner["uid"],
|
||||
target_label=owner["username"],
|
||||
metadata=result,
|
||||
summary=(
|
||||
f"{viewer['username']} watered {owner['username']}'s build on plot "
|
||||
f"{result['slot']} and earned {result['reward_coins']} coins"
|
||||
),
|
||||
links=[audit.target("user", owner["uid"], owner["username"])],
|
||||
)
|
||||
await notify_farm(owner["username"])
|
||||
if wants_json(request):
|
||||
payload = store.serialize_farm(
|
||||
@@ -87,6 +102,31 @@ async def steal_farm(
|
||||
track_action(owner["uid"], "got_stolen_from")
|
||||
if result.get("underdog_triggered"):
|
||||
track_action(viewer["uid"], "underdog_raid")
|
||||
audit.record(
|
||||
request,
|
||||
"game.steal",
|
||||
user=viewer,
|
||||
target_type="user",
|
||||
target_uid=owner["uid"],
|
||||
target_label=owner["username"],
|
||||
metadata={
|
||||
"thief_uid": viewer["uid"],
|
||||
"thief_username": viewer["username"],
|
||||
"owner_uid": owner["uid"],
|
||||
"owner_username": owner["username"],
|
||||
"slot": result["slot"],
|
||||
"crop": result["crop"],
|
||||
"coins": result["coins"],
|
||||
"share": result["share"],
|
||||
"underdog_triggered": result.get("underdog_triggered", False),
|
||||
},
|
||||
summary=(
|
||||
f"{viewer['username']} raided {owner['username']}'s Code Farm and took "
|
||||
f"{result['coins']} coins ({round(result['share'] * 100)}%) from their "
|
||||
f"{result['crop_name']} build"
|
||||
),
|
||||
links=[audit.target("user", owner["uid"], owner["username"])],
|
||||
)
|
||||
create_notification(
|
||||
owner["uid"],
|
||||
"harvest_stolen",
|
||||
|
||||
@@ -80,8 +80,21 @@ async def _respond_action(request: Request, user: dict, fn, on_success=None):
|
||||
@router.post("/plant")
|
||||
async def game_plant(request: Request, data: Annotated[GamePlantForm, Form()]):
|
||||
user = require_user(request)
|
||||
|
||||
def recorded(result):
|
||||
audit.record(
|
||||
request,
|
||||
"game.plant",
|
||||
user=user,
|
||||
metadata=result,
|
||||
summary=(
|
||||
f"{user['username']} planted {result['crop']} on plot "
|
||||
f"{result['slot']} for {result['spent']} coins"
|
||||
),
|
||||
)
|
||||
|
||||
return await _respond_action(
|
||||
request, user, lambda: store.plant(user, data.slot, data.crop)
|
||||
request, user, lambda: store.plant(user, data.slot, data.crop), recorded
|
||||
)
|
||||
|
||||
|
||||
@@ -92,6 +105,16 @@ async def game_harvest(request: Request, data: Annotated[GameSlotForm, Form()]):
|
||||
def reward(result):
|
||||
track_action(user["uid"], "harvest")
|
||||
award_rewards(user["uid"], economy.site_xp_for(result.get("xp", 0)))
|
||||
audit.record(
|
||||
request,
|
||||
"game.harvest",
|
||||
user=user,
|
||||
metadata=result,
|
||||
summary=(
|
||||
f"{user['username']} harvested {result['crop']} on plot "
|
||||
f"{result['slot']} for {result['coins']} coins"
|
||||
),
|
||||
)
|
||||
|
||||
return await _respond_action(
|
||||
request, user, lambda: store.harvest(user, data.slot), reward
|
||||
@@ -101,25 +124,79 @@ async def game_harvest(request: Request, data: Annotated[GameSlotForm, Form()]):
|
||||
@router.post("/buy-plot")
|
||||
async def game_buy_plot(request: Request):
|
||||
user = require_user(request)
|
||||
return await _respond_action(request, user, lambda: store.buy_plot(user))
|
||||
|
||||
def recorded(result):
|
||||
audit.record(
|
||||
request,
|
||||
"game.plot.buy",
|
||||
user=user,
|
||||
metadata=result,
|
||||
summary=(
|
||||
f"{user['username']} bought Code Farm plot "
|
||||
f"{result['plot_count']} for {result['spent']} coins"
|
||||
),
|
||||
)
|
||||
|
||||
return await _respond_action(request, user, lambda: store.buy_plot(user), recorded)
|
||||
|
||||
|
||||
@router.post("/upgrade")
|
||||
async def game_upgrade(request: Request):
|
||||
user = require_user(request)
|
||||
return await _respond_action(request, user, lambda: store.upgrade_ci(user))
|
||||
|
||||
def recorded(result):
|
||||
audit.record(
|
||||
request,
|
||||
"game.ci.upgrade",
|
||||
user=user,
|
||||
metadata=result,
|
||||
summary=(
|
||||
f"{user['username']} upgraded Code Farm CI to tier "
|
||||
f"{result['ci_tier']} for {result['spent']} coins"
|
||||
),
|
||||
)
|
||||
|
||||
return await _respond_action(request, user, lambda: store.upgrade_ci(user), recorded)
|
||||
|
||||
|
||||
@router.post("/fertilize")
|
||||
async def game_fertilize(request: Request, data: Annotated[GameSlotForm, Form()]):
|
||||
user = require_user(request)
|
||||
return await _respond_action(request, user, lambda: store.fertilize(user, data.slot))
|
||||
|
||||
def recorded(result):
|
||||
audit.record(
|
||||
request,
|
||||
"game.fertilize",
|
||||
user=user,
|
||||
metadata=result,
|
||||
summary=(
|
||||
f"{user['username']} fertilized plot {result['slot']} "
|
||||
f"for {result['spent']} coins"
|
||||
),
|
||||
)
|
||||
|
||||
return await _respond_action(
|
||||
request, user, lambda: store.fertilize(user, data.slot), recorded
|
||||
)
|
||||
|
||||
|
||||
@router.post("/daily")
|
||||
async def game_daily(request: Request):
|
||||
user = require_user(request)
|
||||
return await _respond_action(request, user, lambda: store.claim_daily(user))
|
||||
|
||||
def recorded(result):
|
||||
audit.record(
|
||||
request,
|
||||
"game.daily.claim",
|
||||
user=user,
|
||||
metadata=result,
|
||||
summary=(
|
||||
f"{user['username']} claimed the Code Farm daily bonus of "
|
||||
f"{result['reward']} coins (streak {result['streak']})"
|
||||
),
|
||||
)
|
||||
|
||||
return await _respond_action(request, user, lambda: store.claim_daily(user), recorded)
|
||||
|
||||
|
||||
@router.post("/grant")
|
||||
@@ -141,8 +218,21 @@ async def game_claim_grant(request: Request):
|
||||
@router.post("/perk")
|
||||
async def game_perk(request: Request, data: Annotated[GamePerkForm, Form()]):
|
||||
user = require_user(request)
|
||||
|
||||
def recorded(result):
|
||||
audit.record(
|
||||
request,
|
||||
"game.perk.upgrade",
|
||||
user=user,
|
||||
metadata=result,
|
||||
summary=(
|
||||
f"{user['username']} upgraded perk {result['perk']} to level "
|
||||
f"{result['level']} for {result['spent']} coins"
|
||||
),
|
||||
)
|
||||
|
||||
return await _respond_action(
|
||||
request, user, lambda: store.upgrade_perk(user, data.perk)
|
||||
request, user, lambda: store.upgrade_perk(user, data.perk), recorded
|
||||
)
|
||||
|
||||
|
||||
@@ -168,8 +258,21 @@ async def game_prestige(request: Request):
|
||||
@router.post("/legacy")
|
||||
async def game_legacy(request: Request, data: Annotated[GameLegacyForm, Form()]):
|
||||
user = require_user(request)
|
||||
|
||||
def recorded(result):
|
||||
audit.record(
|
||||
request,
|
||||
"game.legacy.upgrade",
|
||||
user=user,
|
||||
metadata=result,
|
||||
summary=(
|
||||
f"{user['username']} upgraded Legacy {result['key']} to level "
|
||||
f"{result['level']} for {result['spent']} stars"
|
||||
),
|
||||
)
|
||||
|
||||
return await _respond_action(
|
||||
request, user, lambda: store.upgrade_legacy(user, data.key)
|
||||
request, user, lambda: store.upgrade_legacy(user, data.key), recorded
|
||||
)
|
||||
|
||||
|
||||
@@ -179,6 +282,16 @@ async def game_claim_quest(request: Request, data: Annotated[GameQuestForm, Form
|
||||
|
||||
def reward(result):
|
||||
award_rewards(user["uid"], economy.site_xp_for(result.get("reward_xp", 0)))
|
||||
audit.record(
|
||||
request,
|
||||
"game.quest.claim",
|
||||
user=user,
|
||||
metadata=result,
|
||||
summary=(
|
||||
f"{user['username']} claimed quest {result['kind']} for "
|
||||
f"{result['reward_coins']} coins"
|
||||
),
|
||||
)
|
||||
|
||||
return await _respond_action(
|
||||
request, user, lambda: store.claim_quest(user, data.quest, data.scope), reward
|
||||
@@ -251,8 +364,21 @@ async def game_buy_infrastructure(request: Request, data: Annotated[GameInfraFor
|
||||
@router.post("/mastery")
|
||||
async def game_upgrade_mastery(request: Request, data: Annotated[GameMasteryForm, Form()]):
|
||||
user = require_user(request)
|
||||
|
||||
def recorded(result):
|
||||
audit.record(
|
||||
request,
|
||||
"game.mastery.upgrade",
|
||||
user=user,
|
||||
metadata=result,
|
||||
summary=(
|
||||
f"{user['username']} upgraded Mastery {result['key']} to level "
|
||||
f"{result['level']} for {result['spent']} mastery points"
|
||||
),
|
||||
)
|
||||
|
||||
return await _respond_action(
|
||||
request, user, lambda: store.upgrade_mastery(user, data.key)
|
||||
request, user, lambda: store.upgrade_mastery(user, data.key), recorded
|
||||
)
|
||||
|
||||
|
||||
@@ -281,6 +407,16 @@ async def game_buy_cosmetic(request: Request, data: Annotated[GameCosmeticForm,
|
||||
@router.post("/cosmetics/equip")
|
||||
async def game_equip_cosmetic(request: Request, data: Annotated[GameCosmeticForm, Form()]):
|
||||
user = require_user(request)
|
||||
|
||||
def recorded(result):
|
||||
audit.record(
|
||||
request,
|
||||
"game.cosmetic.equip",
|
||||
user=user,
|
||||
metadata=result,
|
||||
summary=f"{user['username']} equipped Code Farm title {result['active_title']}",
|
||||
)
|
||||
|
||||
return await _respond_action(
|
||||
request, user, lambda: store.equip_title(user, data.key)
|
||||
request, user, lambda: store.equip_title(user, data.key), recorded
|
||||
)
|
||||
|
||||
@@ -8,10 +8,12 @@ from fastapi.responses import JSONResponse
|
||||
|
||||
from devplacepy.attachments import (
|
||||
get_attachments,
|
||||
get_orphan_attachments_batch,
|
||||
link_attachments,
|
||||
mirror_attachment_to_gitea,
|
||||
remove_gitea_asset,
|
||||
schedule_gitea_mirror,
|
||||
schedule_gitea_removal,
|
||||
soft_delete_attachment,
|
||||
split_attachment_uids,
|
||||
)
|
||||
from devplacepy.database import get_table
|
||||
from devplacepy.dependencies import json_or_form
|
||||
@@ -28,10 +30,6 @@ logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _split_uids(raw) -> list[str]:
|
||||
return [u.strip() for item in raw or [] for u in str(item).split(",") if u.strip()]
|
||||
|
||||
|
||||
def _can_modify(att: dict, user: dict | None, is_open: bool) -> bool:
|
||||
if not user or not is_open:
|
||||
return False
|
||||
@@ -47,21 +45,6 @@ def _payload(rows: list[dict], user: dict | None, is_open: bool) -> list[dict]:
|
||||
return items
|
||||
|
||||
|
||||
def _claim_orphans(uids: list[str], user: dict) -> list[str]:
|
||||
admin = is_admin(user)
|
||||
owned = []
|
||||
for uid in uids:
|
||||
row = get_table("attachments").find_one(uid=uid, deleted_at=None)
|
||||
if not row:
|
||||
continue
|
||||
if row.get("user_uid") and row["user_uid"] != user["uid"] and not admin:
|
||||
continue
|
||||
if row.get("target_uid"):
|
||||
continue
|
||||
owned.append(uid)
|
||||
return owned
|
||||
|
||||
|
||||
async def _load_issue(number: int) -> dict:
|
||||
try:
|
||||
return await runtime.get_client().get_issue(number)
|
||||
@@ -106,12 +89,14 @@ async def add_issue_attachment(
|
||||
summary=f"{user['username']} tried to attach to closed issue #{number}",
|
||||
)
|
||||
return json_error(409, "Attachments cannot be changed on a closed issue")
|
||||
owned = _claim_orphans(_split_uids(data.attachment_uids), user)
|
||||
owned = get_orphan_attachments_batch(
|
||||
split_attachment_uids(data.attachment_uids), user, admin=is_admin(user)
|
||||
)
|
||||
if not owned:
|
||||
return json_error(400, "No valid attachments to add")
|
||||
link_attachments(owned, "issue", str(number))
|
||||
for uid in owned:
|
||||
await mirror_attachment_to_gitea(uid)
|
||||
schedule_gitea_mirror(uid)
|
||||
audit.record(
|
||||
request,
|
||||
"issue.attachment.add",
|
||||
@@ -161,7 +146,7 @@ async def delete_issue_attachment(request: Request, number: int, uid: str):
|
||||
)
|
||||
return json_error(409, "Attachments cannot be changed on a closed issue")
|
||||
soft_delete_attachment(uid, deleted_by=user["uid"])
|
||||
await remove_gitea_asset(att)
|
||||
schedule_gitea_removal(att)
|
||||
audit.record(
|
||||
request,
|
||||
"issue.attachment.delete",
|
||||
@@ -202,12 +187,14 @@ async def add_comment_attachment(
|
||||
issue = await _load_issue(number)
|
||||
if issue.get("state") != STATE_OPEN:
|
||||
return json_error(409, "Attachments cannot be changed on a closed issue")
|
||||
owned = _claim_orphans(_split_uids(data.attachment_uids), user)
|
||||
owned = get_orphan_attachments_batch(
|
||||
split_attachment_uids(data.attachment_uids), user, admin=is_admin(user)
|
||||
)
|
||||
if not owned:
|
||||
return json_error(400, "No valid attachments to add")
|
||||
link_attachments(owned, "issue_comment", str(cid))
|
||||
for uid in owned:
|
||||
await mirror_attachment_to_gitea(uid)
|
||||
schedule_gitea_mirror(uid)
|
||||
audit.record(
|
||||
request,
|
||||
"issue.attachment.add",
|
||||
@@ -250,7 +237,7 @@ async def delete_comment_attachment(
|
||||
if issue.get("state") != STATE_OPEN:
|
||||
return json_error(409, "Attachments cannot be changed on a closed issue")
|
||||
soft_delete_attachment(uid, deleted_by=user["uid"])
|
||||
await remove_gitea_asset(att)
|
||||
schedule_gitea_removal(att)
|
||||
audit.record(
|
||||
request,
|
||||
"issue.attachment.delete",
|
||||
|
||||
@@ -5,7 +5,12 @@ from typing import Annotated
|
||||
|
||||
from fastapi import Depends, APIRouter, Request
|
||||
|
||||
from devplacepy.attachments import link_attachments, mirror_attachment_to_gitea
|
||||
from devplacepy.attachments import (
|
||||
get_orphan_attachments_batch,
|
||||
link_attachments,
|
||||
schedule_gitea_mirror,
|
||||
split_attachment_uids,
|
||||
)
|
||||
from devplacepy.database import get_table
|
||||
from devplacepy.models import IssueCommentForm
|
||||
from devplacepy.responses import action_result, json_error
|
||||
@@ -21,18 +26,6 @@ logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _owned_orphan_uids(raw: list[str], user: dict) -> list[str]:
|
||||
flat = [u.strip() for item in raw or [] for u in str(item).split(",") if u.strip()]
|
||||
owned = []
|
||||
for uid in flat:
|
||||
row = get_table("attachments").find_one(uid=uid, deleted_at=None)
|
||||
if not row or row.get("target_uid"):
|
||||
continue
|
||||
if row.get("user_uid") and row["user_uid"] != user["uid"]:
|
||||
continue
|
||||
owned.append(uid)
|
||||
return owned
|
||||
|
||||
def _notify_admins(actor: dict, number: int) -> None:
|
||||
for admin in get_table("users").find(role="Admin"):
|
||||
if admin["uid"] == actor["uid"]:
|
||||
@@ -74,11 +67,13 @@ async def comment_issue(
|
||||
|
||||
comment_id = int(comment.get("id", 0))
|
||||
store.record_comment_author(comment_id, number, user["uid"])
|
||||
owned = _owned_orphan_uids(data.attachment_uids, user)
|
||||
owned = get_orphan_attachments_batch(
|
||||
split_attachment_uids(data.attachment_uids), user
|
||||
)
|
||||
if owned:
|
||||
link_attachments(owned, "issue_comment", str(comment_id))
|
||||
for uid in owned:
|
||||
await mirror_attachment_to_gitea(uid)
|
||||
schedule_gitea_mirror(uid)
|
||||
background.submit(_notify_admins, user, number)
|
||||
audit.record(
|
||||
request,
|
||||
|
||||
@@ -6,7 +6,7 @@ from typing import Annotated
|
||||
from fastapi import Depends, APIRouter, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from devplacepy.database import get_table
|
||||
from devplacepy.attachments import get_orphan_attachments_batch, split_attachment_uids
|
||||
from devplacepy.models import IssueForm
|
||||
from devplacepy.responses import json_error
|
||||
from devplacepy.schemas import IssueJobOut
|
||||
@@ -20,19 +20,6 @@ logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _owned_orphan_uids(raw: list[str], user: dict) -> list[str]:
|
||||
flat = [u.strip() for item in raw or [] for u in str(item).split(",") if u.strip()]
|
||||
owned = []
|
||||
for uid in flat:
|
||||
row = get_table("attachments").find_one(uid=uid, deleted_at=None)
|
||||
if not row or row.get("target_uid"):
|
||||
continue
|
||||
if row.get("user_uid") and row["user_uid"] != user["uid"]:
|
||||
continue
|
||||
owned.append(uid)
|
||||
return owned
|
||||
|
||||
|
||||
@router.post("/create")
|
||||
async def create_issue(request: Request, data: Annotated[IssueForm, Depends(json_or_form(IssueForm))]):
|
||||
user = require_user(request)
|
||||
@@ -45,7 +32,9 @@ async def create_issue(request: Request, data: Annotated[IssueForm, Depends(json
|
||||
"author_uid": user["uid"],
|
||||
"title": title,
|
||||
"description": data.description.strip(),
|
||||
"attachment_uids": _owned_orphan_uids(data.attachment_uids, user),
|
||||
"attachment_uids": get_orphan_attachments_batch(
|
||||
split_attachment_uids(data.attachment_uids), user
|
||||
),
|
||||
},
|
||||
"user",
|
||||
user["uid"],
|
||||
|
||||
+163
-51
@@ -20,7 +20,6 @@ from devplacepy.templating import clear_messages_cache
|
||||
from devplacepy.utils import (
|
||||
require_user,
|
||||
time_ago,
|
||||
is_admin,
|
||||
_user_from_session,
|
||||
_user_from_api_key,
|
||||
)
|
||||
@@ -31,6 +30,7 @@ from devplacepy.services import presence
|
||||
from devplacepy.services.audit import record as audit
|
||||
from devplacepy.services.correction import PENDING_SCOPE_KEY
|
||||
from devplacepy.dependencies import json_or_form
|
||||
from devplacepy.services.moderation.screening import ContentRefused
|
||||
from devplacepy.services.messaging import (
|
||||
issue_ticket,
|
||||
message_frame,
|
||||
@@ -38,6 +38,7 @@ from devplacepy.services.messaging import (
|
||||
message_relay,
|
||||
persist_message,
|
||||
redeem_ticket,
|
||||
stamp_content_revision,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -113,22 +114,38 @@ def get_conversations(user_uid: str):
|
||||
conv.pop("other_uid", None)
|
||||
return conversations
|
||||
|
||||
def get_conversation_messages(user_uid: str, other_uid: str):
|
||||
def get_conversation_messages(user_uid: str, other_uid: str, before: str = ""):
|
||||
if other_uid in get_blocked_uids(user_uid):
|
||||
return [], None
|
||||
if "messages" not in db.tables:
|
||||
return [], get_users_by_uids([other_uid]).get(other_uid)
|
||||
msgs = list(
|
||||
db.query(
|
||||
"SELECT * FROM messages"
|
||||
" WHERE (sender_uid = :me AND receiver_uid = :other)"
|
||||
" OR (sender_uid = :other AND receiver_uid = :me)"
|
||||
" ORDER BY created_at DESC, id DESC LIMIT :lim",
|
||||
me=user_uid,
|
||||
other=other_uid,
|
||||
lim=CONVERSATION_MESSAGE_LIMIT,
|
||||
before = (before or "").strip()
|
||||
if before:
|
||||
msgs = list(
|
||||
db.query(
|
||||
"SELECT * FROM messages"
|
||||
" WHERE ((sender_uid = :me AND receiver_uid = :other)"
|
||||
" OR (sender_uid = :other AND receiver_uid = :me))"
|
||||
" AND created_at < :before"
|
||||
" ORDER BY created_at DESC, id DESC LIMIT :lim",
|
||||
me=user_uid,
|
||||
other=other_uid,
|
||||
before=before,
|
||||
lim=CONVERSATION_MESSAGE_LIMIT,
|
||||
)
|
||||
)
|
||||
else:
|
||||
msgs = list(
|
||||
db.query(
|
||||
"SELECT * FROM messages"
|
||||
" WHERE (sender_uid = :me AND receiver_uid = :other)"
|
||||
" OR (sender_uid = :other AND receiver_uid = :me)"
|
||||
" ORDER BY created_at DESC, id DESC LIMIT :lim",
|
||||
me=user_uid,
|
||||
other=other_uid,
|
||||
lim=CONVERSATION_MESSAGE_LIMIT,
|
||||
)
|
||||
)
|
||||
)
|
||||
msgs.reverse()
|
||||
|
||||
user_ids = list({m["sender_uid"] for m in msgs} | {other_uid})
|
||||
@@ -158,7 +175,9 @@ def get_conversation_messages(user_uid: str, other_uid: str):
|
||||
return result, other_user
|
||||
|
||||
@router.get("", response_class=HTMLResponse)
|
||||
async def messages_page(request: Request, with_uid: str = None, search: str = ""):
|
||||
async def messages_page(
|
||||
request: Request, with_uid: str = None, search: str = "", before: str = ""
|
||||
):
|
||||
user = require_user(request)
|
||||
conversations = get_conversations(user["uid"])
|
||||
|
||||
@@ -174,25 +193,28 @@ async def messages_page(request: Request, with_uid: str = None, search: str = ""
|
||||
other_online = False
|
||||
other_last_seen = None
|
||||
if with_uid:
|
||||
messages, other_user = get_conversation_messages(user["uid"], with_uid)
|
||||
mark_conversation_read(user["uid"], with_uid)
|
||||
mark_notifications_read_by_target(
|
||||
user["uid"], f"/messages?with_uid={with_uid}"
|
||||
messages, other_user = get_conversation_messages(
|
||||
user["uid"], with_uid, before=before
|
||||
)
|
||||
current_conversation = with_uid
|
||||
other_online = presence.is_online(other_user)
|
||||
other_last_seen = other_user.get("last_seen") if other_user else None
|
||||
audit.record(
|
||||
request,
|
||||
"message.read_on_view",
|
||||
user=user,
|
||||
target_type="user",
|
||||
target_uid=with_uid,
|
||||
target_label=other_user.get("username") if other_user else None,
|
||||
metadata={"message_count": len(messages)},
|
||||
summary=f"{user['username']} read messages from {other_user.get('username') if other_user else with_uid}",
|
||||
links=[audit.target("user", with_uid, other_user.get("username") if other_user else None)],
|
||||
)
|
||||
if not before:
|
||||
mark_conversation_read(user["uid"], with_uid)
|
||||
mark_notifications_read_by_target(
|
||||
user["uid"], f"/messages?with_uid={with_uid}"
|
||||
)
|
||||
audit.record(
|
||||
request,
|
||||
"message.read_on_view",
|
||||
user=user,
|
||||
target_type="user",
|
||||
target_uid=with_uid,
|
||||
target_label=other_user.get("username") if other_user else None,
|
||||
metadata={"message_count": len(messages)},
|
||||
summary=f"{user['username']} read messages from {other_user.get('username') if other_user else with_uid}",
|
||||
links=[audit.target("user", with_uid, other_user.get("username") if other_user else None)],
|
||||
)
|
||||
|
||||
seo_ctx = base_seo_context(
|
||||
request,
|
||||
@@ -259,7 +281,7 @@ async def send_message(request: Request, data: Annotated[MessageForm, Depends(js
|
||||
return action_result(request, "/messages")
|
||||
|
||||
ai_processed = await _finalize_and_broadcast(
|
||||
user, message, request, client_id=data.client_id
|
||||
user, message, request, client_id=data.client_id, wait_ai=True
|
||||
)
|
||||
frame = message_frame(
|
||||
message, user.get("username", ""), data.client_id,
|
||||
@@ -271,31 +293,107 @@ async def send_message(request: Request, data: Annotated[MessageForm, Depends(js
|
||||
|
||||
async def broadcast_message(
|
||||
sender: dict, message: dict, client_id: Optional[str] = None,
|
||||
ai_processed: bool = False,
|
||||
ai_processed: bool = False, ai_pending: bool = False,
|
||||
) -> None:
|
||||
frame = message_frame(
|
||||
message, sender.get("username", ""), client_id,
|
||||
sender_role=sender.get("role"), ai_processed=ai_processed,
|
||||
)
|
||||
message_hub.mark_delivered(message["uid"])
|
||||
frame["ai_pending"] = ai_pending
|
||||
if not ai_processed:
|
||||
message_hub.mark_delivered(message["uid"])
|
||||
targets = [message["sender_uid"], message["receiver_uid"]]
|
||||
await message_hub.send_to_users(targets, frame)
|
||||
|
||||
async def _await_ai_and_push(
|
||||
sender: dict, message: dict, pending: list, client_id: Optional[str]
|
||||
) -> bool:
|
||||
await asyncio.gather(*pending, return_exceptions=True)
|
||||
pending.clear()
|
||||
row = get_table("messages").find_one(uid=message["uid"])
|
||||
if not row:
|
||||
return False
|
||||
changed = row.get("content") != message.get("content")
|
||||
if changed:
|
||||
message["content"] = row["content"]
|
||||
stamp_content_revision(message["uid"])
|
||||
await broadcast_message(sender, message, client_id, ai_processed=True)
|
||||
return changed
|
||||
|
||||
async def _finalize_and_broadcast(
|
||||
sender: dict, message: dict, request: object, client_id: Optional[str] = None
|
||||
sender: dict,
|
||||
message: dict,
|
||||
request: object,
|
||||
client_id: Optional[str] = None,
|
||||
wait_ai: bool = True,
|
||||
) -> bool:
|
||||
message_hub.mark_delivered(message["uid"])
|
||||
scope = getattr(request, "scope", None)
|
||||
pending = scope.get(PENDING_SCOPE_KEY) if scope is not None else None
|
||||
ai_processed = bool(pending)
|
||||
if pending:
|
||||
await asyncio.gather(*pending, return_exceptions=True)
|
||||
pending.clear()
|
||||
row = get_table("messages").find_one(uid=message["uid"])
|
||||
if row:
|
||||
message["content"] = row["content"]
|
||||
await broadcast_message(sender, message, client_id, ai_processed=ai_processed)
|
||||
return ai_processed
|
||||
has_pending = bool(pending)
|
||||
await broadcast_message(
|
||||
sender, message, client_id, ai_processed=False, ai_pending=has_pending
|
||||
)
|
||||
if not pending:
|
||||
return False
|
||||
if wait_ai:
|
||||
return await _await_ai_and_push(sender, message, pending, client_id)
|
||||
asyncio.create_task(_await_ai_and_push(sender, message, pending, client_id))
|
||||
return False
|
||||
|
||||
SYNC_LIMIT = 200
|
||||
|
||||
|
||||
async def _sync_missed(user_uid: str, data: dict, websocket: WebSocket) -> None:
|
||||
since = str(data.get("since") or "").strip()
|
||||
with_uid = str(data.get("with_uid") or "").strip()
|
||||
if not since or "messages" not in db.tables:
|
||||
return
|
||||
if with_uid:
|
||||
rows = list(
|
||||
db.query(
|
||||
"SELECT * FROM messages"
|
||||
" WHERE ((sender_uid = :me AND receiver_uid = :other)"
|
||||
" OR (sender_uid = :other AND receiver_uid = :me))"
|
||||
" AND (created_at > :since"
|
||||
" OR (updated_at IS NOT NULL AND updated_at > :since))"
|
||||
" ORDER BY created_at ASC, id ASC LIMIT :lim",
|
||||
me=user_uid,
|
||||
other=with_uid,
|
||||
since=since,
|
||||
lim=SYNC_LIMIT,
|
||||
)
|
||||
)
|
||||
else:
|
||||
rows = list(
|
||||
db.query(
|
||||
"SELECT * FROM messages"
|
||||
" WHERE (sender_uid = :me OR receiver_uid = :me)"
|
||||
" AND (created_at > :since"
|
||||
" OR (updated_at IS NOT NULL AND updated_at > :since))"
|
||||
" ORDER BY created_at ASC, id ASC LIMIT :lim",
|
||||
me=user_uid,
|
||||
since=since,
|
||||
lim=SYNC_LIMIT,
|
||||
)
|
||||
)
|
||||
if not rows:
|
||||
return
|
||||
sender_uids = {row["sender_uid"] for row in rows}
|
||||
senders = get_users_by_uids(list(sender_uids)) if sender_uids else {}
|
||||
for row in rows:
|
||||
sender = senders.get(row["sender_uid"]) or {}
|
||||
frame = message_frame(
|
||||
dict(row),
|
||||
sender.get("username", ""),
|
||||
sender_role=sender.get("role"),
|
||||
ai_processed=bool(row.get("updated_at")),
|
||||
)
|
||||
try:
|
||||
await websocket.send_json(frame)
|
||||
except Exception: # noqa: BLE001
|
||||
logger.debug("sync frame dropped for %s", user_uid)
|
||||
return
|
||||
|
||||
|
||||
def _resolve_ws_user(websocket: WebSocket):
|
||||
user = _user_from_session(websocket)
|
||||
@@ -351,20 +449,34 @@ async def messages_ws(websocket: WebSocket):
|
||||
attachment_uids = [str(a) for a in raw_attachments][:MAX_WS_ATTACHMENTS]
|
||||
if not receiver_uid:
|
||||
continue
|
||||
message = persist_message(
|
||||
user,
|
||||
receiver_uid,
|
||||
content,
|
||||
attachment_uids,
|
||||
request=websocket,
|
||||
origin="websocket",
|
||||
)
|
||||
try:
|
||||
message = persist_message(
|
||||
user,
|
||||
receiver_uid,
|
||||
content,
|
||||
attachment_uids,
|
||||
request=websocket,
|
||||
origin="websocket",
|
||||
)
|
||||
except ContentRefused as exc:
|
||||
await websocket.send_json(
|
||||
{
|
||||
"type": "error",
|
||||
"client_id": client_id,
|
||||
"text": exc.message,
|
||||
}
|
||||
)
|
||||
continue
|
||||
if message is None:
|
||||
await websocket.send_json(
|
||||
{"type": "error", "client_id": client_id, "text": "Message not sent."}
|
||||
)
|
||||
continue
|
||||
await _finalize_and_broadcast(user, message, websocket, client_id)
|
||||
await _finalize_and_broadcast(
|
||||
user, message, websocket, client_id, wait_ai=False
|
||||
)
|
||||
elif kind == "sync":
|
||||
await _sync_missed(user_uid, data, websocket)
|
||||
elif kind == "typing":
|
||||
receiver_uid = str(data.get("receiver_uid", "")).strip()
|
||||
if receiver_uid:
|
||||
|
||||
@@ -37,10 +37,12 @@ from devplacepy.seo import (
|
||||
site_url,
|
||||
website_schema,
|
||||
discussion_forum_posting,
|
||||
comment_schema_list,
|
||||
)
|
||||
from devplacepy.attachments import save_inline_image
|
||||
from devplacepy.models import PostForm, PostEditForm
|
||||
from devplacepy.services.audit import record as audit
|
||||
from devplacepy.services.opinionwar import store as war_store
|
||||
from devplacepy.dependencies import json_or_form
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -87,6 +89,7 @@ async def create_post(request: Request, data: Annotated[PostForm, Depends(json_o
|
||||
)
|
||||
|
||||
create_poll(uid, user, data.poll_question, data.poll_options, request)
|
||||
war_store.create_war(uid, user, data.war_faction_a, data.war_faction_b, request)
|
||||
url = f"/posts/{post_slug}"
|
||||
return action_result(request, url, data={"uid": uid, "slug": post_slug, "url": url})
|
||||
|
||||
@@ -177,7 +180,12 @@ async def view_post(request: Request, post_slug: str):
|
||||
schemas=[
|
||||
website_schema(base),
|
||||
discussion_forum_posting(
|
||||
post, author, comment_count, detail["star_count"], base
|
||||
post,
|
||||
author,
|
||||
comment_count,
|
||||
detail["star_count"],
|
||||
base,
|
||||
comments=comment_schema_list(top_level, base),
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
@@ -17,6 +17,9 @@ from devplacepy.database import (
|
||||
get_reactions_by_targets,
|
||||
get_user_bookmarks,
|
||||
get_polls_by_post_uids,
|
||||
)
|
||||
from devplacepy.services.opinionwar import store as war_store
|
||||
from devplacepy.database import (
|
||||
get_activity_heatmap,
|
||||
get_activity_months,
|
||||
get_streaks,
|
||||
@@ -203,11 +206,13 @@ async def profile_page(
|
||||
else set()
|
||||
)
|
||||
polls_map = get_polls_by_post_uids(post_uids, current_user)
|
||||
wars_map = war_store.get_wars_by_post_uids(post_uids, current_user)
|
||||
for item in posts:
|
||||
uid = item["post"]["uid"]
|
||||
item["reactions"] = reactions_map.get(uid, {"counts": {}, "mine": []})
|
||||
item["bookmarked"] = uid in bookmark_set
|
||||
item["poll"] = polls_map.get(uid)
|
||||
item["war"] = wars_map.get(uid)
|
||||
|
||||
badges = list(get_table("badges").find(user_uid=profile_user["uid"]))
|
||||
for b in badges:
|
||||
|
||||
@@ -9,6 +9,8 @@ This file documents the project detail page, the per-project virtual filesystem,
|
||||
|
||||
Each project card links to `/projects/{project_uid}` showing full project details, author info, platforms, star count, delete-for-owner, and (for the owner) Private/Read-only toggle buttons plus badges (see **Project visibility and read-only** below). The route is `GET /projects/{project_uid}` in `routers/projects/index.py` and 404s when the viewer cannot see a private project. The sitemap generator links to this URL (not the old `?user_uid=` query param). The detail page also links to the project filesystem at `/projects/{slug}/files`.
|
||||
|
||||
**Project overview page.** The detail page is a dedicated project showcase: one encompassing dark card (`.project-shell`, the site `--bg-card` surface with clipped corners) wraps the hero, the section tab bar and the two-column body, and every inner panel (tab bar, sidebar cards, devlog post cards, empty state, comments section) sits one elevation lighter on `--bg-secondary`. The hero's cover banner is the attachment referenced by `projects.cover_attachment_uid`, falling back to the first image attachment (brand-gradient band when neither exists); the title block, type/platform chips and author row render OVERLAID on the banner behind a bottom scrim (dark text-shadow for readability) beside the optional `projects.logo_attachment_uid` tile, with an owner-set **Visit Website** CTA (`projects.website_url`). Cover and logo ride the ONE existing upload pipeline: `dp-upload` widgets (`name="cover_attachment_uid"`/`"logo_attachment_uid"`, `max-files="1"`) in the create/edit modals upload to `/uploads/upload`, the route validates each uid via `database.get_user_attachment` (must exist, belong to the actor, be an image - `_hero_attachment_uid`) and links it to the project through `attachments.link_attachments`; an empty value on edit keeps the current image (no removal control). `website_url`/`repo_url` are normalized by `models.normalize_website_url` (scheme-less input gets `https://`, non-http(s) rejected) and render with `rel="noopener nofollow"`. Below the hero an anchor **section tab bar** (`.project-tabs`, underline style, Overview `.active`) links `#about` / `#devlog` / `#screenshots` (only when gallery images exist) / `#comments` / the Files page - server-rendered anchors, no JS tab state. The main column holds **About** (description + non-image attachments), the **Devlog** (every post whose `project_uid` points at the project via `_post_card.html` - the template loads `feed.css` for the card styles alongside `post.css`, the same rule as `news.html`) with `devlog_count` (`content.count_project_devlog`) and an owner **Post update** button (`.project-devlog-post-btn`) opening the shared composer preset to `topic=devlog` + this project (the form lives ONCE in `templates/_post_composer_form.html`, locals `_composer_topic`/`_composer_project`, included by `feed.html` and `project_detail.html` - never fork a second copy), a **Screenshots** gallery (image attachments minus the cover/logo, thumbnails, `data-lightbox`, capped at 12 rendered), and the comment thread; the sidebar holds Links (website/repository/files/fork source), the Stats card (5 `.project-stat` entries + a last-update line) and the Author card. Owners add gallery images via the More-menu **Add screenshots** modal: `_attachment_form.html` uploads, then `POST /projects/{slug}/screenshots` (`ProjectScreenshotsForm`, owner-only, audit `project.screenshots.add`) links the uids through the same `link_attachments` choke point; Devii action `project_add_screenshots`, docs id `projects-screenshots`. `comment_count`/`devlog_count` ride `ProjectDetailOut`; the new project fields ride `ProjectOut`; the page og:image prefers the cover attachment. **Locator discipline:** the page has several `Files` anchors (action row, tab bar, sidebar) and, for owners, a second hidden `textarea[name='content']`/Post button inside the composer modal - tests MUST scope (`.project-detail-actions a:has-text('Files')`, `.comment-form textarea[name='content']`).
|
||||
|
||||
**Action row overflow.** The detail page has more actions than fit one line, so `project_detail.html` keeps the engagement actions inline (Files, Share, star vote, bookmark, reactions) and collapses the rest behind a single **More** button (`.project-actions-more`) that opens the shared `app.contextMenu`. The secondary actions (Workspace, Containers, Download zip, Fork, the owner Edit/Private/Read-only/Delete controls) live as real elements inside a hidden `.project-actions-overflow` container, each tagged `data-menu-action` plus `data-menu-icon`/`data-menu-label`. `static/js/ProjectActionsMenu.js` builds the menu from those elements and each item's `onSelect` simply `.click()`s the real element, so all existing wiring is reused unchanged - `app.zipDownloader` (`data-zip-download`), `app.projectForker` (`data-fork-project`), `data-share`, the `data-modal` Edit trigger, and the delegated `data-confirm`/`data-confirm-danger` dialog on the owner forms. The open handler must `stopPropagation()` because `app.contextMenu`'s document-level close listener would otherwise dismiss it on the same click (every other caller opens it from a right-click `attach`, not a left-click). Reuse this pattern - a `More` trigger over `[data-menu-action]` real elements - for any future action row that overflows; do not duplicate controller logic into menu callbacks.
|
||||
|
||||
**Owner editing.** Mirrors post editing exactly: an owner-only **Edit** menu item (`data-modal="edit-project-modal"`) opens the `modal()` macro's `edit-project-modal`, a plain `POST` form to `/projects/edit/{slug}` (route `edit_project` in `routers/projects/index.py`, body `ProjectEditForm`, owner-gated through the shared `content.edit_content_item` which returns 403 JSON / redirect for non-owners and stamps `updated_at`). The modal is the create modal pre-filled from the `project` row (title, description, type/status radios pre-checked, dates via `format_date()` back to DD/MM/YYYY). `is_private`/`read_only` are NOT edited here - they stay on their dedicated toggles. The platforms tag widget reuses the create modal's `platforms-input`/`platforms`/`platforms-tags` ids; `ProfileEditor.initPlatformTags` now **seeds existing tags** from the hidden `#platforms` value on load, so both the empty create form and the pre-filled edit form work from the same code. Devii tool `edit_project`; documented in `docs_api.py` (`projects-edit`).
|
||||
|
||||
@@ -83,8 +83,6 @@ async def containers_json(request: Request, project_slug: str):
|
||||
}
|
||||
)
|
||||
|
||||
# ---------------- instances ----------------
|
||||
|
||||
@router.post("/{project_slug}/containers/instances")
|
||||
async def create_instance(
|
||||
request: Request, project_slug: str, data: Annotated[ContainerInstanceForm, Depends(json_or_form(ContainerInstanceForm))]
|
||||
@@ -133,7 +131,7 @@ async def instance_detail(request: Request, project_slug: str, uid: str):
|
||||
"events": store.list_events(uid),
|
||||
"schedules": store.list_schedules(uid),
|
||||
"stats": api.instance_stats(uid),
|
||||
"runtime": api.instance_runtime(inst),
|
||||
"runtime": await api.instance_runtime(inst),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Form, Request, WebSocket
|
||||
from fastapi import APIRouter, Depends, Form, Request, WebSocket
|
||||
from starlette.responses import Response
|
||||
|
||||
from devplacepy.content import (
|
||||
@@ -10,13 +10,14 @@ from devplacepy.content import (
|
||||
can_open_workspace,
|
||||
)
|
||||
from devplacepy.database import get_table, resolve_by_slug
|
||||
from devplacepy.models import TunnelForm
|
||||
from devplacepy.dependencies import json_or_form
|
||||
from devplacepy.models import EditorPrefsForm, TunnelForm
|
||||
from devplacepy.responses import action_result, json_error, respond
|
||||
from devplacepy.schemas import WorkspaceOut
|
||||
from devplacepy.services.audit import record as audit
|
||||
from devplacepy.services.containers import activity, api, forward, store
|
||||
from devplacepy.services.containers.api import ContainerError
|
||||
from devplacepy.services.containers.workspace import provision, quota, tunnels
|
||||
from devplacepy.services.containers.workspace import editor, provision, quota, tunnels
|
||||
from devplacepy.services.containers.workspace.provision import WorkspaceError
|
||||
from devplacepy.utils import not_found, require_user
|
||||
|
||||
@@ -25,6 +26,12 @@ from ._shared import audit_instance, fail
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _restart_required(instance: dict, profile: editor.EditorProfile) -> bool:
|
||||
if instance.get("status") != store.ST_RUNNING:
|
||||
return False
|
||||
return editor.restart_required(instance, profile)
|
||||
|
||||
|
||||
def _project_or_404(slug: str) -> dict:
|
||||
project = resolve_by_slug(get_table("projects"), slug)
|
||||
if not project:
|
||||
@@ -64,13 +71,15 @@ async def workspace_page(request: Request, slug: str):
|
||||
_guard(request, project, user, "container.workspace.open")
|
||||
instance = provision.find_for_project(project["uid"], user["uid"])
|
||||
limits = quota.resolve(user["uid"])
|
||||
profile = editor.resolve(user["uid"], instance)
|
||||
context = {
|
||||
"project": project,
|
||||
"workspace": provision.view(instance) if instance else None,
|
||||
"workspace": await provision.view(instance) if instance else None,
|
||||
"has_workspace": bool(instance),
|
||||
"viewer_can_workspace": True,
|
||||
"workspace_count": provision.count_for_owner(user["uid"]),
|
||||
"max_workspaces": limits.max_workspaces,
|
||||
"unlimited_workspaces": limits.unlimited,
|
||||
"editor_url": (
|
||||
f"/projects/{slug}/containers/instances/{instance['uid']}/code/"
|
||||
if instance
|
||||
@@ -79,6 +88,10 @@ async def workspace_page(request: Request, slug: str):
|
||||
"editor_password": (
|
||||
api.ensure_editor_password(instance) if instance else ""
|
||||
),
|
||||
"editor": editor.view(user["uid"], instance),
|
||||
"restart_required": (
|
||||
_restart_required(instance, profile) if instance else False
|
||||
),
|
||||
"user": user,
|
||||
}
|
||||
return respond(request, "workspace.html", context, model=WorkspaceOut)
|
||||
@@ -117,7 +130,7 @@ async def workspace_open(request: Request, slug: str):
|
||||
)
|
||||
provision.write_manifest(instance)
|
||||
return action_result(
|
||||
request, f"/projects/{slug}/workspace", data=provision.view(instance)
|
||||
request, f"/projects/{slug}/workspace", data=await provision.view(instance)
|
||||
)
|
||||
|
||||
|
||||
@@ -151,6 +164,63 @@ async def workspace_delete(request: Request, slug: str):
|
||||
return action_result(request, f"/projects/{slug}/workspace")
|
||||
|
||||
|
||||
@router.get("/{slug}/workspace/editor")
|
||||
async def editor_prefs_read(request: Request, slug: str):
|
||||
user = require_user(request)
|
||||
if isinstance(user, Response):
|
||||
return user
|
||||
project = _project_or_404(slug)
|
||||
instance = _workspace_or_404(project, user)
|
||||
if not can_manage_workspace(instance, project, user):
|
||||
return json_error(403, "Not allowed to manage this workspace")
|
||||
profile = editor.resolve(user["uid"], instance)
|
||||
return {
|
||||
"editor": editor.view(user["uid"], instance),
|
||||
"restart_required": _restart_required(instance, profile),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/{slug}/workspace/editor")
|
||||
async def editor_prefs_write(
|
||||
request: Request,
|
||||
slug: str,
|
||||
data: Annotated[EditorPrefsForm, Depends(json_or_form(EditorPrefsForm))],
|
||||
):
|
||||
user = require_user(request)
|
||||
if isinstance(user, Response):
|
||||
return user
|
||||
project = _project_or_404(slug)
|
||||
instance = _workspace_or_404(project, user)
|
||||
if not can_manage_workspace(instance, project, user):
|
||||
return json_error(403, "Not allowed to manage this workspace")
|
||||
owner_uid = instance.get("workspace_owner_uid") or user["uid"]
|
||||
if data.reset:
|
||||
editor.reset_prefs(owner_uid, user["uid"])
|
||||
summary = f"{user['username']} reset their workspace editor preferences"
|
||||
else:
|
||||
editor.save_prefs(
|
||||
owner_uid, data.model_dump(exclude={"reset"}, exclude_unset=True)
|
||||
)
|
||||
summary = f"{user['username']} updated their workspace editor preferences"
|
||||
audit_instance(
|
||||
request,
|
||||
user,
|
||||
"container.workspace.editor.update",
|
||||
instance,
|
||||
project,
|
||||
summary=summary,
|
||||
)
|
||||
profile = editor.resolve(owner_uid, instance)
|
||||
return action_result(
|
||||
request,
|
||||
f"/projects/{slug}/workspace",
|
||||
data={
|
||||
"editor": editor.view(owner_uid, instance),
|
||||
"restart_required": _restart_required(instance, profile),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{slug}/workspace/tunnels")
|
||||
async def tunnel_list(request: Request, slug: str):
|
||||
user = require_user(request)
|
||||
@@ -174,16 +244,12 @@ async def tunnel_create(
|
||||
instance = _workspace_or_404(project, user)
|
||||
if not can_manage_workspace(instance, project, user):
|
||||
return json_error(403, "Not allowed to manage this workspace")
|
||||
if data.container_port <= 0:
|
||||
return json_error(400, "container_port must be between 1 and 65535")
|
||||
limits = quota.resolve(instance.get("workspace_owner_uid", ""), instance)
|
||||
if limits.max_tunnels and tunnels.count_for_instance(
|
||||
instance["uid"]
|
||||
) >= limits.max_tunnels:
|
||||
return json_error(400, f"tunnel limit reached ({limits.max_tunnels})")
|
||||
row = tunnels.create(instance, data.label, data.container_port, user["uid"])
|
||||
if not row:
|
||||
return json_error(400, "could not create tunnel")
|
||||
try:
|
||||
row = provision.publish_tunnel(
|
||||
instance, data.label, data.container_port, user["uid"]
|
||||
)
|
||||
except provision.WorkspaceError as error:
|
||||
return json_error(400, str(error))
|
||||
audit_instance(
|
||||
request,
|
||||
user,
|
||||
@@ -192,7 +258,6 @@ async def tunnel_create(
|
||||
project,
|
||||
metadata={"hostname": row["hostname"], "port": data.container_port},
|
||||
)
|
||||
provision.write_manifest(instance)
|
||||
return action_result(request, f"/projects/{slug}/workspace", data=row)
|
||||
|
||||
|
||||
@@ -246,12 +311,18 @@ async def editor_proxy(request: Request, slug: str, uid: str, path: str = ""):
|
||||
if denial is not None:
|
||||
return denial
|
||||
if instance.get("suspended_at"):
|
||||
return Response("this workspace is suspended", status_code=403)
|
||||
return Response(
|
||||
"this workspace is suspended", status_code=403, media_type="text/plain"
|
||||
)
|
||||
if instance.get("status") != store.ST_RUNNING:
|
||||
return Response("this workspace is not running", status_code=409)
|
||||
return Response(
|
||||
"this workspace is not running", status_code=409, media_type="text/plain"
|
||||
)
|
||||
host, port = provision.editor_target(instance)
|
||||
if not host or not port:
|
||||
return Response("the editor has no reachable port", status_code=502)
|
||||
return Response(
|
||||
"the editor has no reachable port", status_code=502, media_type="text/plain"
|
||||
)
|
||||
activity.touch(instance["uid"])
|
||||
prefix = f"/projects/{slug}/containers/instances/{uid}/code"
|
||||
return await forward.proxy_http(request, host, port, path, prefix=prefix)
|
||||
|
||||
@@ -4,9 +4,15 @@ import logging
|
||||
from typing import Annotated
|
||||
from sqlalchemy import or_
|
||||
from fastapi import Depends, APIRouter, Request
|
||||
from devplacepy.models import ProjectForm, ProjectEditForm, ProjectFlagForm, ForkForm
|
||||
from devplacepy.models import (
|
||||
ProjectForm,
|
||||
ProjectEditForm,
|
||||
ProjectFlagForm,
|
||||
ProjectScreenshotsForm,
|
||||
ForkForm,
|
||||
)
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse, JSONResponse
|
||||
from devplacepy.attachments import get_attachments_batch
|
||||
from devplacepy.attachments import get_attachments_batch, link_attachments
|
||||
from devplacepy.database import (
|
||||
get_table,
|
||||
get_users_by_uids,
|
||||
@@ -17,6 +23,9 @@ from devplacepy.database import (
|
||||
get_reactions_by_targets,
|
||||
get_user_bookmarks,
|
||||
get_polls_by_post_uids,
|
||||
)
|
||||
from devplacepy.services.opinionwar import store as war_store
|
||||
from devplacepy.database import (
|
||||
paginate,
|
||||
text_search_clause,
|
||||
resolve_by_slug,
|
||||
@@ -25,6 +34,7 @@ from devplacepy.database import (
|
||||
get_fork_parent,
|
||||
count_forks,
|
||||
get_top_authors,
|
||||
get_user_attachment,
|
||||
)
|
||||
from devplacepy.project_files import count_files
|
||||
from devplacepy.services.jobs import queue
|
||||
@@ -41,6 +51,7 @@ from devplacepy.content import (
|
||||
can_view_project_containers,
|
||||
can_open_workspace,
|
||||
get_project_devlog,
|
||||
count_project_devlog,
|
||||
)
|
||||
from devplacepy.utils import (
|
||||
get_current_user,
|
||||
@@ -176,17 +187,33 @@ async def projects_page(
|
||||
model=ProjectsOut,
|
||||
)
|
||||
|
||||
def _editor_url(project: dict, user: dict) -> str:
|
||||
from devplacepy.services.containers import store
|
||||
from devplacepy.services.containers.workspace import provision
|
||||
async def _editor_launch(project: dict, user: dict) -> dict:
|
||||
from devplacepy.services.containers.workspace import editor, provision
|
||||
|
||||
blank = {"url": "", "mode": "tab", "width": 0, "height": 0}
|
||||
instance = provision.find_for_project(project["uid"], user["uid"])
|
||||
if not instance or instance.get("suspended_at"):
|
||||
return ""
|
||||
if instance.get("status") != store.ST_RUNNING:
|
||||
return ""
|
||||
if not instance or not await provision.editor_ready(instance):
|
||||
return blank
|
||||
slug = project["slug"] or project["uid"]
|
||||
return f"/projects/{slug}/containers/instances/{instance['uid']}/code/"
|
||||
profile = editor.resolve(user["uid"], instance)
|
||||
return {
|
||||
"url": f"/projects/{slug}/containers/instances/{instance['uid']}/code/",
|
||||
"mode": profile.window_mode,
|
||||
"width": profile.window_width,
|
||||
"height": profile.window_height,
|
||||
}
|
||||
|
||||
|
||||
def _hero_attachment_uid(user: dict, raw_uid: str) -> str | None:
|
||||
uid = (raw_uid or "").strip()
|
||||
if not uid:
|
||||
return None
|
||||
attachment = get_user_attachment(uid)
|
||||
if not attachment or attachment.get("user_uid") != user["uid"]:
|
||||
return None
|
||||
if not attachment.get("is_image"):
|
||||
return None
|
||||
return uid
|
||||
|
||||
|
||||
@router.get("/{project_slug}", response_class=HTMLResponse)
|
||||
@@ -208,13 +235,21 @@ async def project_detail(request: Request, project_slug: str, before: str = None
|
||||
|
||||
base = site_url(request)
|
||||
robots = "noindex,nofollow" if project.get("is_private") else "index,follow"
|
||||
cover_url = next(
|
||||
(
|
||||
a["url"]
|
||||
for a in detail["attachments"]
|
||||
if a["uid"] == project.get("cover_attachment_uid") and a.get("is_image")
|
||||
),
|
||||
None,
|
||||
)
|
||||
seo_ctx = base_seo_context(
|
||||
request,
|
||||
title=project.get("title", "Project"),
|
||||
description=project.get("description", ""),
|
||||
seo_target=("project", project["uid"]),
|
||||
robots=robots,
|
||||
og_image=first_image_url(project, detail["attachments"]),
|
||||
og_image=cover_url or first_image_url(project, detail["attachments"]),
|
||||
breadcrumbs=[
|
||||
{"name": "Home", "url": "/feed"},
|
||||
{"name": "Projects", "url": "/projects"},
|
||||
@@ -226,9 +261,12 @@ async def project_detail(request: Request, project_slug: str, before: str = None
|
||||
schemas=[website_schema(base), software_application_schema(project, base)],
|
||||
)
|
||||
viewer_can_workspace = can_open_workspace(project, user)
|
||||
workspace_editor_url = (
|
||||
_editor_url(project, user) if viewer_can_workspace else ""
|
||||
editor_launch = (
|
||||
await _editor_launch(project, user)
|
||||
if viewer_can_workspace
|
||||
else {"url": "", "mode": "tab", "width": 0, "height": 0}
|
||||
)
|
||||
workspace_editor_url = editor_launch["url"]
|
||||
parent = get_fork_parent(project["uid"])
|
||||
forked_from = (
|
||||
{
|
||||
@@ -251,12 +289,14 @@ async def project_detail(request: Request, project_slug: str, before: str = None
|
||||
get_user_bookmarks(user["uid"], "post", post_uids) if user else set()
|
||||
)
|
||||
polls_map = get_polls_by_post_uids(post_uids, user)
|
||||
wars_map = war_store.get_wars_by_post_uids(post_uids, user)
|
||||
for item in devlog_posts:
|
||||
uid = item["post"]["uid"]
|
||||
item["attachments"] = attachments_map.get(uid, [])
|
||||
item["reactions"] = reactions_map.get(uid, {"counts": {}, "mine": []})
|
||||
item["bookmarked"] = uid in bookmark_set
|
||||
item["poll"] = polls_map.get(uid)
|
||||
item["war"] = wars_map.get(uid)
|
||||
|
||||
return respond(
|
||||
request,
|
||||
@@ -276,11 +316,20 @@ async def project_detail(request: Request, project_slug: str, before: str = None
|
||||
"viewer_can_containers": can_view_project_containers(project, user),
|
||||
"viewer_can_workspace": viewer_can_workspace,
|
||||
"workspace_editor_url": workspace_editor_url,
|
||||
"workspace_editor_mode": editor_launch["mode"],
|
||||
"workspace_editor_width": editor_launch["width"],
|
||||
"workspace_editor_height": editor_launch["height"],
|
||||
"forked_from": forked_from,
|
||||
"fork_count": count_forks(project["uid"]),
|
||||
"file_count": count_files(project["uid"]),
|
||||
"comment_count": get_table("comments").count(
|
||||
target_type="project",
|
||||
target_uid=project["uid"],
|
||||
deleted_at=None,
|
||||
),
|
||||
"devlog_posts": devlog_posts,
|
||||
"devlog_next_cursor": devlog_next_cursor,
|
||||
"devlog_count": count_project_devlog(project["uid"]),
|
||||
},
|
||||
),
|
||||
model=ProjectDetailOut,
|
||||
@@ -396,6 +445,8 @@ async def create_project(request: Request, data: Annotated[ProjectForm, Depends(
|
||||
user = require_user(request)
|
||||
title = data.title.strip()
|
||||
description = data.description.strip()
|
||||
cover_uid = _hero_attachment_uid(user, data.cover_attachment_uid)
|
||||
logo_uid = _hero_attachment_uid(user, data.logo_attachment_uid)
|
||||
|
||||
uid, project_slug = create_content_item(
|
||||
"projects",
|
||||
@@ -409,6 +460,10 @@ async def create_project(request: Request, data: Annotated[ProjectForm, Depends(
|
||||
"project_type": data.project_type,
|
||||
"platforms": data.platforms.strip(),
|
||||
"status": data.status,
|
||||
"website_url": data.website_url or None,
|
||||
"repo_url": data.repo_url or None,
|
||||
"cover_attachment_uid": cover_uid,
|
||||
"logo_attachment_uid": logo_uid,
|
||||
"is_private": 1 if data.is_private else 0,
|
||||
"read_only": 0,
|
||||
},
|
||||
@@ -419,6 +474,7 @@ async def create_project(request: Request, data: Annotated[ProjectForm, Depends(
|
||||
data.attachment_uids,
|
||||
request,
|
||||
)
|
||||
link_attachments([u for u in (cover_uid, logo_uid) if u], "project", uid)
|
||||
url = f"/projects/{project_slug}"
|
||||
return action_result(
|
||||
request, url, data={"uid": uid, "slug": project_slug, "url": url}
|
||||
@@ -429,23 +485,71 @@ async def edit_project(
|
||||
request: Request, project_slug: str, data: Annotated[ProjectEditForm, Depends(json_or_form(ProjectEditForm))]
|
||||
):
|
||||
user = require_user(request)
|
||||
return edit_content_item(
|
||||
fields = {
|
||||
"title": data.title.strip(),
|
||||
"description": data.description.strip(),
|
||||
"release_date": data.release_date or None,
|
||||
"demo_date": data.demo_date or None,
|
||||
"project_type": data.project_type,
|
||||
"platforms": data.platforms.strip(),
|
||||
"status": data.status,
|
||||
"website_url": data.website_url or None,
|
||||
"repo_url": data.repo_url or None,
|
||||
}
|
||||
hero_uids = []
|
||||
for field in ("cover_attachment_uid", "logo_attachment_uid"):
|
||||
uid = _hero_attachment_uid(user, getattr(data, field))
|
||||
if uid:
|
||||
fields[field] = uid
|
||||
hero_uids.append(uid)
|
||||
result = edit_content_item(
|
||||
request,
|
||||
"projects",
|
||||
user,
|
||||
project_slug,
|
||||
{
|
||||
"title": data.title.strip(),
|
||||
"description": data.description.strip(),
|
||||
"release_date": data.release_date or None,
|
||||
"demo_date": data.demo_date or None,
|
||||
"project_type": data.project_type,
|
||||
"platforms": data.platforms.strip(),
|
||||
"status": data.status,
|
||||
},
|
||||
fields,
|
||||
"/projects",
|
||||
target_type="project",
|
||||
)
|
||||
if hero_uids:
|
||||
project = resolve_by_slug(get_table("projects"), project_slug)
|
||||
if project and is_owner(project, user):
|
||||
link_attachments(hero_uids, "project", project["uid"])
|
||||
return result
|
||||
|
||||
@router.post("/{project_slug}/screenshots")
|
||||
async def add_project_screenshots(
|
||||
request: Request,
|
||||
project_slug: str,
|
||||
data: Annotated[ProjectScreenshotsForm, Depends(json_or_form(ProjectScreenshotsForm))],
|
||||
):
|
||||
user = require_user(request)
|
||||
project = resolve_by_slug(get_table("projects"), project_slug)
|
||||
if not project:
|
||||
raise not_found("Project not found")
|
||||
if not is_owner(project, user):
|
||||
if wants_json(request):
|
||||
return json_error(403, "Not allowed")
|
||||
return RedirectResponse(url=f"/projects/{project_slug}", status_code=302)
|
||||
link_attachments(data.attachment_uids, "project", project["uid"])
|
||||
audit.record(
|
||||
request,
|
||||
"project.screenshots.add",
|
||||
user=user,
|
||||
target_type="project",
|
||||
target_uid=project["uid"],
|
||||
target_label=project.get("title"),
|
||||
metadata={"attachment_count": len(data.attachment_uids)},
|
||||
summary=f"{user['username']} added {len(data.attachment_uids)} screenshot(s) to project {project.get('title')}",
|
||||
links=[audit.target("project", project["uid"], project.get("title"))],
|
||||
)
|
||||
url = f"/projects/{project['slug'] or project['uid']}#screenshots"
|
||||
return action_result(
|
||||
request,
|
||||
url,
|
||||
data={"uid": project["uid"], "linked": len(data.attachment_uids), "url": url},
|
||||
)
|
||||
|
||||
|
||||
_FLAG_EVENTS = {
|
||||
("is_private", True): "project.visibility.private",
|
||||
|
||||
@@ -49,17 +49,24 @@ async def push_register(request: Request) -> JSONResponse:
|
||||
if fields is None:
|
||||
return JSONResponse({"error": "Invalid request"}, status_code=400)
|
||||
|
||||
_, created = push.register(user["uid"], provider.name, fields)
|
||||
fields = provider.stamp_registration(fields)
|
||||
write = push.register(user["uid"], provider.name, fields)
|
||||
|
||||
if created:
|
||||
delivered = None
|
||||
detail = ""
|
||||
if write.probe:
|
||||
try:
|
||||
await push.notify_user(user["uid"], WELCOME_PAYLOAD)
|
||||
outcome = await push.notify_registration(write.record, WELCOME_PAYLOAD)
|
||||
delivered = outcome.status == providers.ACCEPTED
|
||||
detail = outcome.detail
|
||||
except Exception as exc:
|
||||
logger.warning("Welcome push failed for %s: %s", user["uid"], exc)
|
||||
delivered = False
|
||||
detail = str(exc)
|
||||
|
||||
audit.record(
|
||||
request,
|
||||
"push.subscribe" if created else "push.update",
|
||||
"push.subscribe" if write.created else "push.update",
|
||||
user=user,
|
||||
target_type="user",
|
||||
target_uid=user["uid"],
|
||||
@@ -69,12 +76,19 @@ async def push_register(request: Request) -> JSONResponse:
|
||||
"endpoint_host": urlparse(fields["endpoint"]).hostname
|
||||
if fields.get("endpoint")
|
||||
else None,
|
||||
"created": created,
|
||||
"created": write.created,
|
||||
"revived": write.revived,
|
||||
"has_client_id": bool(fields.get("client_id")),
|
||||
},
|
||||
summary=f"{user.get('username')} {'registered' if created else 'updated'} a push subscription",
|
||||
summary=f"{user.get('username')} {'registered' if write.created else 'updated'} a push subscription",
|
||||
links=[audit.target("user", user["uid"], user.get("username"))],
|
||||
)
|
||||
return JSONResponse({"registered": True})
|
||||
payload: dict = {"registered": True}
|
||||
if delivered is not None:
|
||||
payload["delivered"] = delivered
|
||||
if detail and not delivered:
|
||||
payload["error"] = detail
|
||||
return JSONResponse(payload)
|
||||
|
||||
|
||||
@router.get("/service-worker.js")
|
||||
|
||||
@@ -243,6 +243,7 @@ def _session_context(request: Request, uid: str, job: dict, session: dict) -> di
|
||||
"sources": report.get("sources", []),
|
||||
"findings": report.get("findings", []),
|
||||
"timeline": report.get("timeline", []),
|
||||
"follow_up_questions": report.get("follow_up_questions", []),
|
||||
"chat_ws_url": f"/tools/deepsearch/{uid}/chat" if done else None,
|
||||
"export_md_url": f"/tools/deepsearch/{uid}/export.md" if done else None,
|
||||
"export_json_url": f"/tools/deepsearch/{uid}/export.json" if done else None,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import re
|
||||
from urllib.parse import quote
|
||||
@@ -435,7 +436,7 @@ async def isslop_source(request: Request, uid: str, path: str, line: int = 0):
|
||||
source_path = (store.media_dir_for(uid) / source_name).resolve()
|
||||
if not source_path.is_relative_to(ISSLOP_MEDIA_DIR.resolve()) or not source_path.is_file():
|
||||
raise not_found("Source not available for this file")
|
||||
text = source_path.read_text(encoding="utf-8", errors="replace")
|
||||
text = await asyncio.to_thread(source_path.read_text, encoding="utf-8", errors="replace")
|
||||
signals = store.decode_json(result.get("signals"), [])
|
||||
marked: dict[int, list] = {}
|
||||
for signal in signals:
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
from devplacepy.constants import TOPICS, TOPIC_LABELS
|
||||
from devplacepy.database import get_table
|
||||
from devplacepy.routers.feed import get_feed_posts, enrich_post_cards
|
||||
from devplacepy.utils import get_current_user, not_found
|
||||
from devplacepy.seo import list_page_seo, next_page_url
|
||||
from devplacepy.responses import respond
|
||||
from devplacepy.schemas import TopicOut, TopicsHubOut
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("", response_class=HTMLResponse)
|
||||
async def topics_hub(request: Request):
|
||||
user = get_current_user(request)
|
||||
posts_table = get_table("posts")
|
||||
topics = [
|
||||
{
|
||||
"key": topic,
|
||||
"label": TOPIC_LABELS.get(topic, topic.title()),
|
||||
"post_count": posts_table.count(topic=topic, deleted_at=None),
|
||||
}
|
||||
for topic in TOPICS
|
||||
]
|
||||
|
||||
seo_ctx = list_page_seo(
|
||||
request,
|
||||
title="Topics",
|
||||
description="Browse DevPlace posts by topic: devlog, showcase, questions, rants, fun, and more.",
|
||||
breadcrumbs=[
|
||||
{"name": "Home", "url": "/feed"},
|
||||
{"name": "Topics", "url": "/topics"},
|
||||
],
|
||||
)
|
||||
return respond(
|
||||
request,
|
||||
"topics.html",
|
||||
{
|
||||
**seo_ctx,
|
||||
"request": request,
|
||||
"user": user,
|
||||
"topics": topics,
|
||||
},
|
||||
model=TopicsHubOut,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{topic}", response_class=HTMLResponse)
|
||||
async def topic_page(request: Request, topic: str, before: str = None):
|
||||
if topic not in TOPICS:
|
||||
raise not_found("Topic not found")
|
||||
user = get_current_user(request)
|
||||
posts, next_cursor = get_feed_posts(user, "all", topic, "", before)
|
||||
posts = enrich_post_cards(posts, user)
|
||||
label = TOPIC_LABELS.get(topic, topic.title())
|
||||
|
||||
seo_ctx = list_page_seo(
|
||||
request,
|
||||
title=f"{label} posts",
|
||||
description=f"Browse {label.lower()} posts from developers on DevPlace.",
|
||||
breadcrumbs=[
|
||||
{"name": "Home", "url": "/feed"},
|
||||
{"name": "Topics", "url": "/topics"},
|
||||
{"name": label, "url": f"/topics/{topic}"},
|
||||
],
|
||||
next_url=next_page_url(request, next_cursor),
|
||||
)
|
||||
return respond(
|
||||
request,
|
||||
"topic.html",
|
||||
{
|
||||
**seo_ctx,
|
||||
"request": request,
|
||||
"user": user,
|
||||
"posts": posts,
|
||||
"topic": topic,
|
||||
"topic_label": label,
|
||||
"next_cursor": next_cursor,
|
||||
},
|
||||
model=TopicOut,
|
||||
)
|
||||
@@ -27,18 +27,8 @@ def resolve(host: str):
|
||||
return row, instance, None, None
|
||||
if instance.get("status") != store.ST_RUNNING:
|
||||
return row, instance, None, None
|
||||
gateway, _ = api.proxy_target(instance)
|
||||
host_port = _published_host_port(instance, int(row.get("container_port") or 0))
|
||||
return row, instance, gateway, host_port
|
||||
|
||||
|
||||
def _published_host_port(instance: dict, container_port: int) -> int:
|
||||
import json
|
||||
|
||||
for mapping in json.loads(instance.get("ports_json") or "[]"):
|
||||
if int(mapping.get("container") or 0) == container_port:
|
||||
return int(mapping.get("host") or 0)
|
||||
return 0
|
||||
host, port = api.tunnel_target(instance, int(row.get("container_port") or 0))
|
||||
return row, instance, host, port
|
||||
|
||||
|
||||
async def handle_http(request: Request, path: str) -> Response:
|
||||
|
||||
@@ -52,6 +52,9 @@ from devplacepy.schemas.listings import (
|
||||
ProjectsOut,
|
||||
SavedItemOut,
|
||||
SavedOut,
|
||||
TopicOut,
|
||||
TopicSummaryOut,
|
||||
TopicsHubOut,
|
||||
)
|
||||
from devplacepy.schemas.profile import (
|
||||
MediaItemOut,
|
||||
@@ -59,6 +62,7 @@ from devplacepy.schemas.profile import (
|
||||
TelegramPairOut,
|
||||
)
|
||||
from devplacepy.schemas.issues import (
|
||||
AdminIssuesPlanningOut,
|
||||
IssueAttachmentsOut,
|
||||
IssueCommentOut,
|
||||
IssueDetailOut,
|
||||
@@ -74,6 +78,7 @@ from devplacepy.schemas.containers import (
|
||||
AdminWorkspacesOut,
|
||||
BotFrameOut,
|
||||
ContainersOut,
|
||||
EditorProfileOut,
|
||||
InstanceOut,
|
||||
ScheduleOut,
|
||||
TunnelOut,
|
||||
@@ -150,6 +155,15 @@ from devplacepy.schemas.dbapi import (
|
||||
DbTableOut,
|
||||
NlQueryOut,
|
||||
)
|
||||
from devplacepy.schemas.battles import (
|
||||
BattlesOut,
|
||||
WarContributorOut,
|
||||
WarEventOut,
|
||||
WarEventsOut,
|
||||
WarOut,
|
||||
WarPersonOut,
|
||||
WarViewerOut,
|
||||
)
|
||||
from devplacepy.schemas.quiz import (
|
||||
QuizAnswerOut,
|
||||
QuizAnswerResultOut,
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from devplacepy.schemas.base import _Out
|
||||
|
||||
|
||||
class WarPersonOut(_Out):
|
||||
uid: str = ""
|
||||
username: str = ""
|
||||
avatar_seed: Optional[str] = None
|
||||
level: int = 1
|
||||
|
||||
|
||||
class WarContributorOut(WarPersonOut):
|
||||
faction: str = ""
|
||||
hp: int = 0
|
||||
|
||||
|
||||
class WarEventOut(_Out):
|
||||
seq: int = 0
|
||||
kind: str = ""
|
||||
message: str = ""
|
||||
faction: str = ""
|
||||
created_at: str = ""
|
||||
hp_a: Optional[int] = None
|
||||
hp_b: Optional[int] = None
|
||||
damage: Optional[int] = None
|
||||
winner: Optional[str] = None
|
||||
|
||||
|
||||
class WarViewerOut(_Out):
|
||||
faction: str = ""
|
||||
hp: int = 0
|
||||
rank: int = 0
|
||||
fight_count: int = 0
|
||||
last_fight_at: str = ""
|
||||
next_fight_at: str = ""
|
||||
can_fight: bool = False
|
||||
|
||||
|
||||
class WarOut(_Out):
|
||||
uid: str = ""
|
||||
post_uid: str = ""
|
||||
post_url: str = ""
|
||||
post_title: str = ""
|
||||
author: Optional[WarPersonOut] = None
|
||||
faction_a: str = ""
|
||||
faction_b: str = ""
|
||||
hp_a: int = 0
|
||||
hp_b: int = 0
|
||||
pct_a: int = 50
|
||||
pct_b: int = 50
|
||||
leader: str = ""
|
||||
fighter_count: int = 0
|
||||
status: str = "active"
|
||||
winner: str = ""
|
||||
winner_label: str = ""
|
||||
created_at: str = ""
|
||||
ends_at: str = ""
|
||||
ends_in: str = ""
|
||||
resolved_at: str = ""
|
||||
last_seq: int = 0
|
||||
fight_cost: int = 0
|
||||
top_contributors: list[WarContributorOut] = []
|
||||
recent_events: list[WarEventOut] = []
|
||||
viewer: Optional[WarViewerOut] = None
|
||||
|
||||
|
||||
class BattlesOut(_Out):
|
||||
battles: list[WarOut] = []
|
||||
current_filter: str = "active"
|
||||
counts: dict = {}
|
||||
search: str = ""
|
||||
pagination: dict = {}
|
||||
|
||||
|
||||
class WarEventsOut(_Out):
|
||||
events: list[WarEventOut] = []
|
||||
status: str = ""
|
||||
@@ -132,16 +132,40 @@ class WorkspaceFlagOut(_Out):
|
||||
created_at: str = ""
|
||||
|
||||
|
||||
class EditorProfileOut(_Out):
|
||||
trust_all: bool = True
|
||||
theme: str = ""
|
||||
font_size: int = 0
|
||||
terminal_font_size: int = 0
|
||||
zoom_level: int = 0
|
||||
layout: str = ""
|
||||
panel_preset: str = ""
|
||||
boot_agent: str = ""
|
||||
boot_shell: bool = True
|
||||
window_mode: str = ""
|
||||
window_width: int = 0
|
||||
window_height: int = 0
|
||||
cpu_millicores: int = 0
|
||||
cpu_cores: float = 0.0
|
||||
memory_mb: int = 0
|
||||
disk_quota_mb: int = 0
|
||||
sources: dict = {}
|
||||
|
||||
|
||||
class WorkspaceViewOut(_Out):
|
||||
uid: str = ""
|
||||
name: str = ""
|
||||
owner_uid: str = ""
|
||||
status: str = ""
|
||||
desired_state: str = ""
|
||||
phase: str = ""
|
||||
phase_label: str = ""
|
||||
editor_ready: bool = False
|
||||
suspended: bool = False
|
||||
flag_reason: str = ""
|
||||
tunnel_name: str = ""
|
||||
primary_url: str = ""
|
||||
last_active_at: str = ""
|
||||
flag_reason: Optional[str] = ""
|
||||
tunnel_name: Optional[str] = ""
|
||||
primary_url: Optional[str] = ""
|
||||
last_active_at: Optional[str] = ""
|
||||
disk_bytes: int = 0
|
||||
disk_quota_mb: int = 0
|
||||
disk_percent: int = 0
|
||||
@@ -151,8 +175,10 @@ class WorkspaceViewOut(_Out):
|
||||
idle_stop_minutes: int = 0
|
||||
retention_days: int = 0
|
||||
max_tunnels: int = 0
|
||||
unlimited: bool = False
|
||||
tunnels: list[TunnelOut] = []
|
||||
flags: list[WorkspaceFlagOut] = []
|
||||
editor: Optional[EditorProfileOut] = None
|
||||
|
||||
|
||||
class WorkspaceOut(_Out):
|
||||
@@ -162,8 +188,11 @@ class WorkspaceOut(_Out):
|
||||
viewer_can_workspace: bool = False
|
||||
workspace_count: int = 0
|
||||
max_workspaces: int = 0
|
||||
unlimited_workspaces: bool = False
|
||||
editor_url: str = ""
|
||||
editor_password: str = ""
|
||||
editor: Optional[EditorProfileOut] = None
|
||||
restart_required: bool = False
|
||||
user: Optional[Any] = None
|
||||
|
||||
|
||||
|
||||
@@ -121,6 +121,10 @@ class ProjectOut(_Out):
|
||||
read_only: Optional[bool] = None
|
||||
release_date: Optional[str] = None
|
||||
demo_date: Optional[str] = None
|
||||
website_url: Optional[str] = None
|
||||
repo_url: Optional[str] = None
|
||||
cover_attachment_uid: Optional[str] = None
|
||||
logo_attachment_uid: Optional[str] = None
|
||||
created_at: Optional[str] = None
|
||||
updated_at: Optional[str] = None
|
||||
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from devplacepy.schemas.base import _Out
|
||||
|
||||
|
||||
@@ -13,8 +15,11 @@ class GameCropOut(_Out):
|
||||
reward_coins: int = 0
|
||||
reward_xp: int = 0
|
||||
min_level: int = 1
|
||||
min_mastery: int = 0
|
||||
grow_seconds: int = 0
|
||||
locked: bool = False
|
||||
locked_reason: str = ""
|
||||
locked_text: str = ""
|
||||
market_state: str = "normal"
|
||||
|
||||
|
||||
@@ -183,6 +188,7 @@ class GameFarmOut(_Out):
|
||||
class GameStateOut(_Out):
|
||||
ok: bool = True
|
||||
farm: GameFarmOut
|
||||
game_error: Optional[str] = None
|
||||
|
||||
|
||||
class GameFarmViewOut(_Out):
|
||||
@@ -190,6 +196,7 @@ class GameFarmViewOut(_Out):
|
||||
page_title: str = ""
|
||||
meta_description: str = ""
|
||||
stole_coins: int = 0
|
||||
game_error: Optional[str] = None
|
||||
|
||||
|
||||
class GameLeaderboardEntryOut(_Out):
|
||||
|
||||
@@ -67,3 +67,16 @@ class IssuesOut(_Out):
|
||||
state: str = "open"
|
||||
configured: bool = True
|
||||
error_message: Optional[str] = None
|
||||
viewer_is_admin: bool = False
|
||||
|
||||
|
||||
class AdminPlanningTicketOut(_Out):
|
||||
number: int = 0
|
||||
title: str = ""
|
||||
labels: list[str] = []
|
||||
|
||||
|
||||
class AdminIssuesPlanningOut(_Out):
|
||||
configured: bool = True
|
||||
tickets: list[AdminPlanningTicketOut] = []
|
||||
tickets_error: bool = False
|
||||
|
||||
@@ -132,6 +132,7 @@ class DeepsearchSessionOut(_Out):
|
||||
sources: list = []
|
||||
findings: list = []
|
||||
timeline: list = []
|
||||
follow_up_questions: list = []
|
||||
chat_ws_url: Optional[str] = None
|
||||
export_md_url: Optional[str] = None
|
||||
export_json_url: Optional[str] = None
|
||||
@@ -203,6 +204,10 @@ class IsslopReportOut(_Out):
|
||||
detected_builder: Optional[str] = None
|
||||
dom_slop_score: Optional[float] = None
|
||||
error: Optional[str] = None
|
||||
report_url: Optional[str] = None
|
||||
badge_url: Optional[str] = None
|
||||
events_url: Optional[str] = None
|
||||
topic: Optional[str] = None
|
||||
content_hash: Optional[str] = None
|
||||
markdown: str = ""
|
||||
generator_model: str = ""
|
||||
@@ -225,3 +230,7 @@ class IsslopSourceOut(_Out):
|
||||
source: str = ""
|
||||
truncated: bool = False
|
||||
signals: list = []
|
||||
source_lines: list = []
|
||||
marked_lines: dict = {}
|
||||
focus_line: int = 0
|
||||
report_url: Optional[str] = None
|
||||
|
||||
@@ -5,6 +5,7 @@ from __future__ import annotations
|
||||
from typing import Any, Optional
|
||||
|
||||
from devplacepy.schemas.base import _Out
|
||||
from devplacepy.schemas.battles import WarOut
|
||||
from devplacepy.schemas.content import (
|
||||
AttachmentOut,
|
||||
CommentItemOut,
|
||||
@@ -33,6 +34,7 @@ class FeedItemOut(_Out):
|
||||
reactions: ReactionsOut = ReactionsOut()
|
||||
bookmarked: bool = False
|
||||
poll: Optional[PollOut] = None
|
||||
war: Optional[WarOut] = None
|
||||
project_link: Optional[ProjectLinkOut] = None
|
||||
|
||||
|
||||
@@ -106,6 +108,23 @@ class SavedItemOut(_Out):
|
||||
time_ago: Optional[str] = None
|
||||
|
||||
|
||||
class TopicOut(_Out):
|
||||
posts: list[FeedItemOut] = []
|
||||
topic: str = ""
|
||||
topic_label: str = ""
|
||||
next_cursor: Optional[str] = None
|
||||
|
||||
|
||||
class TopicSummaryOut(_Out):
|
||||
key: str = ""
|
||||
label: str = ""
|
||||
post_count: int = 0
|
||||
|
||||
|
||||
class TopicsHubOut(_Out):
|
||||
topics: list[TopicSummaryOut] = []
|
||||
|
||||
|
||||
class FeedOut(_Out):
|
||||
posts: list[FeedItemOut] = []
|
||||
current_tab: Optional[str] = None
|
||||
@@ -134,6 +153,7 @@ class PostDetailOut(_Out):
|
||||
reactions: ReactionsOut = ReactionsOut()
|
||||
bookmarked: bool = False
|
||||
poll: Optional[PollOut] = None
|
||||
war: Optional[WarOut] = None
|
||||
comment_count: Optional[int] = None
|
||||
related_posts: list[FeedItemOut] = []
|
||||
topics: list[str] = []
|
||||
@@ -169,11 +189,16 @@ class ProjectDetailOut(_Out):
|
||||
viewer_can_containers: bool = False
|
||||
viewer_can_workspace: bool = False
|
||||
workspace_editor_url: Optional[str] = None
|
||||
workspace_editor_mode: Optional[str] = None
|
||||
workspace_editor_width: Optional[int] = None
|
||||
workspace_editor_height: Optional[int] = None
|
||||
forked_from: Optional[dict] = None
|
||||
fork_count: int = 0
|
||||
file_count: int = 0
|
||||
comment_count: int = 0
|
||||
devlog_posts: list[FeedItemOut] = []
|
||||
devlog_next_cursor: Optional[str] = None
|
||||
devlog_count: int = 0
|
||||
|
||||
|
||||
class GistsOut(_Out):
|
||||
|
||||
@@ -184,6 +184,8 @@ class QuizAttemptOut(_Out):
|
||||
class QuizAttemptPageOut(_Out):
|
||||
quiz: QuizOut = QuizOut()
|
||||
attempt: QuizAttemptOut = QuizAttemptOut()
|
||||
answer_max_chars: int = 0
|
||||
quiz_error: Optional[str] = None
|
||||
|
||||
|
||||
class QuizResultOut(_Out):
|
||||
@@ -225,6 +227,7 @@ class QuizBuilderOut(_Out):
|
||||
questions: list[QuizQuestionOut] = []
|
||||
kinds: list[Any] = []
|
||||
validation_errors: list[str] = []
|
||||
quiz_error: Optional[str] = None
|
||||
|
||||
|
||||
class QuizFormPageOut(_Out):
|
||||
|
||||
@@ -39,7 +39,7 @@ class StatisticsHighlightOut(BaseModel):
|
||||
value: Any
|
||||
|
||||
|
||||
class StatisticsOut(BaseModel):
|
||||
class StatisticsPayloadOut(BaseModel):
|
||||
tab: str
|
||||
window_hours: int
|
||||
granularity: str
|
||||
@@ -49,4 +49,18 @@ class StatisticsOut(BaseModel):
|
||||
series: list[StatisticsSeriesOut] = []
|
||||
tables: list[StatisticsTableOut] = []
|
||||
highlights: list[StatisticsHighlightOut] = []
|
||||
notes: dict[str, Any] = {}
|
||||
notes: dict[str, Any] = {}
|
||||
|
||||
|
||||
class StatisticsTabOut(BaseModel):
|
||||
key: str
|
||||
label: str
|
||||
icon: str
|
||||
active: bool
|
||||
|
||||
|
||||
class StatisticsOut(BaseModel):
|
||||
active_tab: str
|
||||
window_hours: int
|
||||
tabs: list[StatisticsTabOut] = []
|
||||
initial: StatisticsPayloadOut
|
||||
+46
-1
@@ -91,7 +91,41 @@ def breadcrumb_schema(items, base_url):
|
||||
}
|
||||
|
||||
|
||||
def discussion_forum_posting(post, author, comment_count, star_count, base_url):
|
||||
MAX_SCHEMA_COMMENTS = 20
|
||||
|
||||
|
||||
def comment_schema(comment_item, base_url):
|
||||
comment = comment_item["comment"]
|
||||
author = comment_item.get("author")
|
||||
return {
|
||||
"@type": "Comment",
|
||||
"text": truncate(plain_markdown(comment.get("content", "")), 300),
|
||||
"author": {
|
||||
"@type": "Person",
|
||||
"name": author["username"] if author else "Unknown",
|
||||
"url": f"{base_url}/profile/{author['username']}" if author else "",
|
||||
},
|
||||
"datePublished": comment.get("created_at", ""),
|
||||
}
|
||||
|
||||
|
||||
def comment_schema_list(comment_tree, base_url, limit=MAX_SCHEMA_COMMENTS):
|
||||
flat = []
|
||||
|
||||
def walk(items):
|
||||
for item in items:
|
||||
if len(flat) >= limit:
|
||||
return
|
||||
flat.append(comment_schema(item, base_url))
|
||||
walk(item.get("children", []))
|
||||
|
||||
walk(comment_tree)
|
||||
return flat
|
||||
|
||||
|
||||
def discussion_forum_posting(
|
||||
post, author, comment_count, star_count, base_url, comments=None
|
||||
):
|
||||
schema = {
|
||||
"@type": "DiscussionForumPosting",
|
||||
"headline": post.get("title") or "Untitled",
|
||||
@@ -117,6 +151,8 @@ def discussion_forum_posting(post, author, comment_count, star_count, base_url):
|
||||
},
|
||||
],
|
||||
}
|
||||
if comments:
|
||||
schema["comment"] = comments
|
||||
return schema
|
||||
|
||||
|
||||
@@ -421,11 +457,19 @@ def _build_sitemap(base_url):
|
||||
urlset.append(url_element(f"{base_url}/", changefreq="daily", priority="1.0"))
|
||||
urlset.append(url_element(f"{base_url}/feed", changefreq="hourly", priority="0.9"))
|
||||
urlset.append(url_element(f"{base_url}/news", changefreq="hourly", priority="0.9"))
|
||||
urlset.append(url_element(f"{base_url}/topics", changefreq="daily", priority="0.7"))
|
||||
from devplacepy.constants import TOPICS
|
||||
|
||||
for topic in TOPICS:
|
||||
urlset.append(
|
||||
url_element(f"{base_url}/topics/{topic}", changefreq="daily", priority="0.7")
|
||||
)
|
||||
urlset.append(
|
||||
url_element(f"{base_url}/projects", changefreq="daily", priority="0.8")
|
||||
)
|
||||
urlset.append(url_element(f"{base_url}/gists", changefreq="daily", priority="0.8"))
|
||||
urlset.append(url_element(f"{base_url}/quizzes", changefreq="daily", priority="0.8"))
|
||||
urlset.append(url_element(f"{base_url}/battles", changefreq="daily", priority="0.7"))
|
||||
urlset.append(
|
||||
url_element(f"{base_url}/leaderboard", changefreq="daily", priority="0.7")
|
||||
)
|
||||
@@ -433,6 +477,7 @@ def _build_sitemap(base_url):
|
||||
urlset.append(url_element(f"{base_url}/tools", changefreq="monthly", priority="0.5"))
|
||||
urlset.append(url_element(f"{base_url}/tools/seo", changefreq="monthly", priority="0.5"))
|
||||
urlset.append(url_element(f"{base_url}/tools/deepsearch", changefreq="monthly", priority="0.5"))
|
||||
urlset.append(url_element(f"{base_url}/tools/isslop", changefreq="monthly", priority="0.5"))
|
||||
urlset.append(
|
||||
url_element(f"{base_url}/workspaces/index", changefreq="daily", priority="0.6")
|
||||
)
|
||||
|
||||
@@ -26,7 +26,7 @@ Generic, lightweight, **fire-and-forget** offload for non-critical side-effects
|
||||
- **Field registry is the single source of truth.** `CORRECTABLE_FIELDS: dict[str, tuple[str, ...]]` maps each correctable table to its prose columns: `posts` -> `(title, content)`, `projects`/`gists` -> `(title, description)`, `comments`/`messages` -> `(content,)`, `users` -> `(bio,)`. `gists.source_code`, project files, and Gitea issues are intentionally excluded - code and external systems are never corrected.
|
||||
- **Choke entrypoint.** `schedule_correction(user, table, uid, request=None)` is the only thing handlers call (every hook passes `request`). It is a no-op unless: a user dict is present, `table` is in the registry, `user["ai_correction_enabled"]` is truthy, and the user has a non-empty `api_key`. In **sync** mode (`user["ai_correction_sync"]`) it calls `_run_inline_awaited`, which - only when a `request.scope` and a running event loop exist - submits `_run_correction` to `loop.run_in_executor(AI_APPLY_EXECUTOR, ...)` (off the loop thread, on the dedicated AI pool) and stashes the future on `request.scope[PENDING_SCOPE_KEY]` for the middleware to await; if there is no request/loop it returns False and falls back to background. In **background** mode (default) it `background.submit`s `_run_correction` (per-worker queue; also runs inline under `DEVPLACE_DISABLE_SERVICES=1`). The same `_run_correction` worker function runs in all cases.
|
||||
- **The hooks (covers UI + REST + Devii + devRant in one place):** `content.create_content_item` (posts/projects/gists), `content.edit_content_item`, `content.create_comment_record`, `content.edit_comment_record`, `services/messaging/persist.persist_message` (DMs - sender is the user), `routers/profile/index.update_profile` (bio), and the two devRant direct-update edit paths (`routers/devrant/rants.edit_rant`, `routers/devrant/comments.edit_comment`). devRant create paths route through the shared content cores, so they are already hooked. Code-only and external paths (project files, Gitea) are deliberately not hooked.
|
||||
- **The gateway call is fail-soft.** `correct_text(api_key, prompt, text)` is synchronous (runs on the background worker thread), POSTs to `INTERNAL_GATEWAY_URL` with `model=INTERNAL_MODEL` via `stealth.stealth_sync_client`, authenticated with the user's own `Bearer` api_key (per-user attribution). It returns the ORIGINAL text on any error, empty output, or suspiciously large output (`len > len(text) * MAX_GROWTH_FACTOR + 200`, rejecting hallucinated expansion). `_run_correction` reads the row back, corrects each registry field that has non-blank text, writes only changed fields via `table.update(updates, ["uid"])` without touching `updated_at` (auto-correction is not a user edit) or the slug (slugs are permanent), and `clear_user_cache(user_uid)` when `table == "users"` so the corrected bio re-caches.
|
||||
- **The gateway call is fail-soft.** `correct_text(api_key, prompt, text)` is synchronous (runs on the background worker thread), POSTs to `INTERNAL_GATEWAY_URL` with `model=correction_model()` (`get_setting("correction_model", "") or INTERNAL_MODEL` - admin-configurable at `/admin/settings`, blank falls back to the gateway default `molodetz`; the gateway URL itself is never configurable per feature, always `INTERNAL_GATEWAY_URL`) via `stealth.stealth_sync_client`, authenticated with the user's own `Bearer` api_key (per-user attribution). It returns the ORIGINAL text on any error, empty output, or suspiciously large output (`len > len(text) * MAX_GROWTH_FACTOR + 200`, rejecting hallucinated expansion). `_run_correction` reads the row back, corrects each registry field that has non-blank text, writes only changed fields via `table.update(updates, ["uid"])` without touching `updated_at` (auto-correction is not a user edit) or the slug (slugs are permanent), and `clear_user_cache(user_uid)` when `table == "users"` so the corrected bio re-caches.
|
||||
- **Per-user usage aggregation (only on success).** `correct_text` returns `(text, usage)`; `usage` is parsed from the gateway's `X-Gateway-*` response headers (`_usage_from_headers`) whenever the upstream call returned 200 (cost was incurred, even if the corrected output was rejected), else `None` on any failure. `_usage_from_headers` captures the token and cost headers PLUS the timing headers `X-Gateway-Upstream-Latency-Ms` and `X-Gateway-Total-Latency-Ms` (as `upstream_latency_ms`/`total_latency_ms`), so each call's timing is metered. `_run_correction` accumulates the per-field `usage` into one `totals` dict and, when `totals["calls"] > 0`, makes ONE call to `database.add_correction_usage(user_uid, totals)` (a single `totals` dict, not positional args) - so a 2-field content item is a single aggregated write, and a failed/empty correction records nothing. `add_correction_usage` (and `add_modifier_usage`) delegate to the shared `database._add_usage(usage_table, user_uid, totals)`: a single atomic `INSERT ... ON CONFLICT(user_uid) DO UPDATE SET col = col + excluded.col` upsert against the `correction_usage` table (per-user running SUMS: `calls`/`prompt_tokens`/`completion_tokens`/`total_tokens`/`cost_usd`/`upstream_latency_ms`/`total_latency_ms`/`updated_at`, unique index `idx_correction_usage_user`, the two latency columns REAL default 0.0, all ensured in `init_db`). It is a derived counter table (NOT in `SOFT_DELETE_TABLES`, like `gateway_usage_ledger`) and is deliberately separate from `users` so accumulating never invalidates the auth/user cache. `database.get_correction_usage(user_uid)` (via `_get_usage`) returns the stored sums PLUS computed averages: `avg_tokens` (total_tokens/calls), `avg_upstream_latency_ms`, `avg_total_latency_ms`, `avg_tokens_per_second` (completion_tokens over total upstream seconds), and `avg_cost_usd`.
|
||||
- **Profile display:** `routers/profile/usage._correction_usage(uid, include_cost)` shapes it via the shared `_usage_view(data, include_cost)` (mirrors `_ai_quota`); `profile/index.py` builds it only for `is_owner or viewer_is_admin` and passes `include_cost=viewer_is_admin`, exposed as the `correction_usage` dict on the context and `ProfileOut`. The view surfaces the sums plus averages - `avg_tokens` (avg tokens/call), `avg_latency_ms` (avg upstream latency), `avg_total_latency_ms`, `avg_tokens_per_second` (avg speed), and `total_time_s` (total upstream seconds) - rendered as extra tiles on the card. **Financial gating:** tokens/call-count and the performance tiles show to the owner and admins; the dollar `cost_usd` and `avg_cost_usd` keys are present ONLY when `viewer_is_admin`, in BOTH the HTML card (`templates/profile.html`, `.correction-usage-*`) and the `respond(..., model=ProfileOut)` JSON (same rule as `_ai_quota`'s `spent_usd` - hiding it in the template alone would leak it to a member fetching their own profile as JSON). The card renders only when `correction_usage.calls` > 0.
|
||||
- **Import-cycle discipline.** `correction.py` imports only `stealth`, `config`, `database.get_table`/`add_correction_usage`, and `services.background.background` at module top; `clear_user_cache` is imported lazily inside `_run_correction`. Never import `content` or `utils` at module top.
|
||||
@@ -37,7 +37,7 @@ Generic, lightweight, **fire-and-forget** offload for non-critical side-effects
|
||||
**A sibling of AI content correction that runs only on an explicit inline directive.** The engine reuses the correction plumbing wholesale (`CORRECTABLE_FIELDS`, `PENDING_SCOPE_KEY`, `gateway_complete`, the sync/background apply-mode mechanics, the per-user usage upsert) and differs only in the trigger and the apply-mode/enabled defaults: it is **enabled by default** and **synchronous by default**, and it runs ONLY where an authored prose field contains an inline `@ai <instruction>` directive.
|
||||
|
||||
- **The `@ai` gate is the whole difference.** `has_ai_directive(text)` matches the regex `@ai\s+\S` (case-insensitive). `schedule_modification(user, table, uid, request=None)` is a no-op unless a user is present, `table` is in `CORRECTABLE_FIELDS`, `user["ai_modifier_enabled"]` is truthy, the user has an `api_key`, AND at least one of the table's registry fields actually contains an `@ai` directive. `_run_modification` re-checks the gate per field, so untriggered fields are never sent to the gateway and never metered. Triggerless writes cost nothing. The configured prompt (default `config.DEFAULT_MODIFIER_PROMPT` = "Execute what is behind `@ai` (the prompt) and replace that part including `@ai`") tells the model to execute the instruction and replace the marked part including the `@ai` marker.
|
||||
- **Total reuse of the correction layer.** `CORRECTABLE_FIELDS`, `PENDING_SCOPE_KEY`, `gateway_complete`, the sync/background apply-mode mechanics (`_run_inline_awaited` -> `loop.run_in_executor` + `request.scope[PENDING_SCOPE_KEY]` awaited by the `await_pending_corrections` middleware in `main.py`, the same self-deadlock-avoiding path), and the per-user usage upsert pattern are all imported from / mirror `services/correction.py`. `modify_text(api_key, prompt, text, context="")` composes a modifier system message and calls the shared `gateway_complete`. The same hooks fire it: `profile/index.update_profile` calls both `schedule_correction` and `schedule_modification`, and the content/comment/messaging cores invoke it alongside correction, so it covers the web UI, REST, devRant, and Devii in one place. Code and source files are never modified (same `CORRECTABLE_FIELDS` registry, `gists.source_code`/project files/Gitea excluded). `schedule_modification` is hooked alongside `schedule_correction` at the same content/comment/messaging/profile entrypoints.
|
||||
- **Total reuse of the correction layer.** `CORRECTABLE_FIELDS`, `PENDING_SCOPE_KEY`, `gateway_complete`, the sync/background apply-mode mechanics (`_run_inline_awaited` -> `loop.run_in_executor` + `request.scope[PENDING_SCOPE_KEY]` awaited by the `await_pending_corrections` middleware in `main.py`, the same self-deadlock-avoiding path), and the per-user usage upsert pattern are all imported from / mirror `services/correction.py`. `modify_text(api_key, prompt, text, context="")` composes a modifier system message and calls the shared `gateway_complete` with `model=modifier_model()` (`get_setting("modifier_model", "") or INTERNAL_MODEL`, its own admin-configurable setting at `/admin/settings`, independent of `correction_model` so each feature can use a different model - blank falls back to the gateway default `molodetz`). The same hooks fire it: `profile/index.update_profile` calls both `schedule_correction` and `schedule_modification`, and the content/comment/messaging cores invoke it alongside correction, so it covers the web UI, REST, devRant, and Devii in one place. Code and source files are never modified (same `CORRECTABLE_FIELDS` registry, `gists.source_code`/project files/Gitea excluded). `schedule_modification` is hooked alongside `schedule_correction` at the same content/comment/messaging/profile entrypoints.
|
||||
- **Context-aware (modifier only, not correction).** Unlike correction, the modifier gives the model a grounding **context block** so an `@ai` instruction can reason about who is asking and what it is attached to. `services/ai_context.py` `build_context(table, uid, row, user_uid) -> str` assembles it; `_run_modification` builds it **lazily once per row** (only after a field is confirmed to contain `@ai`, so triggerless writes do no extra queries) and passes it to every field's `modify_text`, which appends it to the system message under a `# Context (use it to inform the result; never echo this block)` header. The block (fail-soft, length-capped, each part wrapped in try/except so a failed lookup never blocks the modification) has three parts:
|
||||
1. **date** - `Today is DD/MM/YYYY on the DevPlace developer network.`
|
||||
2. **author/stats** - the author's username, role, level, stars, post count (`get_user_post_count`), leaderboard rank (`get_user_rank`), follower count (`get_follow_counts`), member-since date, and bio (capped `MAX_BIO`).
|
||||
@@ -118,6 +118,10 @@ devplace devii reset-quota --all # Reset every quota (users and guests)
|
||||
|
||||
`ModerationService` is a lock-owner `BaseService` (default-enabled, hourly, floor 300s) with two jobs: it purges accounts whose deletion grace window has closed (`deletion.purge_due`, the same code path as `devplace accounts prune`), and it reports the moderation queue's service-level snapshot - logging when a report is past the published response window and exposing the queue counts, the oldest open age and the pending-purge count as `collect_metrics` stat cards. It owns no request-path work; the queue itself is entirely synchronous. Full subsystem detail in `devplacepy/services/moderation/CLAUDE.md`.
|
||||
|
||||
## Acceptance convergence (`services/acceptance/service.py`)
|
||||
|
||||
`AcceptanceService` is a lock-owner `BaseService` (**opt-in**, `default_enabled = False`, five minutes, floor 60s) that grants every policy agreement to every account which has not declined it, so a production-identical instance used for manual testing never interrupts with an acceptance dialog. It is invisible to the rest of the application by contract: one registration line in `main.py` is the only import anywhere, and there is no route, schema, template, Devii tool or environment flag. The decline register is the consent ledger itself - the service only ever writes `granted`, so any `withdrawn` row was written by a human and that pair is never touched again. Three gates stand between a fresh install and a single written row (service disabled, every agreement disabled, dry run on). Full subsystem detail in `devplacepy/services/acceptance/CLAUDE.md`; the design record is `accept.md` at the repository root.
|
||||
|
||||
## Multi-worker concurrency (preferred rules)
|
||||
|
||||
`uvicorn --workers N` = N independent processes sharing only the filesystem and SQLite DB. Module-global caches/counters are per-process, so a local `clear()` is invisible to siblings. Full reference: admin docs `Production -> Multi-worker and concurrency` (`templates/docs/production-concurrency.html`). Enforce these:
|
||||
@@ -158,8 +162,9 @@ Rules: a new hot read-path aggregate follows this exact pattern (module-level `T
|
||||
| `admin.services.{name}` | 5s | `{service}` |
|
||||
| `admin.ai-usage.{hours}` | 15s | `build_analytics(hours)` (hours parsed from the topic) |
|
||||
| `admin.backups` | 8s | `routers/admin/backups._dashboard(can_download=False)` (storage, backups, schedules, metrics) |
|
||||
| `user.{owner_uid}.workspace.{uid}` | 3s | `{workspace, editor_url}` (`provision.view`, the same shape `GET /projects/{slug}/workspace` JSON carries; `None` unless the instance is a workspace owned by `owner_uid`) |
|
||||
|
||||
All these topics are admin-only by pub/sub policy (non-`public`, non-`user.{uid}` -> `privileged` required), matching the admin-only pages. Frontend monitors (`ContainerInstance`, `ContainerList`, `ContainerManager`, `BotMonitor`, `ServiceMonitor`, `AiUsageMonitor`, `BackupMonitor`) each `window.app.pubsub.subscribe(topic, render)` in their init and keep a **lengthened HTTP poll (15-30s) as initial-load + fallback** - the relay drives liveness at the cadence above. `AiUsageMonitor` re-subscribes (unsubscribe old, subscribe new) when the window-hours selector changes, since hours is in the topic.
|
||||
All these topics except the last are admin-only by pub/sub policy (non-`public`, non-`user.{uid}` -> `privileged` required), matching the admin-only pages. The workspace topic is the one member-facing view: it sits in the owner's private `user.{uid}.*` namespace so the owner (and admins) can subscribe and nobody else can, and the compute callable re-checks `workspace_owner_uid` against the topic so a guessed uid never leaks another member's workspace. `WorkspaceManager` subscribes to it and keeps a 2s/20s HTTP poll as fallback (see `devplacepy/services/containers/CLAUDE.md`). Frontend monitors (`ContainerInstance`, `ContainerList`, `ContainerManager`, `BotMonitor`, `ServiceMonitor`, `AiUsageMonitor`, `BackupMonitor`) each `window.app.pubsub.subscribe(topic, render)` in their init and keep a **lengthened HTTP poll (15-30s) as initial-load + fallback** - the relay drives liveness at the cadence above. `AiUsageMonitor` re-subscribes (unsubscribe old, subscribe new) when the window-hours selector changes, since hours is in the topic.
|
||||
|
||||
**Container topics never broadcast private-project instances**: `container.list` publishes only public-project rows with `partial: true` (`ContainerList.merge` updates by uid, never removes, so private rows from the authoritative HTTP poll survive), and `project.{slug}.containers` / `container.{uid}.detail` / `container.{uid}.logs` skip private-project targets entirely (owners fall back to their HTTP polls).
|
||||
|
||||
@@ -181,7 +186,7 @@ Online status is a single **`users.last_seen`** UTC-ISO column (ensured in `data
|
||||
|
||||
**Write path (all workers):** `main.py`'s `track_presence` HTTP middleware resolves the cached current user on every non-`/static`, non-`/avatar` request and calls `presence.touch(uid)`. `touch` keeps a per-worker in-memory `_last_write: dict[uid -> monotonic]` and writes `users.last_seen` (via `database.set_last_seen`) only when the last write for that uid is older than `config.PRESENCE_WRITE_SECONDS` (= `PRESENCE_TIMEOUT_SECONDS // 2`). So continuous browsing is a dict lookup; a write happens at most ~once per half-window per active user per worker, and the row is updated in place (zero growth). It deliberately does **not** call `clear_user_cache` (that would defeat the 300s auth cache; the stale cached self-row is irrelevant since presence of *other* users is always read from a fresh row).
|
||||
|
||||
**Consent gate (write path).** `touch` checks `presence.recording_allowed(uid)` (the `activity_recording` consent) **after** the per-worker throttle, so the consent read costs at most one query per half-window per active user rather than one per request. A user who withdraws the consent simply stops being written and appears offline; `base.html` shows a `.recording-indicator` while it is on. Never move the check above the throttle.
|
||||
**Consent gate (write path).** `touch` checks `presence.recording_allowed(uid)` (the `activity_recording` consent) **after** the per-worker throttle, so the consent read costs at most one query per half-window per active user rather than one per request. A user who withdraws the consent simply stops being written and appears offline. Never move the check above the throttle.
|
||||
|
||||
**Read path (any worker):** `presence.is_online(user_row)` = `now - last_seen < PRESENCE_TIMEOUT_SECONDS` (env `DEVPLACE_PRESENCE_TIMEOUT_SECONDS`, default 60). Profile (`routers/profile/index.py` -> `profile_online`) and messages (`routers/messages.py` seed) read `last_seen` off the user row they already loaded - no extra query. Exposed as the Jinja global `is_online(user)` (`templating.py`), on `UserOut.last_seen` and `ProfileOut.profile_online`. This is the **only** cross-worker-correct approach here because pub/sub is in-process.
|
||||
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file documents the acceptance convergence subsystem (`devplacepy/services/acceptance/`). Claude Code auto-loads it whenever a file under this directory is read or edited. The full design record is [`accept.md`](../../../accept.md) at the repository root.
|
||||
|
||||
## Why this subsystem exists
|
||||
|
||||
An operator running a production-identical instance for extended manual testing is otherwise taxed forever by the platform's own safety controls: five consents, a versioned terms gate on every mutating request, and every account predating the trust-and-safety commit reading `terms_version = NULL` because `init_db` deliberately never backfills it. This service converges each account onto the acceptance state a real population would have produced itself, so the instance stays production byte for byte while nobody has to click the same dialog again.
|
||||
|
||||
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 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.
|
||||
|
||||
## Module map
|
||||
|
||||
| File | Owns |
|
||||
|---|---|
|
||||
| `agreements.py` | `Agreement`, `AGREEMENTS`, `setting_key`, `label_for`, `agreement_for` |
|
||||
| `pending.py` | `latest_consent`, `not_withdrawn`, `satisfied_clause`, `live_account_clauses`, `current_version`, `pending` |
|
||||
| `grant.py` | `converge_user` plus the two private claim shapes and the audit call |
|
||||
| `service.py` | `AcceptanceService`: config fields built from the registry, `run_once`, `collect_metrics` |
|
||||
|
||||
`pending.py` and `grant.py` import only `devplacepy.database` and `sqlalchemy` at module top; `generate_uid` and the audit recorder are imported lazily inside the functions that use them, mirroring `services/moderation/deletion.py`.
|
||||
|
||||
## The registry is the completeness guarantee
|
||||
|
||||
`AGREEMENTS` annotates `database.CONSENT_KINDS` with two facts: which `site_settings` key holds the policy version, and which `users` column the application's own gate reads. `terms` is the only agreement with a gate column, because `needs_acceptance` reads `users.terms_version` and not the ledger; `privacy` is versioned but ledger-only, matching `VERSION_KEYS` in `routers/profile/consent.py`.
|
||||
|
||||
A unit test asserts `{a.kind for a in AGREEMENTS} == set(CONSENT_KINDS)`. **A sixth consent fails the suite until it is classified here**, and it then appears in the admin form with no edit to the service, because the per-agreement config fields are built from the registry rather than written out by hand.
|
||||
|
||||
## Rules that must not regress
|
||||
|
||||
- **Satisfaction is the gate's own expression, never a proxy.** `pending` compares exactly what the application compares: `users.terms_version` against `get_setting("terms_version", "1") or "1"` for `terms`, the ledger row's `version` for `privacy`, the latest state for the other three. The `or "1"` is load-bearing: an admin settings save can write `terms_version = ""`, and a bare `get_setting` would make every account pending forever.
|
||||
- **Order by `created_at DESC, id DESC`, never one of the two.** That pair is what `database.consent_state` selects, so it is the expression the gate evaluates. `consent_view` on the privacy tab orders by `created_at` alone; that is the display path, not a gate. Never introduce a third ordering.
|
||||
- **Build the live-account clauses with `has_column`.** `init_db` ensures `terms_version`, `terms_accepted_at` and `deletion_requested_at` on `users`, but **not `is_active`** - that column is created implicitly the first time a suspension or a deletion writes it, so a hardcoded reference raises `no such column` on an instance where nobody was ever suspended. An absent column means no account can be in that state, so omitting the clause is the correct answer.
|
||||
- **Every write is one conditional statement decided on the driver's real `rowcount`**, via `db.executable.execute(text(...), params).rowcount` inside `with db:`, exactly like `deletion.claim_deletion`. Sixteen real processes racing one account produce exactly one ledger row and one audit row.
|
||||
- **Never write `updated_at` on `users`.** That is why `database.atomic.conditional_update_row` cannot be reused here: it appends `updated_at` unconditionally, the table has no such column, and creating one would make the service's rows distinguishable from the route's.
|
||||
- **The ledger insert must stay byte-compatible with `set_consent`**, including `withdrawn_at = ''` rather than `NULL` on the unused side. A unit test compares the two field by field. It is not `set_consent` itself only because `set_consent` cannot express a precondition or join a caller's transaction.
|
||||
- **One cache bump per run, not one per account.** `clear_user_cache` propagates a global `auth` version bump that makes every worker drop its whole user cache; `run_once` bumps once at the end, and only when a gate column actually changed.
|
||||
- **The audit row is deliberate.** It uses the existing `terms.accept` and `consent.grant` keys with `actor_kind="service"`, `actor_username="acceptance"`. The audit log is the operator's record and no code path reads it, so it costs nothing in invisibility and is the only trace distinguishing a converged acceptance from a human one.
|
||||
|
||||
## What is deliberately not an agreement
|
||||
|
||||
| Excluded | Why |
|
||||
|---|---|
|
||||
| `users.age_band` | A declaration of fact, not an agreement. Fabricating a declared age would silently unlock `restricted` content for an account that declared 13-15. Sign the test account up with an adult date of birth instead. |
|
||||
| `users.mature_opt_in` | A preference gated by the age band, with no ledger row and therefore no decline register. The site setting `moderation_mature_default_hidden` already turns the interstitial off instance-wide. |
|
||||
| Guest consents | Nothing writes a consent row with `owner_kind = "guest"`, and the gateway resolves a guest to owner kind `anonymous`, which `consent_denied` exempts by design. |
|
||||
| Preferences (`interactions_enabled`, notifications, customization) | None of them blocks anything. |
|
||||
|
||||
## The terms asymmetry, and the one operator step
|
||||
|
||||
`POST /auth/accept-terms` grants **two** agreements: `terms` and `privacy`. The service keeps them independent on purpose, because the per-agreement switches exist for edge-case testing; enabling both reproduces the human path exactly, enabling one is a deliberate divergence.
|
||||
|
||||
Withdrawing the `terms` consent does not clear `users.terms_version`, so a tester who wants to exercise the gate withdraws their own `terms` consent and then has an administrator bump `terms_version` at `/admin/settings`. Every other account converges within one interval; the tester stays gated indefinitely.
|
||||
|
||||
## Rules for extending this
|
||||
|
||||
- A new consent kind: classify it in `AGREEMENTS` (the test forces this), and nothing else.
|
||||
- Never add a route, a schema, a template, a Devii tool or a `docs_api` entry. The surface is the generic services admin, exactly as for `NotificationRelayService` and `AuditService`.
|
||||
- Never add a second decline register, a second ordering, or an environment check.
|
||||
- Never write a persisted test that enables the service and asserts convergence: the suite is serial against one seeded database, and granting `terms` to every account would poison the moderation tests that assert the gate refuses. Convergence is covered by unit tests calling `converge_user` directly.
|
||||
@@ -0,0 +1 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
@@ -0,0 +1,37 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from devplacepy.database import CONSENT_KINDS
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Agreement:
|
||||
kind: str
|
||||
version_setting: str
|
||||
gate_column: str
|
||||
stamp_column: str
|
||||
|
||||
|
||||
AGREEMENTS: tuple[Agreement, ...] = (
|
||||
Agreement("terms", "terms_version", "terms_version", "terms_accepted_at"),
|
||||
Agreement("privacy", "privacy_version", "", ""),
|
||||
Agreement("ai_third_party", "", "", ""),
|
||||
Agreement("activity_recording", "", "", ""),
|
||||
Agreement("container_credentials", "", "", ""),
|
||||
)
|
||||
|
||||
|
||||
def setting_key(kind: str) -> str:
|
||||
return f"acceptance_grant_{kind}"
|
||||
|
||||
|
||||
def label_for(kind: str) -> str:
|
||||
return CONSENT_KINDS.get(kind, kind)
|
||||
|
||||
|
||||
def agreement_for(kind: str) -> Agreement | None:
|
||||
for agreement in AGREEMENTS:
|
||||
if agreement.kind == kind:
|
||||
return agreement
|
||||
return None
|
||||
@@ -0,0 +1,130 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from sqlalchemy import text
|
||||
|
||||
from devplacepy.database import CONSENTS_TABLE, _now_iso, db, get_table
|
||||
|
||||
from .agreements import Agreement
|
||||
from .pending import has_gate_column, not_withdrawn, satisfied_clause
|
||||
|
||||
CONSENT_COLUMNS = (
|
||||
"uid",
|
||||
"owner_kind",
|
||||
"owner_id",
|
||||
"kind",
|
||||
"version",
|
||||
"state",
|
||||
"granted_at",
|
||||
"withdrawn_at",
|
||||
"created_at",
|
||||
"deleted_at",
|
||||
"deleted_by",
|
||||
)
|
||||
|
||||
|
||||
def _gate_set_clause(agreement: Agreement) -> str:
|
||||
users = get_table("users")
|
||||
assignments = [f"{agreement.gate_column} = :version"]
|
||||
if agreement.stamp_column and users.has_column(agreement.stamp_column):
|
||||
assignments.append(f"{agreement.stamp_column} = :now")
|
||||
return ", ".join(assignments)
|
||||
|
||||
|
||||
def _insert_consent_sql(precondition: str) -> str:
|
||||
columns = ", ".join(CONSENT_COLUMNS)
|
||||
sql = (
|
||||
f"INSERT INTO {CONSENTS_TABLE} ({columns}) "
|
||||
"SELECT :row_uid, 'user', :uid, :kind, :version, 'granted', "
|
||||
":now, '', :now, NULL, NULL"
|
||||
)
|
||||
if precondition:
|
||||
return f"{sql} WHERE {precondition}"
|
||||
return sql
|
||||
|
||||
|
||||
def _consent_params(agreement: Agreement, uid: str, version: str, now: str) -> dict:
|
||||
from devplacepy.utils import generate_uid
|
||||
|
||||
return {
|
||||
"row_uid": generate_uid(),
|
||||
"uid": uid,
|
||||
"kind": agreement.kind,
|
||||
"version": version,
|
||||
"now": now,
|
||||
}
|
||||
|
||||
|
||||
def _claim_gate_column(agreement: Agreement, uid: str, version: str, now: str) -> bool:
|
||||
claim = (
|
||||
f"UPDATE users SET {_gate_set_clause(agreement)} "
|
||||
"WHERE uid = :uid "
|
||||
f"AND COALESCE({agreement.gate_column}, '') != :version "
|
||||
f"AND {not_withdrawn('users.uid')}"
|
||||
)
|
||||
params = _consent_params(agreement, uid, version, now)
|
||||
with db:
|
||||
claimed = db.executable.execute(text(claim), params).rowcount
|
||||
if claimed != 1:
|
||||
return False
|
||||
db.executable.execute(text(_insert_consent_sql("")), params)
|
||||
return True
|
||||
|
||||
|
||||
def _claim_ledger(agreement: Agreement, uid: str, version: str, now: str) -> bool:
|
||||
precondition = (
|
||||
f"{not_withdrawn(':uid')} AND {satisfied_clause(agreement, ':uid', '')}"
|
||||
)
|
||||
params = _consent_params(agreement, uid, version, now)
|
||||
with db:
|
||||
written = db.executable.execute(
|
||||
text(_insert_consent_sql(precondition)), params
|
||||
).rowcount
|
||||
return written == 1
|
||||
|
||||
|
||||
def _record(agreement: Agreement, user: dict, version: str) -> None:
|
||||
from devplacepy.services.audit import record as audit
|
||||
|
||||
uid = user["uid"]
|
||||
username = user.get("username") or ""
|
||||
links = [audit.target("user", uid, username)]
|
||||
if agreement.kind == "terms":
|
||||
audit.record_system(
|
||||
"terms.accept",
|
||||
actor_kind="service",
|
||||
actor_username="acceptance",
|
||||
target_type="user",
|
||||
target_uid=uid,
|
||||
target_label=username,
|
||||
new_value=version,
|
||||
summary=f"{username} accepted terms version {version}",
|
||||
links=links,
|
||||
)
|
||||
return
|
||||
audit.record_system(
|
||||
"consent.grant",
|
||||
actor_kind="service",
|
||||
actor_username="acceptance",
|
||||
target_type="user",
|
||||
target_uid=uid,
|
||||
target_label=username,
|
||||
new_value="granted",
|
||||
metadata={"kind": agreement.kind},
|
||||
summary=f"granted {agreement.kind} consent for {username}",
|
||||
links=links,
|
||||
)
|
||||
|
||||
|
||||
def converge_user(agreement: Agreement, user: dict, version: str) -> bool:
|
||||
uid = user.get("uid")
|
||||
if not uid:
|
||||
return False
|
||||
now = _now_iso()
|
||||
if has_gate_column(agreement):
|
||||
won = _claim_gate_column(agreement, uid, version, now)
|
||||
else:
|
||||
won = _claim_ledger(agreement, uid, version, now)
|
||||
if not won:
|
||||
return False
|
||||
_record(agreement, user, version)
|
||||
return True
|
||||
@@ -0,0 +1,78 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from devplacepy.database import CONSENTS_TABLE, db, get_setting, get_table
|
||||
|
||||
from .agreements import Agreement
|
||||
|
||||
DEFAULT_VERSION = "1"
|
||||
|
||||
LIVE_ACCOUNT_CLAUSES: tuple[tuple[str, str], ...] = (
|
||||
("deletion_requested_at", "COALESCE(u.deletion_requested_at, '') = ''"),
|
||||
("is_active", "COALESCE(u.is_active, 1) != 0"),
|
||||
("deleted_at", "u.deleted_at IS NULL"),
|
||||
)
|
||||
|
||||
|
||||
def latest_consent(column: str, user_ref: str) -> str:
|
||||
return (
|
||||
f"COALESCE((SELECT c.{column} FROM {CONSENTS_TABLE} c "
|
||||
f"WHERE c.owner_kind = 'user' AND c.owner_id = {user_ref} "
|
||||
"AND c.kind = :kind AND c.deleted_at IS NULL "
|
||||
"ORDER BY c.created_at DESC, c.id DESC LIMIT 1), '')"
|
||||
)
|
||||
|
||||
|
||||
def not_withdrawn(user_ref: str) -> str:
|
||||
return f"{latest_consent('state', user_ref)} != 'withdrawn'"
|
||||
|
||||
|
||||
def has_gate_column(agreement: Agreement) -> bool:
|
||||
if not agreement.gate_column:
|
||||
return False
|
||||
if "users" not in db.tables:
|
||||
return False
|
||||
return get_table("users").has_column(agreement.gate_column)
|
||||
|
||||
|
||||
def satisfied_clause(agreement: Agreement, user_ref: str, column_prefix: str) -> str:
|
||||
if has_gate_column(agreement):
|
||||
return f"COALESCE({column_prefix}{agreement.gate_column}, '') != :version"
|
||||
if agreement.version_setting:
|
||||
return f"{latest_consent('version', user_ref)} != :version"
|
||||
return f"{latest_consent('state', user_ref)} != 'granted'"
|
||||
|
||||
|
||||
def live_account_clauses() -> list[str]:
|
||||
users = get_table("users")
|
||||
return [
|
||||
clause for column, clause in LIVE_ACCOUNT_CLAUSES if users.has_column(column)
|
||||
]
|
||||
|
||||
|
||||
def current_version(agreement: Agreement) -> str:
|
||||
if not agreement.version_setting:
|
||||
return DEFAULT_VERSION
|
||||
return get_setting(agreement.version_setting, DEFAULT_VERSION) or DEFAULT_VERSION
|
||||
|
||||
|
||||
def pending(agreement: Agreement, limit: int) -> list[dict]:
|
||||
if "users" not in db.tables or CONSENTS_TABLE not in db.tables:
|
||||
return []
|
||||
bound = max(1, int(limit))
|
||||
clauses = [
|
||||
*live_account_clauses(),
|
||||
not_withdrawn("u.uid"),
|
||||
satisfied_clause(agreement, "u.uid", "u."),
|
||||
]
|
||||
sql = (
|
||||
"SELECT u.uid, u.username FROM users u "
|
||||
f"WHERE {' AND '.join(clauses)} "
|
||||
"ORDER BY u.id LIMIT :limit"
|
||||
)
|
||||
rows = db.query(
|
||||
sql,
|
||||
kind=agreement.kind,
|
||||
version=current_version(agreement),
|
||||
limit=bound,
|
||||
)
|
||||
return [dict(row) for row in rows]
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user