Compare commits
No commits in common. "master" and "typosaurus/157-add-badge-earned-info-to-user-profile-badges-api-response" have entirely different histories.
master
...
typosaurus
@ -30,7 +30,7 @@ All application code is under `devplacepy/`: `devplacepy/routers/`, `devplacepy/
|
||||
- **F. Verify your own work.** After each edit, re-read the changed region and re-check the consumers.
|
||||
|
||||
## Mode
|
||||
Default to **REPORT** mode: record findings, do NOT modify files. Apply **FIX** mode only when the invocation explicitly asks you to fix. In FIX mode: reconfirm each finding survives refutation, run the cross-reference impact check, apply a minimal idiomatic root-cause fix, re-read the change, then re-validate every file you touched with the per-language checks (`python -m py_compile` + `pyflakes` for Python, `node --check` for JS, brace balance for CSS, tag and `{% %}` balance for templates) and confirm `python -c "from devplacepy.main import app"` still imports clean. Never run the test suite; never perform any git write operation.
|
||||
Default to **REPORT** mode: record findings, do NOT modify files. Apply **FIX** mode only when the invocation explicitly asks you to fix. In FIX mode: reconfirm each finding survives refutation, run the cross-reference impact check, apply a minimal idiomatic root-cause fix, re-read the change, then run `hawk .` and confirm it passes. Never run the test suite; never perform any git write operation.
|
||||
|
||||
## Obey the rules you enforce
|
||||
No comments or docstrings in source you author; no em-dashes (use a hyphen); keep `retoor <retoor@molodetz.nl>` as the first line of any file you create.
|
||||
|
||||
@ -40,10 +40,10 @@ The already-established **choke points** (the public function is a thin wrapper
|
||||
- **C. Cross-reference before every change.** A funnel is called from many sites; deferring inside it changes ALL of them. Find every caller and confirm none depends on the side-effect's result synchronously. If even one does, do not defer the funnel.
|
||||
- **D. Zero degradation.** A fix must never reduce functionality, weaken a check, or change observable behavior beyond moving WHEN a side-effect runs. Deferral is best-effort and must NEVER raise into the caller. If the only fix would degrade or risk stale reads, record it unfixed with the safe path forward.
|
||||
- **E. Dig deep.** Pursue the root cause; gather more evidence rather than guessing or bailing.
|
||||
- **F. Verify your own work.** After each edit, re-read the changed region, re-check the callers, confirm `python -c "from devplacepy.main import app"` still imports clean, and re-validate every file you touched with the per-language checks (`python -m py_compile` + `pyflakes` for Python, `node --check` for JS, brace balance for CSS, tag and `{% %}` balance for templates).
|
||||
- **F. Verify your own work.** After each edit, re-read the changed region, re-check the callers, confirm `python -c "from devplacepy.main import app"` still imports clean, and run `hawk .`.
|
||||
|
||||
## Mode
|
||||
Default to **REPORT** mode: record findings, do NOT modify files. Apply **FIX** mode only when the invocation explicitly asks you to fix. In FIX mode: reconfirm each finding survives refutation, run the cross-reference impact check, apply a minimal idiomatic root-cause fix, re-read the change, confirm the app imports clean, then re-validate every file you touched with the per-language checks (`python -m py_compile` + `pyflakes` for Python, `node --check` for JS, brace balance for CSS, tag and `{% %}` balance for templates) and confirm they pass. **HARD GUARDRAIL: never run the test suite (no `make test`, no `pytest`); never perform any git write operation.** Validate by clean import + the per-language checks + an em-dash scan only.
|
||||
Default to **REPORT** mode: record findings, do NOT modify files. Apply **FIX** mode only when the invocation explicitly asks you to fix. In FIX mode: reconfirm each finding survives refutation, run the cross-reference impact check, apply a minimal idiomatic root-cause fix, re-read the change, confirm the app imports clean, then run `hawk .` and confirm it passes. **HARD GUARDRAIL: never run the test suite (no `make test`, no `pytest`); never perform any git write operation.** Validate by clean import + hawk + an em-dash scan only.
|
||||
|
||||
## Obey the rules you enforce
|
||||
No comments or docstrings in source you author; no em-dashes (use a hyphen); full typing on functions you add; keep `retoor <retoor@molodetz.nl>` as the first line of any source file you create.
|
||||
@ -78,4 +78,4 @@ FIX: move the side-effect into a thin public wrapper that `background.submit(_wo
|
||||
- **wrong-tool**: grep `background.submit(` for any argument that is an `async def`/coroutine function, and any external-client call (gitea/push/AI) deferred via the sync queue.
|
||||
|
||||
## Output
|
||||
Return a markdown report: a one-line summary, then one bullet per finding with severity (`error`/`warning`/`info`), `file:line`, the rule name (e.g. `missing-deferral`, `double-wrap`, `unsafe-deferral`, `bypass-funnel`, `wrong-tool`, `captured-request`, `broken-wiring`), the message, and (in fix mode) whether it was fixed. End with the verification you ran (clean import, the per-language checks, em-dash scan) and its result. Never claim the test suite was run.
|
||||
Return a markdown report: a one-line summary, then one bullet per finding with severity (`error`/`warning`/`info`), `file:line`, the rule name (e.g. `missing-deferral`, `double-wrap`, `unsafe-deferral`, `bypass-funnel`, `wrong-tool`, `captured-request`, `broken-wiring`), the message, and (in fix mode) whether it was fixed. End with the verification you ran (clean import, `hawk .`, em-dash scan) and its result. Never claim the test suite was run.
|
||||
|
||||
@ -30,7 +30,7 @@ All application code is under `devplacepy/`: `devplacepy/routers/`, `devplacepy/
|
||||
- **F. Verify your own work.** After each edit, re-read the changed region and re-check the consumers.
|
||||
|
||||
## Mode
|
||||
Default to **REPORT** mode: record findings, do NOT modify files. Apply **FIX** mode only when the invocation explicitly asks you to fix. In FIX mode: reconfirm each finding survives refutation, run the cross-reference impact check, apply a minimal idiomatic root-cause fix, re-read the change, then re-validate every file you touched with the per-language checks (`python -m py_compile` + `pyflakes` for Python, `node --check` for JS, brace balance for CSS, tag and `{% %}` balance for templates) and confirm `python -c "from devplacepy.main import app"` still imports clean. Never run the test suite; never perform any git write operation.
|
||||
Default to **REPORT** mode: record findings, do NOT modify files. Apply **FIX** mode only when the invocation explicitly asks you to fix. In FIX mode: reconfirm each finding survives refutation, run the cross-reference impact check, apply a minimal idiomatic root-cause fix, re-read the change, then run `hawk .` and confirm it passes. Never run the test suite; never perform any git write operation.
|
||||
|
||||
## Obey the rules you enforce
|
||||
No comments or docstrings in source you author; no em-dashes (use a hyphen); keep `retoor <retoor@molodetz.nl>` as the first line of any file you create.
|
||||
|
||||
@ -30,7 +30,7 @@ All application code is under `devplacepy/`: `devplacepy/routers/`, `devplacepy/
|
||||
- **F. Verify your own work.** After each edit, re-read the changed region and re-check the consumers.
|
||||
|
||||
## Mode
|
||||
Default to **REPORT** mode: record findings, do NOT modify files. Apply **FIX** mode only when the invocation explicitly asks you to fix. In FIX mode: reconfirm each finding survives refutation, run the cross-reference impact check, apply a minimal idiomatic root-cause fix, re-read the change, then re-validate every file you touched with the per-language checks (`python -m py_compile` + `pyflakes` for Python, `node --check` for JS, brace balance for CSS, tag and `{% %}` balance for templates) and confirm `python -c "from devplacepy.main import app"` still imports clean. Never run the test suite; never perform any git write operation.
|
||||
Default to **REPORT** mode: record findings, do NOT modify files. Apply **FIX** mode only when the invocation explicitly asks you to fix. In FIX mode: reconfirm each finding survives refutation, run the cross-reference impact check, apply a minimal idiomatic root-cause fix, re-read the change, then run `hawk .` and confirm it passes. Never run the test suite; never perform any git write operation.
|
||||
|
||||
## Obey the rules you enforce
|
||||
No comments or docstrings in source you author; no em-dashes (use a hyphen); keep `retoor <retoor@molodetz.nl>` as the first line of any file you create.
|
||||
|
||||
@ -30,7 +30,7 @@ All application code is under `devplacepy/`: `devplacepy/routers/`, `devplacepy/
|
||||
- **F. Verify your own work.** After each edit, re-read the changed region and re-check the consumers.
|
||||
|
||||
## Mode
|
||||
Default to **REPORT** mode: record findings, do NOT modify files. Apply **FIX** mode only when the invocation explicitly asks you to fix. In FIX mode: reconfirm each finding survives refutation, run the cross-reference impact check, apply a minimal idiomatic root-cause fix, re-read the change, then re-validate every file you touched with the per-language checks (`python -m py_compile` + `pyflakes` for Python, `node --check` for JS, brace balance for CSS, tag and `{% %}` balance for templates) and confirm `python -c "from devplacepy.main import app"` still imports clean. Never run the test suite; never perform any git write operation.
|
||||
Default to **REPORT** mode: record findings, do NOT modify files. Apply **FIX** mode only when the invocation explicitly asks you to fix. In FIX mode: reconfirm each finding survives refutation, run the cross-reference impact check, apply a minimal idiomatic root-cause fix, re-read the change, then run `hawk .` and confirm it passes. Never run the test suite; never perform any git write operation.
|
||||
|
||||
## Obey the rules you enforce
|
||||
No comments or docstrings in source you author; no em-dashes (use a hyphen); keep `retoor <retoor@molodetz.nl>` as the first line of any file you create.
|
||||
|
||||
@ -30,7 +30,7 @@ All application code is under `devplacepy/`: `devplacepy/routers/`, `devplacepy/
|
||||
- **F. Verify your own work.** After each edit, re-read the changed region and re-check the consumers.
|
||||
|
||||
## Mode
|
||||
Default to **REPORT** mode: record findings, do NOT modify files. Apply **FIX** mode only when the invocation explicitly asks you to fix. In FIX mode: reconfirm each finding survives refutation, run the cross-reference impact check, apply a minimal idiomatic root-cause fix, re-read the change, then re-validate every file you touched with the per-language checks (`python -m py_compile` + `pyflakes` for Python, `node --check` for JS, brace balance for CSS, tag and `{% %}` balance for templates) and confirm `python -c "from devplacepy.main import app"` still imports clean. Never run the test suite; never perform any git write operation.
|
||||
Default to **REPORT** mode: record findings, do NOT modify files. Apply **FIX** mode only when the invocation explicitly asks you to fix. In FIX mode: reconfirm each finding survives refutation, run the cross-reference impact check, apply a minimal idiomatic root-cause fix, re-read the change, then run `hawk .` and confirm it passes. Never run the test suite; never perform any git write operation.
|
||||
|
||||
## Obey the rules you enforce
|
||||
No comments or docstrings in source you author; no em-dashes (use a hyphen); keep `retoor <retoor@molodetz.nl>` as the first line of any file you create.
|
||||
|
||||
@ -56,11 +56,11 @@ When a layer is intentionally absent (an internal route with no public docs, a r
|
||||
No comments or docstrings in source you author; full typing on every signature and variable; `pathlib` over `os`; dataclasses over fixed-key dicts; no magic numbers; no version pinning; no em-dashes anywhere (use a hyphen) in any file you touch. Keep `retoor <retoor@molodetz.nl>` as the first line (correct comment style for the language) of any NEW source file you create - never of the existing files you edit, and never inside a `.md` with YAML frontmatter.
|
||||
|
||||
## Validation (after implementing; never skip)
|
||||
There is NO validator binary in this environment - validate each touched file directly, using the Python interpreter where `import devplacepy` resolves its dependencies (verify that first; the repo `.venv` may be incomplete). Then: confirm `python -c "from devplacepy.main import app"` imports clean; compile or parse every touched language (`python -m py_compile <files>` for Python, `node --check <file>` for JS, brace balance for CSS, tag and `{% %}`/`{{ }}` balance for templates); and grep every touched file for em-dashes - the character AND the entity forms `—`/`—`/`—` - confirming none. For any new `*Out` schema, `model_validate` it against a representative context dict so a key mismatch surfaces now, not at request time. Do NOT run the test suite. Then hand off: name which maintainer dimensions are most relevant to the change (e.g. fanout, security, dry, docs, seo, audit, frontend, style, test) so the fleet can verify it.
|
||||
There is NO `hawk` or validator binary in this environment - validate each touched file directly, using the Python interpreter where `import devplacepy` resolves its dependencies (verify that first; the repo `.venv` may be incomplete). Then: confirm `python -c "from devplacepy.main import app"` imports clean; compile or parse every touched language (`python -m py_compile <files>` for Python, `node --check <file>` for JS, brace balance for CSS, tag and `{% %}`/`{{ }}` balance for templates); and grep every touched file for em-dashes - the character AND the entity forms `—`/`—`/`—` - confirming none. For any new `*Out` schema, `model_validate` it against a representative context dict so a key mismatch surfaces now, not at request time. Do NOT run the test suite. Then hand off: name which maintainer dimensions are most relevant to the change (e.g. fanout, security, dry, docs, seo, audit, frontend, style, test) so the fleet can verify it.
|
||||
|
||||
## Live verification of UI/API changes (mandatory for visual work)
|
||||
A structurally valid template can still render broken - the static checks and the import check never open a browser. Per CLAUDE.md this project treats live verification as non-negotiable for any layout, styling, component, responsive, or backend change:
|
||||
- Do not assume any verification CLI is installed (`mole`, `falcon`, `hound` are NOT present here); check with `command -v` first and fall back to the steps below or the project's `screenshot`/`serve`/`validate` skills when they exist.
|
||||
A structurally valid template can still render broken - `hawk` and the import check never open a browser. Per CLAUDE.md this project treats live verification as non-negotiable for any layout, styling, component, responsive, or backend change:
|
||||
- Do not assume any verification CLI is installed (`hawk`, `mole`, `falcon`, `hound` are NOT present here); check with `command -v` first and fall back to the steps below or the project's `screenshot`/`serve`/`validate` skills when they exist.
|
||||
- When your change touches `templates/` or `static/`, the rendered result MUST be visually verified: start the dev server (`make dev` in the background; confirm it is healthy on `http://localhost:10500`), capture each new/changed route with headless Playwright (`wait_until="domcontentloaded"`), and inspect the screenshot against the intended UI and the surrounding design system (tokens, spacing, responsiveness). Tear down any server you started.
|
||||
- When your change touches `routers/`, verify the endpoints over HTTP against the live server (an api-spec runner if available, otherwise `curl`/`httpx` asserting the status and a body fragment).
|
||||
- The `/feature` workflow performs this live `Verify` phase for you automatically; when you are invoked standalone for UI/API work, perform it yourself before declaring the work complete, or explicitly state it is the caller's responsibility and name the routes to check.
|
||||
|
||||
@ -30,7 +30,7 @@ All application code is under `devplacepy/`: `devplacepy/routers/`, `devplacepy/
|
||||
- **F. Verify your own work.** After each edit, re-read the changed region and re-check the consumers.
|
||||
|
||||
## Mode
|
||||
Default to **REPORT** mode: record findings, do NOT modify files. Apply **FIX** mode only when the invocation explicitly asks you to fix. In FIX mode: reconfirm each finding survives refutation, run the cross-reference impact check, apply a minimal idiomatic root-cause fix, re-read the change, then re-validate every file you touched with the per-language checks (`python -m py_compile` + `pyflakes` for Python, `node --check` for JS, brace balance for CSS, tag and `{% %}` balance for templates) and confirm `python -c "from devplacepy.main import app"` still imports clean. Visual judgement is out of scope for auto-fix and is recorded as a finding. Never run the test suite; never perform any git write operation.
|
||||
Default to **REPORT** mode: record findings, do NOT modify files. Apply **FIX** mode only when the invocation explicitly asks you to fix. In FIX mode: reconfirm each finding survives refutation, run the cross-reference impact check, apply a minimal idiomatic root-cause fix, re-read the change, then run `hawk .` and confirm it passes. Visual judgement is out of scope for auto-fix and is recorded as a finding. Never run the test suite; never perform any git write operation.
|
||||
|
||||
## Obey the rules you enforce
|
||||
No comments or docstrings in source you author; no em-dashes (use a hyphen); keep `retoor <retoor@molodetz.nl>` as the first line of any file you create.
|
||||
|
||||
@ -30,7 +30,7 @@ All application code is under `devplacepy/`: `devplacepy/routers/`, `devplacepy/
|
||||
- **F. Verify your own work.** After each edit, re-read the changed region and re-check the consumers.
|
||||
|
||||
## Mode
|
||||
Default to **REPORT** mode: record findings, do NOT modify files. Apply **FIX** mode only when the invocation explicitly asks you to fix. In FIX mode: reconfirm each finding survives refutation, run the cross-reference impact check, apply a minimal idiomatic root-cause fix, re-read the change, then re-validate every file you touched with the per-language checks (`python -m py_compile` + `pyflakes` for Python, `node --check` for JS, brace balance for CSS, tag and `{% %}` balance for templates) and confirm `python -c "from devplacepy.main import app"` still imports clean. Never run the test suite; never perform any git write operation.
|
||||
Default to **REPORT** mode: record findings, do NOT modify files. Apply **FIX** mode only when the invocation explicitly asks you to fix. In FIX mode: reconfirm each finding survives refutation, run the cross-reference impact check, apply a minimal idiomatic root-cause fix, re-read the change, then run `hawk .` and confirm it passes. Never run the test suite; never perform any git write operation.
|
||||
|
||||
## Obey the rules you enforce
|
||||
No comments or docstrings in source you author; no em-dashes (use a hyphen); keep `retoor <retoor@molodetz.nl>` as the first line of any file you create.
|
||||
|
||||
@ -30,7 +30,7 @@ All application code is under `devplacepy/`: `devplacepy/routers/`, `devplacepy/
|
||||
- **F. Verify your own work.** After each edit, re-read the changed region and re-check the consumers.
|
||||
|
||||
## Mode
|
||||
Default to **REPORT** mode: record findings, do NOT modify files. Apply **FIX** mode only when the invocation explicitly asks you to fix. In FIX mode: reconfirm each finding survives refutation, run the cross-reference impact check, apply a minimal idiomatic root-cause fix, re-read the change, then re-validate every file you touched with the per-language checks (`python -m py_compile` + `pyflakes` for Python, `node --check` for JS, brace balance for CSS, tag and `{% %}` balance for templates) and confirm `python -c "from devplacepy.main import app"` still imports clean. Never run the test suite; never perform any git write operation.
|
||||
Default to **REPORT** mode: record findings, do NOT modify files. Apply **FIX** mode only when the invocation explicitly asks you to fix. In FIX mode: reconfirm each finding survives refutation, run the cross-reference impact check, apply a minimal idiomatic root-cause fix, re-read the change, then run `hawk .` and confirm it passes. Never run the test suite; never perform any git write operation.
|
||||
|
||||
## Obey the rules you enforce
|
||||
No comments or docstrings in source you author; no em-dashes (use a hyphen); keep `retoor <retoor@molodetz.nl>` as the first line of any file you create.
|
||||
|
||||
@ -30,7 +30,7 @@ All application code is under `devplacepy/`: `devplacepy/routers/`, `devplacepy/
|
||||
- **F. Verify your own work.** After each edit, re-read the changed region and re-check the consumers.
|
||||
|
||||
## Mode
|
||||
Default to **REPORT** mode: record findings, do NOT modify files. Apply **FIX** mode only when the invocation explicitly asks you to fix. In FIX mode: reconfirm each finding survives refutation, run the cross-reference impact check, apply a minimal idiomatic root-cause fix, re-read the change, then re-validate every file you touched with the per-language checks (`python -m py_compile` + `pyflakes` for Python, `node --check` for JS, brace balance for CSS, tag and `{% %}` balance for templates) and confirm `python -c "from devplacepy.main import app"` still imports clean. A rename is auto-applied ONLY for a confirmed local/private name that passed the decision algorithm AND only after you grep and update every reference in the same run. Never run the test suite; never perform any git write operation.
|
||||
Default to **REPORT** mode: record findings, do NOT modify files. Apply **FIX** mode only when the invocation explicitly asks you to fix. In FIX mode: reconfirm each finding survives refutation, run the cross-reference impact check, apply a minimal idiomatic root-cause fix, re-read the change, then run `hawk .` and confirm it passes. A rename is auto-applied ONLY for a confirmed local/private name that passed the decision algorithm AND only after you grep and update every reference in the same run. Never run the test suite; never perform any git write operation.
|
||||
|
||||
## Obey the rules you enforce
|
||||
No comments or docstrings in source you author; no em-dashes (use a hyphen); keep `retoor <retoor@molodetz.nl>` as the first line of any file you create.
|
||||
|
||||
@ -12,4 +12,4 @@ Follow the audit-log design (`devplacepy/services/audit/`); confirm against the
|
||||
3. Call the recorder on the mutation's success path: `audit.record(request, event_key, ...)` in HTTP or WebSocket handlers, or `audit.record_system(event_key, ...)` in request-less contexts (services, jobs, CLI). On a guard or denial branch pass `result="denied"`; on a failure branch pass `result="failure"`.
|
||||
4. Route through the existing DRY choke point when one applies (`content.py`, the `project_files.py` helpers, `routers/containers.py` `_audit_instance`, the Devii dispatcher `_audit_mechanic`) instead of scattering call sites. The HTTP path and the Devii path for one mutation must stay disjoint (no double counting).
|
||||
5. Recording is best-effort: wrap nothing the caller depends on, and NEVER gate the audited action on the record succeeding.
|
||||
6. Validate the touched files with the per-language checks (`python -m py_compile` + `pyflakes` for Python, `node --check` for JS, brace balance for CSS, tag and `{% %}` balance for templates) and `python -c "from devplacepy.main import app"`.
|
||||
6. Validate with `hawk` on the touched files and `python -c "from devplacepy.main import app"`.
|
||||
|
||||
@ -10,4 +10,4 @@ Follow the docs convention exactly (confirm against `devplacepy/routers/docs/pag
|
||||
1. Create `devplacepy/templates/docs/<slug>.html` as a prose page: one `<div class="docs-content" data-render> ... </div>` containing GitHub-flavored markdown. The page is rendered server-side. Any example component markup INSIDE the data-render block must be HTML-escaped (`<dp-...>`); a live demo, if any, goes in a SEPARATE block OUTSIDE the data-render div with its own `<script type="module">`.
|
||||
2. Register it in `DOCS_PAGES` in `devplacepy/routers/docs/pages.py`: `{"slug": "<slug>", "title": "<title>", "kind": "prose", "section": SECTION_*}`. Add `"admin": True` for an admin-only page. If a new section is needed, add a `SECTION_*` constant and place it in the correct `AUDIENCES` group.
|
||||
3. Write accurate, professional content - confirm every factual claim against the source. No em-dashes, no AI disclaimers, dates as DD/MM/YYYY.
|
||||
4. Validate: check the new template for tag and `{% %}` balance and `pages.py` with `python -m py_compile` + `pyflakes`, run `python -c "from devplacepy.main import app"`, and confirm the slug is registered with no duplicate.
|
||||
4. Validate: run `hawk` on the new template and on `pages.py`, run `python -c "from devplacepy.main import app"`, and confirm the slug is registered with no duplicate.
|
||||
|
||||
@ -33,7 +33,7 @@ Arguments: `$ARGUMENTS`
|
||||
## Execute
|
||||
1. Resolve the dimension list and mode from the arguments above.
|
||||
2. **CHECK mode**: launch every selected subagent concurrently (one `Agent` call per dimension in a single message). Each subagent runs read-only and returns its findings report. Tell each subagent explicitly: "Operate in REPORT mode. Do not modify any file." If `changed`, append the file list and: "Restrict findings to these files."
|
||||
3. **FIX mode**: launch the selected subagents **one at a time in canonical order** (never in parallel - parallel edits to the same file would conflict). Tell each: "Operate in FIX mode: apply minimal root-cause fixes per your doctrine, then re-validate every file you touched with the per-language checks and confirm `python -c \"from devplacepy.main import app\"` still imports clean." Wait for each to finish before starting the next. If `changed`, append the file list and: "Restrict fixes to these files."
|
||||
3. **FIX mode**: launch the selected subagents **one at a time in canonical order** (never in parallel - parallel edits to the same file would conflict). Tell each: "Operate in FIX mode: apply minimal root-cause fixes per your doctrine, then run `hawk .` and confirm it passes." Wait for each to finish before starting the next. If `changed`, append the file list and: "Restrict fixes to these files."
|
||||
4. Each subagent's final message is its report; it is not shown to the user directly, so collect them.
|
||||
|
||||
## Report
|
||||
|
||||
@ -13,4 +13,4 @@ Mirror an existing service - read `devplacepy/services/base.py` (BaseService) an
|
||||
4. If it calls an LLM, default its endpoint to `config.INTERNAL_GATEWAY_URL` and authenticate with the internal gateway key, like the other AI consumers.
|
||||
5. Emit audit events via `record_system` for any state change it makes.
|
||||
6. Document it in `devplacepy/services/CLAUDE.md` (Background services base machinery section, or the service's own nested `CLAUDE.md` if it has one) and in `README.md` if user-visible.
|
||||
7. Validate the touched files with the per-language checks (`python -m py_compile` + `pyflakes` for Python, `node --check` for JS, brace balance for CSS, tag and `{% %}` balance for templates) and `python -c "from devplacepy.main import app"`.
|
||||
7. Validate with `hawk` on the touched files and `python -c "from devplacepy.main import app"`.
|
||||
|
||||
@ -1,13 +1,13 @@
|
||||
---
|
||||
description: Run the mandatory DevPlace pre-completion verification on changed files - the per-language checks, the app import, and an em-dash scan. Zero errors required. Never runs the test suite.
|
||||
allowed-tools: Bash(python *), Bash(node *), Bash(git status:*), Bash(git diff:*), Read, Grep
|
||||
description: Run the mandatory DevPlace pre-completion verification on changed files - the validator, the app import, and an em-dash scan. Zero errors required. Never runs the test suite.
|
||||
allowed-tools: Bash(python *), Bash(hawk *), Bash(git status:*), Bash(git diff:*), Read, Grep
|
||||
---
|
||||
Changed files in the working tree:
|
||||
!`git status --porcelain`
|
||||
|
||||
Verify the work is complete and correct, following the DevPlace verification rule (zero tolerance):
|
||||
|
||||
1. For each changed or new file under `devplacepy/` or `tests/`, run the per-language checks (`python -m py_compile` + `pyflakes` for Python, `node --check` for JS, brace balance for CSS, tag and `{% %}` balance for templates). Every file must come back clean.
|
||||
1. For each changed or new file under `devplacepy/` or `tests/`, run `hawk <file>` (it covers Python, JavaScript, CSS, and HTML/Jinja). Every file must report clean.
|
||||
2. Run `python -c "from devplacepy.main import app"` - it must import with no error.
|
||||
3. Grep the changed files for em-dash characters (U+2014 and U+2013) that are authored prose, and report any. Leave em-dashes that are data (replace/maketrans/regex targets, fixtures) untouched.
|
||||
4. Report a PASS or FAIL summary with the exact failures.
|
||||
|
||||
20
.claude/settings.local.json
Normal file
20
.claude/settings.local.json
Normal file
@ -0,0 +1,20 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(python *)",
|
||||
"Bash(DEVPLACE_DISABLE_SERVICES=1 python -)",
|
||||
"Bash(command -v hawk)",
|
||||
"Bash(export DEVPLACE_DISABLE_SERVICES=1)",
|
||||
"Bash(export DEVPLACE_DATABASE_URL=\"sqlite:///tmp/devplace_verify.db\")",
|
||||
"Bash(rm -f /tmp/devplace_verify.db)",
|
||||
"Bash(export DEVPLACE_DATABASE_URL=\"sqlite:////tmp/devplace_verify.db\")",
|
||||
"Bash",
|
||||
"Edit(/home/retoor/projects/devplacepy/devplacepy/routers/projects/containers/instances.py)",
|
||||
"Edit(/home/retoor/projects/devplacepy/devplacepy/static/js/components/ContainerTerminal.js)",
|
||||
"Edit(/home/retoor/projects/devplacepy/devplacepy/services/containers/store.py)",
|
||||
"Verify",
|
||||
"Edit(/home/retoor/projects/devplacepy/devplacepy/static/js/MessagesLayout.js)",
|
||||
"Write(/home/retoor/projects/devplacepy/devplacepy/static/css/messages.css)"
|
||||
]
|
||||
}
|
||||
}
|
||||
@ -18,7 +18,7 @@ const RULES = [
|
||||
'- A Devii Action requires_auth/requires_admin MUST exactly match the underlying route guard. Never grant a member an admin capability. A non-admin must not even see an admin tool schema.',
|
||||
'- If the action is irreversible or destructive, add it to dispatcher CONFIRM_REQUIRED and declare a confirm boolean param in its spec (schemas set additionalProperties:false, so a gated tool without a declared confirm param can never receive confirm=true and loops forever).',
|
||||
'- Prefer handler="http" reusing an existing REST route; only add a local controller handler when there is no route. Reuse the arg()/body()/query()/confirm() helpers for params.',
|
||||
'- Validate with the per-language checks (py_compile + pyflakes for Python, node --check for JS, brace balance for CSS, tag and {% %} balance for templates) and "python -c \\"from devplacepy.main import app\\"". NEVER run the test suite. Never perform any git write.',
|
||||
'- Validate with "hawk ." and "python -c \\"from devplacepy.main import app\\"". NEVER run the test suite. Never perform any git write.',
|
||||
].join('\n')
|
||||
|
||||
const TESTS = [
|
||||
@ -100,7 +100,7 @@ const map = await agent(
|
||||
)
|
||||
|
||||
const build = await agent(
|
||||
`Add this Devii tool, editing files directly in the repo. Add the Action to the catalog mirroring the similar action, set requires_auth/requires_admin to exactly match the underlying route guard, wire the dispatcher handler if a new local handler is needed, and add a docs_api.py entry if it wraps an HTTP endpoint. If destructive, add it to CONFIRM_REQUIRED and declare a confirm param. Then run the per-language checks (py_compile + pyflakes for Python, node --check for JS, brace balance for CSS, tag and {% %} balance for templates) and "python -c \\"from devplacepy.main import app\\"". Do not write the test here. Do not run the suite. Do not commit.\n\nTool request: ${ask}\n\nContext:\n${JSON.stringify(map, null, 2)}\n\n${RULES}\n\nReturn the action name, files changed, and whether the checks and the import passed.`,
|
||||
`Add this Devii tool, editing files directly in the repo. Add the Action to the catalog mirroring the similar action, set requires_auth/requires_admin to exactly match the underlying route guard, wire the dispatcher handler if a new local handler is needed, and add a docs_api.py entry if it wraps an HTTP endpoint. If destructive, add it to CONFIRM_REQUIRED and declare a confirm param. Then run "hawk ." and "python -c \\"from devplacepy.main import app\\"". Do not write the test here. Do not run the suite. Do not commit.\n\nTool request: ${ask}\n\nContext:\n${JSON.stringify(map, null, 2)}\n\n${RULES}\n\nReturn the action name, files changed, and whether validator and import passed.`,
|
||||
{ label: 'implement', phase: 'Implement', schema: BUILD_SCHEMA }
|
||||
)
|
||||
|
||||
@ -127,7 +127,7 @@ const gaps = audits
|
||||
let gapFix = 'no actionable gaps'
|
||||
if (gaps.length) {
|
||||
gapFix = await agent(
|
||||
`Close these Devii tool gaps with minimal root-cause fixes in the repo, then re-run the per-language checks. Do not run the suite. Do not commit.\n\nGaps:\n${JSON.stringify(gaps, null, 2)}\n\n${RULES}`,
|
||||
`Close these Devii tool gaps with minimal root-cause fixes in the repo, then re-run "hawk .". Do not run the suite. Do not commit.\n\nGaps:\n${JSON.stringify(gaps, null, 2)}\n\n${RULES}`,
|
||||
{ label: 'fix-gaps', phase: 'Fix' }
|
||||
)
|
||||
}
|
||||
|
||||
@ -17,7 +17,7 @@ const RULES = [
|
||||
'- No em-dash characters; use a hyphen. Full Python type hints; pathlib over os; Pydantic Form input with explicit max lengths; sanitize and bound user input.',
|
||||
'- Reuse templating.templates, database.py batch helpers, respond(), the shared partials and frontend utilities. Never per-router Jinja2Templates.',
|
||||
'- Guards: get_current_user (public read), require_user (member write), require_admin (admin). Every POST/PUT/DELETE is guarded. Declare specific routes before catch-alls. Never pass a respond() context key that collides with a Jinja global (use viewer_is_admin).',
|
||||
'- Validate with the per-language checks (py_compile + pyflakes for Python, node --check for JS, brace balance for CSS, tag and {% %} balance for templates) and "python -c \\"from devplacepy.main import app\\"". NEVER run the test suite. Never perform any git write.',
|
||||
'- Validate with "hawk ." and "python -c \\"from devplacepy.main import app\\"". NEVER run the test suite. Never perform any git write.',
|
||||
].join('\n')
|
||||
|
||||
const TOUCHPOINTS = [
|
||||
@ -108,7 +108,7 @@ const map = await agent(
|
||||
)
|
||||
|
||||
const build = await agent(
|
||||
`Implement this single DevPlace route across every applicable touchpoint, editing files directly in the repo, mirroring the closest existing route. Keep the layers in agreement (Out schema carries every returned JSON key; Devii action auth flags match the guard). Then run the per-language checks (py_compile + pyflakes for Python, node --check for JS, brace balance for CSS, tag and {% %} balance for templates) and "python -c \\"from devplacepy.main import app\\"". Do not write the test here. Do not run the suite. Do not commit.\n\nEndpoint: ${ask}\n\nClosest route to mirror:\n${JSON.stringify(map, null, 2)}\n\n${TOUCHPOINTS}\n\n${RULES}\n\nReturn the files changed and whether the checks and the import passed.`,
|
||||
`Implement this single DevPlace route across every applicable touchpoint, editing files directly in the repo, mirroring the closest existing route. Keep the layers in agreement (Out schema carries every returned JSON key; Devii action auth flags match the guard). Then run "hawk ." and "python -c \\"from devplacepy.main import app\\"". Do not write the test here. Do not run the suite. Do not commit.\n\nEndpoint: ${ask}\n\nClosest route to mirror:\n${JSON.stringify(map, null, 2)}\n\n${TOUCHPOINTS}\n\n${RULES}\n\nReturn the files changed and whether validator and import passed.`,
|
||||
{ label: 'implement', phase: 'Implement', schema: BUILD_SCHEMA }
|
||||
)
|
||||
|
||||
@ -135,7 +135,7 @@ const gaps = audits
|
||||
let gapFix = 'no actionable gaps'
|
||||
if (gaps.length) {
|
||||
gapFix = await agent(
|
||||
`Close these gaps on the new route with minimal root-cause fixes in the repo, then re-run the per-language checks. Do not run the suite. Do not commit.\n\nGaps:\n${JSON.stringify(gaps, null, 2)}\n\n${RULES}`,
|
||||
`Close these gaps on the new route with minimal root-cause fixes in the repo, then re-run "hawk .". Do not run the suite. Do not commit.\n\nGaps:\n${JSON.stringify(gaps, null, 2)}\n\n${RULES}`,
|
||||
{ label: 'fix-gaps', phase: 'Fix' }
|
||||
)
|
||||
}
|
||||
|
||||
@ -22,7 +22,7 @@ const RULES = [
|
||||
'- Reuse shared helpers: templating.templates (never a per-router Jinja2Templates), database.py batch helpers (no inline N+1), the respond() negotiator, _avatar_link.html / _user_link.html, and on the frontend Http / Poller / JobPoller / OptimisticAction / FloatingWindow and the dp-* components.',
|
||||
'- Auth guards: get_current_user (public read), require_user (member write), require_admin (admin). Every POST/PUT/DELETE is guarded; deletes are soft and owner-or-admin.',
|
||||
'- Never pass a respond() context key that collides with a Jinja global (use viewer_is_admin, not is_admin). Dates are DD/MM/YYYY via format_date.',
|
||||
'- Validate with the per-language checks (py_compile + pyflakes for Python, node --check for JS, brace balance for CSS, tag and {% %} balance for templates) and "python -c \\"from devplacepy.main import app\\"". NEVER run the pytest suite. Never perform any git write.',
|
||||
'- Validate with "hawk ." and "python -c \\"from devplacepy.main import app\\"". NEVER run the pytest suite. Never perform any git write.',
|
||||
].join('\n')
|
||||
|
||||
const FANOUT = [
|
||||
@ -185,7 +185,7 @@ const plan = await agent(
|
||||
)
|
||||
|
||||
const build = await agent(
|
||||
`Implement directly - no plan, no approval needed, this is implement mode. Build this DevPlace feature coherently and completely, editing files in the repo, following the plan. Keep every layer in agreement (the *Out schema must carry every JSON key the handler returns; the Devii action auth flags must match the route guard; a respond() context key must never shadow a Jinja global). Do NOT write pytest tests in this step (a later phase owns that). When done, run the per-language checks (py_compile + pyflakes for Python, node --check for JS, brace balance for CSS, tag and {% %} balance for templates) and "python -c \\"from devplacepy.main import app\\"" and report whether each passed, and list the user-facing routes the feature exposes.\n\nFeature request: ${ask}\n\nPlan:\n${JSON.stringify(plan, null, 2)}\n\n${FANOUT}\n\n${RULES}\n\nReturn the list of files you changed or created, whether the checks and the import passed, the routes, and a short summary.`,
|
||||
`Implement directly - no plan, no approval needed, this is implement mode. Build this DevPlace feature coherently and completely, editing files in the repo, following the plan. Keep every layer in agreement (the *Out schema must carry every JSON key the handler returns; the Devii action auth flags must match the route guard; a respond() context key must never shadow a Jinja global). Do NOT write pytest tests in this step (a later phase owns that). When done, run "hawk ." and "python -c \\"from devplacepy.main import app\\"" and report whether each passed, and list the user-facing routes the feature exposes.\n\nFeature request: ${ask}\n\nPlan:\n${JSON.stringify(plan, null, 2)}\n\n${FANOUT}\n\n${RULES}\n\nReturn the list of files you changed or created, whether the validator and the import passed, the routes, and a short summary.`,
|
||||
{ agentType: 'feature-builder', label: 'implement', phase: 'Implement', schema: BUILD_SCHEMA }
|
||||
)
|
||||
|
||||
@ -279,7 +279,7 @@ for (const i of (live && live.issues) || []) {
|
||||
let gapFix = 'no actionable gaps from the audit or live verification'
|
||||
if (gaps.length) {
|
||||
gapFix = await agent(
|
||||
`Close these confirmed completeness, security, style, frontend, and live-rendering gaps found in the new feature. Apply minimal root-cause fixes directly in the repo, keeping all layers in agreement and the styling consistent with the design system. Re-run the per-language checks afterward. Do not run the pytest suite. Do not commit.\n\nGaps:\n${JSON.stringify(gaps, null, 2)}\n\n${RULES}`,
|
||||
`Close these confirmed completeness, security, style, frontend, and live-rendering gaps found in the new feature. Apply minimal root-cause fixes directly in the repo, keeping all layers in agreement and the styling consistent with the design system. Re-run "hawk ." afterward. Do not run the pytest suite. Do not commit.\n\nGaps:\n${JSON.stringify(gaps, null, 2)}\n\n${RULES}`,
|
||||
{ agentType: 'feature-builder', label: 'fix-gaps', phase: 'Fix' }
|
||||
)
|
||||
}
|
||||
|
||||
@ -19,7 +19,7 @@ const RULES = [
|
||||
'- Runtime artifacts live in config.DATA_DIR (the var/ dir), OUTSIDE the devplacepy package and NOT under /static. Heavy compression or blocking work runs in a subprocess. SQLite stays synchronous.',
|
||||
'- Enqueue endpoints own authz (require_user plus any resource guard); status and download are capability URLs scoped only by the unguessable uuid7. Soft-delete the job tracking rows; permanent artifacts are not deleted by cleanup().',
|
||||
'- Record audit events with record_system in the service. Frontend status polling uses JobPoller, never a bespoke loop.',
|
||||
'- Validate with the per-language checks (py_compile + pyflakes for Python, node --check for JS, brace balance for CSS, tag and {% %} balance for templates) and "python -c \\"from devplacepy.main import app\\"". NEVER run the test suite. Never perform any git write.',
|
||||
'- Validate with "hawk ." and "python -c \\"from devplacepy.main import app\\"". NEVER run the test suite. Never perform any git write.',
|
||||
].join('\n')
|
||||
|
||||
const CHECKLIST = [
|
||||
@ -133,7 +133,7 @@ const plan = await agent(
|
||||
)
|
||||
|
||||
const build = await agent(
|
||||
`Implement this new async job kind coherently, editing files directly in the repo, mirroring ZipService/ForkService and following the plan. Keep the *JobOut schema, routes, Devii tools, and docs in agreement. Then run the per-language checks (py_compile + pyflakes for Python, node --check for JS, brace balance for CSS, tag and {% %} balance for templates) and "python -c \\"from devplacepy.main import app\\"". Do not write tests here. Do not run the suite. Do not commit.\n\nJob request: ${ask}\n\nPlan:\n${JSON.stringify(plan, null, 2)}\n\n${CHECKLIST}\n\n${RULES}\n\nReturn the job kind, files changed, and whether the checks and the import passed.`,
|
||||
`Implement this new async job kind coherently, editing files directly in the repo, mirroring ZipService/ForkService and following the plan. Keep the *JobOut schema, routes, Devii tools, and docs in agreement. Then run "hawk ." and "python -c \\"from devplacepy.main import app\\"". Do not write tests here. Do not run the suite. Do not commit.\n\nJob request: ${ask}\n\nPlan:\n${JSON.stringify(plan, null, 2)}\n\n${CHECKLIST}\n\n${RULES}\n\nReturn the job kind, files changed, and whether validator and import passed.`,
|
||||
{ label: 'implement', phase: 'Implement', schema: BUILD_SCHEMA }
|
||||
)
|
||||
|
||||
@ -162,7 +162,7 @@ const gaps = audits
|
||||
let gapFix = 'no actionable gaps'
|
||||
if (gaps.length) {
|
||||
gapFix = await agent(
|
||||
`Close these job-service gaps with minimal root-cause fixes in the repo, then re-run the per-language checks. Do not run the suite. Do not commit.\n\nGaps:\n${JSON.stringify(gaps, null, 2)}\n\n${RULES}`,
|
||||
`Close these job-service gaps with minimal root-cause fixes in the repo, then re-run "hawk .". Do not run the suite. Do not commit.\n\nGaps:\n${JSON.stringify(gaps, null, 2)}\n\n${RULES}`,
|
||||
{ label: 'fix-gaps', phase: 'Fix' }
|
||||
)
|
||||
}
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@ -14,10 +14,7 @@ notification-private.pem
|
||||
notification-private.pkcs8.pem
|
||||
notification-public.pem
|
||||
.pytest_cache/
|
||||
.ruff_cache/
|
||||
.opencode
|
||||
.dpc/
|
||||
.claude/settings.local.json
|
||||
devii_*.db
|
||||
devii_*.db-shm
|
||||
devii_*.db-wal
|
||||
|
||||
636
ARCHITECTURE.md
636
ARCHITECTURE.md
@ -1,636 +0,0 @@
|
||||
# DevPlace architecture atlas
|
||||
|
||||
Author: retoor <retoor@molodetz.nl>
|
||||
|
||||
Every diagram below was derived from the source tree, the live SQLite schema and the imported FastAPI app object, not from the documentation. Where the repository documentation and the running code disagree, the disagreement is recorded in the last section.
|
||||
|
||||
| Measure | Value |
|
||||
|---|---|
|
||||
| python files | 644 |
|
||||
| routers | 38 |
|
||||
| http paths | 348 |
|
||||
| websockets | 12 |
|
||||
| services | 27 |
|
||||
| db tables | 102 |
|
||||
| agent tools | 318 |
|
||||
| test functions | 3073 |
|
||||
|
||||
## 01 Deployment topology
|
||||
|
||||
Source: `docker-compose.yml / Dockerfile / nginx/nginx.conf.template`
|
||||
|
||||
Two containers on one bridge network, on a single host. nginx is the only published listener and binds to loopback only. The app container bind-mounts the repository root, so the production process reads the same SQLite file and the same `data/` tree as `make dev`.
|
||||
|
||||
```mermaid
|
||||
%%{init:{"theme":"base","themeVariables":{"background":"#F4F8F7","primaryColor":"#E4EEED","primaryTextColor":"#0F1A1B","primaryBorderColor":"#0A6D6A","secondaryColor":"#EDE6D9","tertiaryColor":"#F4F8F7","lineColor":"#6B7C7C","textColor":"#0F1A1B","fontFamily":"ui-monospace,SFMono-Regular,Menlo,monospace","fontSize":"13px","clusterBkg":"#FAFCFC","clusterBorder":"#C6D4D3"}}}%%
|
||||
flowchart TB
|
||||
client["Browser / API client / Telegram / devRant client"]
|
||||
subgraph host["Single host"]
|
||||
subgraph appnet["docker network: appnet"]
|
||||
nginx["nginx container<br/>127.0.0.1:PORT to :80<br/>8 websocket upgrade locations"]
|
||||
app["app container<br/>uvicorn devplacepy.main:app<br/>--workers 2 --backlog 8192"]
|
||||
end
|
||||
repo["repository root<br/>bind mount .:/app"]
|
||||
static["devplacepy/static<br/>read only mount"]
|
||||
data["DEVPLACE_DATA_DIR = data/<br/>23 registered paths"]
|
||||
sqlite[("data/devplace.db<br/>SQLite, WAL, 256MB mmap")]
|
||||
lock["data/locks/devplace-services.lock<br/>flock, elects one service owner"]
|
||||
docker["host Docker daemon<br/>ppy:latest instances"]
|
||||
end
|
||||
client --> nginx
|
||||
nginx -->|"proxy_pass, X-Real-IP"| app
|
||||
nginx -->|"/static/, /static/uploads/"| static
|
||||
app --> repo
|
||||
app --> data
|
||||
data --> sqlite
|
||||
app --> lock
|
||||
app -->|"docker CLI backend"| docker
|
||||
```
|
||||
|
||||
nginx depends_on app with condition service_healthy. App healthcheck: interval 30s, timeout 10s, retries 3, start_period 120s, start_interval 2s.
|
||||
|
||||
## 02 Worker boot and service election
|
||||
|
||||
Source: `devplacepy/main.py lifespan`
|
||||
|
||||
Every uvicorn worker runs the full lifespan. Schema work is serialized behind an exclusive lock so workers pay it one after another; background services are elected, so exactly one worker in the host owns them.
|
||||
|
||||
```mermaid
|
||||
%%{init:{"theme":"base","themeVariables":{"background":"#F4F8F7","primaryColor":"#E4EEED","primaryTextColor":"#0F1A1B","primaryBorderColor":"#0A6D6A","secondaryColor":"#EDE6D9","tertiaryColor":"#F4F8F7","lineColor":"#6B7C7C","textColor":"#0F1A1B","fontFamily":"ui-monospace,SFMono-Regular,Menlo,monospace","fontSize":"13px","clusterBkg":"#FAFCFC","clusterBorder":"#C6D4D3"}}}%%
|
||||
flowchart TB
|
||||
s1["ensure_data_dirs<br/>creates all 23 DATA_PATHS"]
|
||||
s2["init_lock<br/>exclusive flock on INIT_LOCK_FILE"]
|
||||
s3["init_db<br/>idempotent columns and indexes"]
|
||||
s4["ensure_certificates<br/>VAPID keys in data/keys"]
|
||||
s5["service_manager.register x27"]
|
||||
gate{"DEVPLACE_DISABLE_SERVICES set?"}
|
||||
skip["services registered but never started<br/>test mode"]
|
||||
bg["background.start<br/>per worker asyncio queue"]
|
||||
elect{"acquire_service_lock"}
|
||||
owner["set_lock_owner True<br/>supervise all 27 services"]
|
||||
decline["declined, another worker owns them"]
|
||||
vis["start_visit_flusher"]
|
||||
serve(["serving on port 10500"])
|
||||
s1 --> s2 --> s3 --> s4 --> s5 --> gate
|
||||
gate -->|yes| skip --> vis
|
||||
gate -->|no| bg --> elect
|
||||
elect -->|"lock acquired"| owner --> vis
|
||||
elect -->|"lock held elsewhere"| decline --> vis
|
||||
vis --> serve
|
||||
```
|
||||
|
||||
Shutdown reverses it: flush_visits, service_manager.shutdown_all, background.stop.
|
||||
|
||||
## 03 Request pipeline
|
||||
|
||||
Source: `devplacepy/main.py, verified against app.user_middleware`
|
||||
|
||||
Ten middlewares wrap every request. The order below is the real execution order read off the built middleware stack, outermost first. Two of them are plain ASGI classes; the other eight are `BaseHTTPMiddleware` dispatch functions.
|
||||
|
||||
```mermaid
|
||||
%%{init:{"theme":"base","themeVariables":{"background":"#F4F8F7","primaryColor":"#E4EEED","primaryTextColor":"#0F1A1B","primaryBorderColor":"#0A6D6A","secondaryColor":"#EDE6D9","tertiaryColor":"#F4F8F7","lineColor":"#6B7C7C","textColor":"#0F1A1B","fontFamily":"ui-monospace,SFMono-Regular,Menlo,monospace","fontSize":"13px","clusterBkg":"#FAFCFC","clusterBorder":"#C6D4D3"}}}%%
|
||||
flowchart TB
|
||||
req(["incoming ASGI scope"])
|
||||
m0["0 GZipMiddleware<br/>minimum_size 512, level 5"]
|
||||
m1["1 TunnelDispatchMiddleware<br/>host matches a workspace tunnel?"]
|
||||
tun["routers/tunnel.py<br/>handle_http / handle_ws"]
|
||||
m2["2 response_timing<br/>request.state.request_start, X-Response-Time"]
|
||||
m3["3 visit_statistics"]
|
||||
m4["4 track_presence<br/>throttled last_seen write"]
|
||||
m5["5 maintenance_middleware<br/>503 unless static, avatar, auth, admin"]
|
||||
m6["6 rate_limit_middleware<br/>mutating methods only, per IP bucket"]
|
||||
m7["7 add_security_headers"]
|
||||
m8["8 await_pending_corrections<br/>drains sync AI correction futures"]
|
||||
m9["9 refresh_db_snapshot"]
|
||||
mounts["mounts: /static/uploads, /static/v{ts}, /static"]
|
||||
routes["38 included routers"]
|
||||
handlers["exception handlers<br/>404, 500, RequestValidationError"]
|
||||
req --> m0 --> m1
|
||||
m1 -->|"tunnel host"| tun
|
||||
m1 -->|"normal host"| m2 --> m3 --> m4 --> m5 --> m6 --> m7 --> m8 --> m9
|
||||
m9 --> mounts
|
||||
m9 --> routes
|
||||
routes --> handlers
|
||||
```
|
||||
|
||||
Rate limiting reads rate_limit_per_minute and rate_limit_window_seconds from site_settings and exempts reads and the /openai gateway. Bucket key is X-Real-IP falling back to request.client.host.
|
||||
|
||||
## 04 URL surface
|
||||
|
||||
Source: `devplacepy/main.py include_router calls`
|
||||
|
||||
38 routers, each mounted under one prefix, grouped here by the concern they serve. Directory shape mirrors the URL: a domain with one resource is a flat module, a domain with several sub-resources is a package that aggregates its leaves in `__init__.py`.
|
||||
|
||||
```mermaid
|
||||
%%{init:{"theme":"base","themeVariables":{"background":"#F4F8F7","primaryColor":"#E4EEED","primaryTextColor":"#0F1A1B","primaryBorderColor":"#0A6D6A","secondaryColor":"#EDE6D9","tertiaryColor":"#F4F8F7","lineColor":"#6B7C7C","textColor":"#0F1A1B","fontFamily":"ui-monospace,SFMono-Regular,Menlo,monospace","fontSize":"12px","clusterBkg":"#FAFCFC","clusterBorder":"#C6D4D3"}}}%%
|
||||
flowchart LR
|
||||
app(["FastAPI app"])
|
||||
subgraph identity["Identity and profile"]
|
||||
r1["/auth - auth/"]
|
||||
r2["/profile - profile/"]
|
||||
r3["/avatar - avatar.py"]
|
||||
r4["/follow - follow.py"]
|
||||
r5["(none) - relations.py block, mute"]
|
||||
end
|
||||
subgraph social["Content and engagement"]
|
||||
r6["/feed - feed.py"]
|
||||
r7["/posts - posts.py"]
|
||||
r8["/comments - comments.py"]
|
||||
r9["/gists - gists.py"]
|
||||
r10["/news - news.py"]
|
||||
r11["/votes - votes.py"]
|
||||
r12["/reactions - reactions.py"]
|
||||
r13["/bookmarks - bookmarks.py"]
|
||||
r14["/polls - polls.py"]
|
||||
r15["/awards - awards.py"]
|
||||
r16["/leaderboard - leaderboard.py"]
|
||||
r17["/notifications - notifications.py"]
|
||||
r18["/messages - messages.py"]
|
||||
r19["/uploads - uploads.py"]
|
||||
r20["/media - media.py"]
|
||||
end
|
||||
subgraph work["Projects and compute"]
|
||||
r21["/projects - projects/ incl files, containers"]
|
||||
r22["/p - proxy.py container ingress"]
|
||||
r23["/zips - zips.py"]
|
||||
r24["/forks - forks.py"]
|
||||
r25["/issues - issues/"]
|
||||
end
|
||||
subgraph aiml["AI and tools"]
|
||||
r26["/openai - openai_gateway.py"]
|
||||
r27["/devii - devii.py"]
|
||||
r28["/tools - tools/ seo, deepsearch, isslop"]
|
||||
end
|
||||
subgraph play["Play"]
|
||||
r29["/game - game/"]
|
||||
r30["/quizzes - quizzes/"]
|
||||
end
|
||||
subgraph platform["Platform"]
|
||||
r31["/admin - admin/ 23 modules"]
|
||||
r32["/docs - docs/"]
|
||||
r33["(none) - push.py, seo.py"]
|
||||
end
|
||||
subgraph machine["Machine interfaces"]
|
||||
r34["/api - devrant/"]
|
||||
r35["/dbapi - dbapi/ read only"]
|
||||
r36["/xmlrpc - xmlrpc.py"]
|
||||
r37["/pubsub - pubsub.py"]
|
||||
end
|
||||
app --> identity
|
||||
app --> social
|
||||
app --> work
|
||||
app --> aiml
|
||||
app --> play
|
||||
app --> platform
|
||||
app --> machine
|
||||
```
|
||||
|
||||
| Prefix | Paths | Prefix | Paths | Prefix | Paths |
|
||||
|---|---|---|---|---|---|
|
||||
| /admin | 92 | /profile | 17 | /gists | 5 |
|
||||
| /projects | 46 | /issues | 13 | /messages | 5 |
|
||||
| /tools | 27 | /dbapi | 9 | /notifications | 5 |
|
||||
| /game | 23 | /auth | 6 | /uploads | 5 |
|
||||
| /quizzes | 21 | /devii | 5 | /docs | 4 |
|
||||
| /api | 20 | /posts | 4 | everything else | 36 |
|
||||
|
||||
## 05 Route fan-out
|
||||
|
||||
Source: `devplacepy/responses.py, schemas/, docs_api/, services/devii/actions/`
|
||||
|
||||
One handler serves four consumers. `respond()` feeds the same context dict to a Pydantic model and to a Jinja template, so a key missing from the `*Out` schema is silently absent from JSON while still rendering in HTML. The agent catalog and the docs registry are separate declarations that must be kept in step by hand.
|
||||
|
||||
```mermaid
|
||||
%%{init:{"theme":"base","themeVariables":{"background":"#F4F8F7","primaryColor":"#E4EEED","primaryTextColor":"#0F1A1B","primaryBorderColor":"#0A6D6A","secondaryColor":"#EDE6D9","tertiaryColor":"#F4F8F7","lineColor":"#6B7C7C","textColor":"#0F1A1B","fontFamily":"ui-monospace,SFMono-Regular,Menlo,monospace","fontSize":"13px","clusterBkg":"#FAFCFC","clusterBorder":"#C6D4D3"}}}%%
|
||||
flowchart TB
|
||||
form["models.py<br/>Pydantic form model via Annotated Form"]
|
||||
guard["guard: get_current_user<br/>require_user / require_admin"]
|
||||
handler["router handler"]
|
||||
helpers["database/ batch helpers<br/>get_users_by_uids, build_pagination"]
|
||||
ctx["context dict"]
|
||||
neg{"wants_json(request)"}
|
||||
json["model.model_validate<br/>JSONResponse"]
|
||||
html["templates.TemplateResponse<br/>extends base.html"]
|
||||
action["services/devii/actions catalog<br/>Action, requires_auth mirrors guard"]
|
||||
docs["docs_api endpoint()<br/>params and sample_response"]
|
||||
seo["seo.py base_seo_context<br/>JSON-LD, sitemap entry"]
|
||||
form --> handler
|
||||
guard --> handler
|
||||
handler --> helpers --> ctx --> neg
|
||||
neg -->|"json accepted"| json
|
||||
neg -->|"otherwise"| html
|
||||
handler -.->|"same capability"| action
|
||||
handler -.->|"same contract"| docs
|
||||
html -.-> seo
|
||||
```
|
||||
|
||||
wants_json is true when the request content-type starts with application/json, or Accept contains application/json and does not contain text/html. Registry sizes: 318 agent actions, 259 documented endpoint entries across 20 API groups, 122 prose docs pages.
|
||||
|
||||
## 06 Identity resolution
|
||||
|
||||
Source: `devplacepy/utils/auth.py`
|
||||
|
||||
A single resolver serves browsers, API clients, the devRant compatibility layer and issued access tokens. The result is memoised on `request.state` for the request and in a process TTL cache keyed by credential.
|
||||
|
||||
```mermaid
|
||||
%%{init:{"theme":"base","themeVariables":{"background":"#F4F8F7","primaryColor":"#E4EEED","primaryTextColor":"#0F1A1B","primaryBorderColor":"#0A6D6A","secondaryColor":"#EDE6D9","tertiaryColor":"#F4F8F7","lineColor":"#6B7C7C","textColor":"#0F1A1B","fontFamily":"ui-monospace,SFMono-Regular,Menlo,monospace","fontSize":"13px","clusterBkg":"#FAFCFC","clusterBorder":"#C6D4D3"}}}%%
|
||||
flowchart TB
|
||||
start(["get_current_user"])
|
||||
cached{"request.state._auth_user set?"}
|
||||
sess{"session cookie<br/>64 hex chars"}
|
||||
xapi{"X-API-KEY header"}
|
||||
bearer{"Authorization: Bearer"}
|
||||
basic{"Authorization: Basic"}
|
||||
triple["try in order:<br/>users.api_key,<br/>devrant token 40 hex,<br/>access token 64 chars"]
|
||||
user(["user dict"])
|
||||
guest(["None, guest"])
|
||||
gu["require_user: 303 to /"]
|
||||
ga["require_admin: redirect to /feed"]
|
||||
start --> cached
|
||||
cached -->|yes| user
|
||||
cached -->|no| sess
|
||||
sess -->|match| user
|
||||
sess -->|no| xapi
|
||||
xapi -->|present| triple
|
||||
triple -->|match| user
|
||||
triple -->|no match| bearer
|
||||
xapi -->|absent| bearer
|
||||
bearer -->|present| triple
|
||||
bearer -->|absent| basic
|
||||
basic -->|"username-or-email:password, pbkdf2_sha256"| user
|
||||
basic -->|no| guest
|
||||
guest --> gu
|
||||
user --> ga
|
||||
```
|
||||
|
||||
Roles are stored capitalized as Admin or Member and tested through the is_admin global. Admin seniority is enforced per user mutation in routers/admin/users.py.
|
||||
|
||||
## 07 Background service fleet
|
||||
|
||||
Source: `devplacepy/services/manager.py, base.py, jobs/base.py`
|
||||
|
||||
27 singletons registered at boot, supervised only by the worker that won the service lock. Sixteen are long-lived loops on `BaseService`; eleven are queue consumers on `JobService`, which adds retention, concurrency and timeout settings on top and drains rows from the shared `jobs` table.
|
||||
|
||||
```mermaid
|
||||
%%{init:{"theme":"base","themeVariables":{"background":"#F4F8F7","primaryColor":"#E4EEED","primaryTextColor":"#0F1A1B","primaryBorderColor":"#0A6D6A","secondaryColor":"#EDE6D9","tertiaryColor":"#F4F8F7","lineColor":"#6B7C7C","textColor":"#0F1A1B","fontFamily":"ui-monospace,SFMono-Regular,Menlo,monospace","fontSize":"12px","clusterBkg":"#FAFCFC","clusterBorder":"#C6D4D3"}}}%%
|
||||
flowchart LR
|
||||
mgr["ServiceManager<br/>supervise, shutdown_all"]
|
||||
settings[("site_settings<br/>enabled flag and interval per service")]
|
||||
jobs[("jobs table")]
|
||||
subgraph base["BaseService loops - 16"]
|
||||
b1["NewsService"]
|
||||
b2["BotsService"]
|
||||
b3["GatewayService"]
|
||||
b4["DeviiService"]
|
||||
b5["PubSubService"]
|
||||
b6["NotificationRelayService"]
|
||||
b7["LiveViewRelayService"]
|
||||
b8["PresenceRelayService"]
|
||||
b9["IssueTrackerService"]
|
||||
b10["ContainerService"]
|
||||
b11["WorkspaceService"]
|
||||
b12["XmlrpcService"]
|
||||
b13["AuditService"]
|
||||
b14["PushService"]
|
||||
b15["TelegramService"]
|
||||
b16["TelegramOutboxService"]
|
||||
end
|
||||
subgraph job["JobService consumers - 11"]
|
||||
j1["ZipService"]
|
||||
j2["ForkService"]
|
||||
j3["SeoService"]
|
||||
j4["SeoMetaService"]
|
||||
j5["AwardService"]
|
||||
j6["BackupService"]
|
||||
j7["DbApiJobService"]
|
||||
j8["DeepsearchService"]
|
||||
j9["IsslopService"]
|
||||
j10["IssueCreateService"]
|
||||
j11["PlanningReportService"]
|
||||
end
|
||||
mgr --> base
|
||||
mgr --> job
|
||||
settings --> mgr
|
||||
job --> jobs
|
||||
```
|
||||
|
||||
Separate from the fleet, services/background.py offers a fire and forget queue per worker for audit writes, XP awards and notifications; when no consumer runs, as in tests, the callable executes inline so ordering stays deterministic.
|
||||
|
||||
## 08 Data layer
|
||||
|
||||
Source: `devplacepy/database/, devplacepy/config.py`
|
||||
|
||||
One SQLite file reached through `dataset`, called synchronously from async handlers by design. 102 tables exist in the live schema; 52 of them carry the soft delete pair and are restorable as one event from the admin trash.
|
||||
|
||||
```mermaid
|
||||
%%{init:{"theme":"base","themeVariables":{"background":"#F4F8F7","primaryColor":"#E4EEED","primaryTextColor":"#0F1A1B","primaryBorderColor":"#0A6D6A","secondaryColor":"#EDE6D9","tertiaryColor":"#F4F8F7","lineColor":"#6B7C7C","textColor":"#0F1A1B","fontFamily":"ui-monospace,SFMono-Regular,Menlo,monospace","fontSize":"12px","clusterBkg":"#FAFCFC","clusterBorder":"#C6D4D3"}}}%%
|
||||
flowchart TB
|
||||
db[("data/devplace.db")]
|
||||
subgraph g1["Identity and access - 14"]
|
||||
t1["users, sessions, password_resets<br/>access_tokens, devrant_tokens<br/>user_relations, follows<br/>notification_preferences, user_customizations<br/>push_registration, email_accounts<br/>telegram_links, telegram_pairings, telegram_outbox"]
|
||||
end
|
||||
subgraph g2["Content and engagement - 16"]
|
||||
t2["posts, comments, gists<br/>projects, project_files, project_forks<br/>attachments, news, news_images, news_sync<br/>polls, poll_options, poll_votes<br/>votes, reactions, bookmarks"]
|
||||
end
|
||||
subgraph g3["Reputation - 5"]
|
||||
t3["awards, badges, award_usage<br/>user_activity, user_activity_seen"]
|
||||
end
|
||||
subgraph g4["Code Farm - 9"]
|
||||
t4["game_farms, game_plots, game_quests<br/>game_cosmetics, game_market_ticks<br/>game_steals, game_treasury<br/>game_eras, game_era_results"]
|
||||
end
|
||||
subgraph g5["Quizzes - 5"]
|
||||
t5["quizzes, quiz_questions, quiz_options<br/>quiz_attempts, quiz_answers"]
|
||||
end
|
||||
subgraph g6["Live delivery - 3"]
|
||||
t6["messages, notifications, ws_tickets"]
|
||||
end
|
||||
subgraph g7["Devii - 8"]
|
||||
t7["devii_conversations, devii_turns<br/>devii_tasks, devii_task_runs<br/>devii_lessons, devii_virtual_tools<br/>devii_behavior, devii_usage_ledger"]
|
||||
end
|
||||
subgraph g8["AI gateway - 6"]
|
||||
t8["gateway_providers, gateway_models<br/>gateway_usage_ledger<br/>gateway_quota_rules, gateway_quota_resets<br/>gateway_concurrency_samples"]
|
||||
end
|
||||
subgraph g9["Jobs, tools and usage - 20"]
|
||||
t9["jobs, backups, backup_schedules<br/>deepsearch_sessions, deepsearch_messages, deepsearch_url_cache<br/>isslop_analyses, isslop_reports, isslop_events<br/>isslop_dom_results, isslop_file_results, isslop_image_results<br/>seo_metadata, seo_usage<br/>issue_tickets, issue_comment_authors, issue_usage<br/>correction_usage, modifier_usage, news_usage"]
|
||||
end
|
||||
subgraph g10["Containers - 9"]
|
||||
t10["instances, instance_events, instance_metrics<br/>tunnels, workspace_flags, workspace_quota_rules<br/>builds, dockerfiles, dockerfile_versions"]
|
||||
end
|
||||
subgraph g11["Platform state - 7"]
|
||||
t11["site_settings, cache_state, service_state<br/>audit_log, audit_log_links<br/>visit_stats_hourly, visit_unique_slots"]
|
||||
end
|
||||
db --> g1
|
||||
db --> g2
|
||||
db --> g3
|
||||
db --> g4
|
||||
db --> g5
|
||||
db --> g6
|
||||
db --> g7
|
||||
db --> g8
|
||||
db --> g9
|
||||
db --> g10
|
||||
db --> g11
|
||||
```
|
||||
|
||||
```mermaid
|
||||
%%{init:{"theme":"base","themeVariables":{"background":"#F4F8F7","primaryColor":"#E4EEED","primaryTextColor":"#0F1A1B","primaryBorderColor":"#0A6D6A","secondaryColor":"#EDE6D9","tertiaryColor":"#F4F8F7","lineColor":"#6B7C7C","textColor":"#0F1A1B","fontFamily":"ui-monospace,SFMono-Regular,Menlo,monospace","fontSize":"12px","clusterBkg":"#FAFCFC","clusterBorder":"#C6D4D3"}}}%%
|
||||
flowchart LR
|
||||
root["DEVPLACE_DATA_DIR<br/>default repo/data"]
|
||||
blobs["uploads, attachments, project_files<br/>sharded xx/yy on the uuid7 random tail"]
|
||||
jobsdir["zips, zip_staging, fork_staging<br/>backups, backup_staging"]
|
||||
reports["seo_reports, planning_reports<br/>dbapi, deepsearch, deepsearch_chroma"]
|
||||
isslop["isslop, isslop_workspaces<br/>isslop_runs, isslop_media"]
|
||||
ws["container_workspaces, workspace_state"]
|
||||
keys["keys VAPID, bot, locks"]
|
||||
dbs["devplace.db, devii_tasks.db, devii_lessons.db"]
|
||||
root --> blobs
|
||||
root --> jobsdir
|
||||
root --> reports
|
||||
root --> isslop
|
||||
root --> ws
|
||||
root --> keys
|
||||
root --> dbs
|
||||
```
|
||||
|
||||
config.DATA_PATHS registers 23 directories and ensure_data_dirs creates the whole tree before any write. Uploads live under data/uploads but are served at the unchanged /static/uploads URL.
|
||||
|
||||
## 09 Content rendering
|
||||
|
||||
Source: `devplacepy/rendering.py, static/js/ContentRenderer.js`
|
||||
|
||||
Two pipelines with a deliberate split: anything that exists at request time is rendered on the server for SEO, and the client pipeline is reserved for content that does not exist yet. Each has its own XSS control at a different point.
|
||||
|
||||
```mermaid
|
||||
%%{init:{"theme":"base","themeVariables":{"background":"#F4F8F7","primaryColor":"#E4EEED","primaryTextColor":"#0F1A1B","primaryBorderColor":"#0A6D6A","secondaryColor":"#EDE6D9","tertiaryColor":"#F4F8F7","lineColor":"#6B7C7C","textColor":"#0F1A1B","fontFamily":"ui-monospace,SFMono-Regular,Menlo,monospace","fontSize":"13px","clusterBkg":"#FAFCFC","clusterBorder":"#C6D4D3"}}}%%
|
||||
flowchart TB
|
||||
subgraph server["Server, rendering.py, lru_cache"]
|
||||
a1["raw text"]
|
||||
a2["normalize dashes to hyphen"]
|
||||
a3["emoji shortcodes, 4869 names"]
|
||||
a4["mistune GFM, escape=True"]
|
||||
a5["media pass: bare URLs to embeds, mentions to links"]
|
||||
a6["mask emails on rendered text nodes"]
|
||||
a7["render_content / render_title in template"]
|
||||
a8["ContentEnhancer adds highlighting and copy buttons"]
|
||||
a1 --> a2 --> a3 --> a4 --> a5 --> a6 --> a7 --> a8
|
||||
end
|
||||
subgraph client["Client, ContentRenderer.js"]
|
||||
b1["live text: comments, DM bubbles, Devii, DeepSearch"]
|
||||
b2["marked"]
|
||||
b3["DOMPurify.sanitize, fail closed"]
|
||||
b4["highlight.js"]
|
||||
b5["media and autolink pass"]
|
||||
b1 --> b2 --> b3 --> b4 --> b5
|
||||
end
|
||||
```
|
||||
|
||||
The server escapes at the markdown step, the client sanitizes after parsing. Server rendered content must not carry data-render, or both pipelines run over it.
|
||||
|
||||
## 10 AI plane
|
||||
|
||||
Source: `devplacepy/routers/openai_gateway.py, services/openai_gateway/, services/devii/`
|
||||
|
||||
Every model call in the product, internal or external, leaves through one gateway, which is where routing, quota and cost attribution live. Internal consumers call it over loopback HTTP rather than importing a client, so their spend lands in the same ledger.
|
||||
|
||||
```mermaid
|
||||
%%{init:{"theme":"base","themeVariables":{"background":"#F4F8F7","primaryColor":"#E4EEED","primaryTextColor":"#0F1A1B","primaryBorderColor":"#0A6D6A","secondaryColor":"#EDE6D9","tertiaryColor":"#F4F8F7","lineColor":"#6B7C7C","textColor":"#0F1A1B","fontFamily":"ui-monospace,SFMono-Regular,Menlo,monospace","fontSize":"12px","clusterBkg":"#FAFCFC","clusterBorder":"#C6D4D3"}}}%%
|
||||
flowchart TB
|
||||
ext["external client<br/>/openai/v1/*"]
|
||||
subgraph consumers["Internal consumers via INTERNAL_GATEWAY_URL"]
|
||||
c1["services/devii"]
|
||||
c2["services/correction<br/>AI correction and modifier"]
|
||||
c3["services/news"]
|
||||
c4["services/bot"]
|
||||
c5["services/deepsearch + jobs/deepsearch"]
|
||||
c6["services/dbapi nl2sql"]
|
||||
c7["services/gitea enhance + planning"]
|
||||
c8["jobs/isslop"]
|
||||
end
|
||||
gw["gateway.py<br/>routing.py, quota.py, usage.py<br/>reliability.py, vision.py"]
|
||||
ledger[("gateway_usage_ledger<br/>gateway_quota_rules")]
|
||||
stealth["stealth_async_client<br/>curl_cffi Chrome 146 fingerprint"]
|
||||
up["upstream provider"]
|
||||
subgraph devii["Devii assistant"]
|
||||
d1["registry.py CATALOG<br/>318 tools"]
|
||||
d2["role gating<br/>guest 108, member 246<br/>admin 312, primary 318"]
|
||||
d3["45 tools behind CONFIRM_REQUIRED"]
|
||||
d4["session, tasks, lessons<br/>virtual tools, behavior"]
|
||||
d5["surfaces: /devii ws, Telegram, CLI"]
|
||||
end
|
||||
ext --> gw
|
||||
consumers --> gw
|
||||
gw --> ledger
|
||||
gw --> stealth --> up
|
||||
d1 --> d2 --> d3
|
||||
devii --> c1
|
||||
d4 --> d1
|
||||
d5 --> d4
|
||||
```
|
||||
|
||||
Cleartext loopback calls are forced to HTTP/1.1 in curl_transport.http_version_for, because the Chrome impersonation profile would otherwise negotiate HTTP/2 against an HTTP/1.1 only uvicorn.
|
||||
|
||||
## 11 Containers, workspaces and ingress
|
||||
|
||||
Source: `devplacepy/services/containers/, routers/projects/containers/, routers/proxy.py, routers/tunnel.py`
|
||||
|
||||
One shared image serves every instance. Reconciliation is a loop that compares desired state in the database against what the Docker daemon actually reports, and there are two independent ways in from the outside: a path prefix and a hostname.
|
||||
|
||||
```mermaid
|
||||
%%{init:{"theme":"base","themeVariables":{"background":"#F4F8F7","primaryColor":"#E4EEED","primaryTextColor":"#0F1A1B","primaryBorderColor":"#0A6D6A","secondaryColor":"#EDE6D9","tertiaryColor":"#F4F8F7","lineColor":"#6B7C7C","textColor":"#0F1A1B","fontFamily":"ui-monospace,SFMono-Regular,Menlo,monospace","fontSize":"12px","clusterBkg":"#FAFCFC","clusterBorder":"#C6D4D3"}}}%%
|
||||
flowchart TB
|
||||
ui["/projects/{slug}/containers<br/>instances, schedules, workspace"]
|
||||
api["services/containers/api.py"]
|
||||
store[("instances, instance_events<br/>instance_metrics, tunnels<br/>workspace_flags, workspace_quota_rules")]
|
||||
svc["ContainerService<br/>reconcile desired vs docker ps"]
|
||||
wsvc["WorkspaceService<br/>provision, certs, quota, tunnels"]
|
||||
backend["backend/docker_cli.py<br/>backend/fake.py for tests"]
|
||||
image["single shared image ppy:latest"]
|
||||
inst["running instance<br/>rootless workflow, aptroot"]
|
||||
wsdir["data/container_workspaces/{uid}"]
|
||||
proxy["/p/{slug}<br/>routers/proxy.py, relays headers verbatim"]
|
||||
tunnel["host based tunnel<br/>TunnelDispatchMiddleware to routers/tunnel.py"]
|
||||
code["workspace editor over websocket<br/>/projects/{slug}/containers/instances/{uid}/code"]
|
||||
exec["terminal over websocket<br/>.../exec/ws"]
|
||||
ui --> api --> store
|
||||
svc --> store
|
||||
wsvc --> store
|
||||
api --> backend
|
||||
svc --> backend
|
||||
wsvc --> wsdir
|
||||
backend --> image --> inst
|
||||
proxy --> inst
|
||||
tunnel --> inst
|
||||
code --> inst
|
||||
exec --> inst
|
||||
```
|
||||
|
||||
Access uses stricter predicates than the rest of the product: owns_instance, can_view_project_containers, can_view_instance, can_manage_instance. The primary administrator sees and manages every container; any other admin can only view others on public projects and manage instances they own.
|
||||
|
||||
## 12 Real time plane
|
||||
|
||||
Source: `routers/*.py websocket handlers, services relays, nginx.conf.template`
|
||||
|
||||
Twelve WebSocket endpoints, each of which needs its own nginx upgrade location because the catch-all location strips upgrade headers. Fan-out to connected sockets goes through in-process hubs driven by relay services on the lock-owning worker.
|
||||
|
||||
```mermaid
|
||||
%%{init:{"theme":"base","themeVariables":{"background":"#F4F8F7","primaryColor":"#E4EEED","primaryTextColor":"#0F1A1B","primaryBorderColor":"#0A6D6A","secondaryColor":"#EDE6D9","tertiaryColor":"#F4F8F7","lineColor":"#6B7C7C","textColor":"#0F1A1B","fontFamily":"ui-monospace,SFMono-Regular,Menlo,monospace","fontSize":"12px","clusterBkg":"#FAFCFC","clusterBorder":"#C6D4D3"}}}%%
|
||||
flowchart LR
|
||||
subgraph sockets["WebSocket endpoints"]
|
||||
s1["/devii/ws"]
|
||||
s2["/messages/ws"]
|
||||
s3["/pubsub/ws"]
|
||||
s4["/tools/seo/{uid}/ws"]
|
||||
s5["/tools/deepsearch/{uid}/ws"]
|
||||
s6["/tools/deepsearch/{uid}/chat"]
|
||||
s7["/dbapi/query/{uid}/ws"]
|
||||
s8[".../containers/instances/{uid}/exec/ws"]
|
||||
s9[".../containers/instances/{uid}/code"]
|
||||
s10[".../code/{path}"]
|
||||
s11["/p/{slug}"]
|
||||
s12["/p/{slug}/{path}"]
|
||||
end
|
||||
subgraph relays["Relay services"]
|
||||
r1["NotificationRelayService"]
|
||||
r2["LiveViewRelayService"]
|
||||
r3["PresenceRelayService<br/>track limit 500, online limit 30"]
|
||||
r4["PubSubService"]
|
||||
end
|
||||
hubs["messaging/hub.py, pubsub/hub.py<br/>devii/hub.py"]
|
||||
tick[("ws_tickets<br/>expiring auth tickets")]
|
||||
s2 --> hubs
|
||||
s3 --> hubs
|
||||
s1 --> hubs
|
||||
relays --> hubs
|
||||
tick --> s2
|
||||
```
|
||||
|
||||
Presence is authoritative from the relay: PRESENCE_TRACK_LIMIT sets the tracked online set, PRESENCE_ONLINE_LIMIT only caps how many the feed panel displays, and PRESENCE_ONLINE_MARGIN_SECONDS provides hysteresis at the boundary.
|
||||
|
||||
## 13 Frontend
|
||||
|
||||
Source: `devplacepy/static/, devplacepy/templates/`
|
||||
|
||||
No framework and no package manager. 131 ES6 modules, one class per file, hung off a single global `app`; 33 of them are custom elements. 217 Jinja templates, of which 107 are the docs site.
|
||||
|
||||
```mermaid
|
||||
%%{init:{"theme":"base","themeVariables":{"background":"#F4F8F7","primaryColor":"#E4EEED","primaryTextColor":"#0F1A1B","primaryBorderColor":"#0A6D6A","secondaryColor":"#EDE6D9","tertiaryColor":"#F4F8F7","lineColor":"#6B7C7C","textColor":"#0F1A1B","fontFamily":"ui-monospace,SFMono-Regular,Menlo,monospace","fontSize":"12px","clusterBkg":"#FAFCFC","clusterBorder":"#C6D4D3"}}}%%
|
||||
flowchart TB
|
||||
base["templates/base.html<br/>extra_head for CSS, extra_js for JS"]
|
||||
partials["shared partials<br/>_avatar_link, _user_link, _comment_section<br/>_post_card, _pagination, _sidebar_search"]
|
||||
appjs["static/js/Application.js<br/>instantiated once as app"]
|
||||
subgraph modules["131 ES6 modules"]
|
||||
u1["utilities<br/>Http, Poller, JobPoller<br/>OptimisticAction, FloatingWindow, ScrollMemory"]
|
||||
u2["33 custom elements<br/>components/ with dp- prefix"]
|
||||
u3["feature modules<br/>chat/, devii/, autoload/"]
|
||||
end
|
||||
css["49 stylesheets<br/>variables.css tokens, per page files"]
|
||||
vers["static_url in Jinja, assetUrl in JS<br/>/static/v{boot ts}/, immutable for a year"]
|
||||
cust["per user CSS and JS injection<br/>custom_css_tag, custom_js_tag"]
|
||||
base --> partials
|
||||
base --> appjs --> modules
|
||||
base --> css
|
||||
base --> cust
|
||||
vers --> css
|
||||
vers --> modules
|
||||
```
|
||||
|
||||
Per user customizations are configured only through Devii, stored in user_customizations, scoped globally or per matched route template, and run solely in that owner's own browser sessions.
|
||||
|
||||
## 14 Test and delivery topology
|
||||
|
||||
Source: `tests/, pyproject.toml, .gitea/workflows/test.yaml`
|
||||
|
||||
Three tiers separated by what they exercise, decided by fixtures, run serially in a single process against one uvicorn subprocess on port 10501 with a temporary database.
|
||||
|
||||
```mermaid
|
||||
%%{init:{"theme":"base","themeVariables":{"background":"#F4F8F7","primaryColor":"#E4EEED","primaryTextColor":"#0F1A1B","primaryBorderColor":"#0A6D6A","secondaryColor":"#EDE6D9","tertiaryColor":"#F4F8F7","lineColor":"#6B7C7C","textColor":"#0F1A1B","fontFamily":"ui-monospace,SFMono-Regular,Menlo,monospace","fontSize":"12px","clusterBkg":"#FAFCFC","clusterBorder":"#C6D4D3"}}}%%
|
||||
flowchart LR
|
||||
unit["tests/unit - 140 modules<br/>mirrors source module path<br/>local_db or no fixture"]
|
||||
api["tests/api - 221 modules<br/>mirrors URL path<br/>app_server, seeded_db"]
|
||||
e2e["tests/e2e - 131 modules<br/>mirrors URL path<br/>page, alice, bob"]
|
||||
srv["uvicorn subprocess :10501<br/>temp SQLite, own DATA_DIR<br/>DEVPLACE_DISABLE_SERVICES=1"]
|
||||
pw["Playwright Chromium<br/>session scoped context"]
|
||||
ci["Gitea Actions on push and PR to master<br/>full suite under coverage"]
|
||||
prod["promotion to production<br/>docker compose"]
|
||||
unit --> ci
|
||||
api --> srv --> ci
|
||||
e2e --> srv
|
||||
e2e --> pw --> ci
|
||||
ci --> prod
|
||||
```
|
||||
|
||||
pytest-xdist is not a dependency and -n is rejected centrally, so no tier can be parallelised by accident.
|
||||
|
||||
## 15 Findings
|
||||
|
||||
Source: `measured against the repository documentation`
|
||||
|
||||
The structure holds up: the router tree mirrors the URL tree, the test tree mirrors both, every runtime path is registered in one place, and every model call has a single exit. The items below are the places where the running code and the written record have drifted apart, or where a structure exists that nothing currently uses. None of them is a functional defect.
|
||||
|
||||
### Middleware order in the documentation is stale
|
||||
|
||||
The root CLAUDE.md states that response_timing is the outermost middleware. Measured from app.user_middleware it is third, behind GZipMiddleware and TunnelDispatchMiddleware, both added after it. The consequence is minor but real: X-Response-Time excludes compression time and excludes tunnel-host dispatch entirely.
|
||||
|
||||
### Two catalogue counts have drifted
|
||||
|
||||
CLAUDE.md cites around 2882 tests and an events.md catalogue of 288 keys. Measured now: 3073 functions named test_ across 492 modules, and 312 distinct dotted event keys in events.md. Both are undercounts in the docs, not missing implementation.
|
||||
|
||||
### instance_schedules is declared soft-deletable but has no table
|
||||
|
||||
It appears in database.SOFT_DELETE_TABLES and in the routers under /projects/{slug}/containers/schedules, but no such table exists in the live schema. dataset creates it lazily on first insert, so this is correct only for as long as every read path tolerates the table being absent.
|
||||
|
||||
### Three legacy container tables still exist
|
||||
|
||||
builds, dockerfiles and dockerfile_versions are present in the live database. The project replaced per-project images with the single shared ppy:latest image and ships devplace containers prune-builds as the one-time cleanup for exactly these rows. The tables are still carried.
|
||||
|
||||
### Five duplicate OpenAPI operation IDs
|
||||
|
||||
Generating the schema warns on editor_proxy twice in routers/projects/containers/workspace.py, passthrough in routers/openai_gateway.py, and proxy_http twice in routers/proxy.py. These are catch-all routes registered for several methods, so a generated client would collide on those names.
|
||||
|
||||
### The one deliberate asymmetry is documented and intentional
|
||||
|
||||
routers/__init__.py holds nothing but the attribution line; unlike every nested router package, the top level does not aggregate. main.py performs all 38 include_router calls directly, which keeps prefix ownership in a single readable block.
|
||||
|
||||
---
|
||||
|
||||
Sources: devplacepy/main.py, the imported FastAPI app object, data/devplace.db sqlite_master, and the working tree at HEAD 192df12b with uncommitted local modifications present. Counts exclude __pycache__ and the virtual environment.
|
||||
313
CHANGELOG.md
Normal file
313
CHANGELOG.md
Normal file
@ -0,0 +1,313 @@
|
||||
## 2026-06-19 🟢
|
||||
|
||||
- Block and mute user relations with API endpoints, CLI emoji-sync command, and content filtering
|
||||
|
||||
|
||||
## 2026-06-18 🟢
|
||||
|
||||
- News service with image dedup, AI grading, featured/landing auto-rotation and admin lock
|
||||
- Server-rendered content pipeline with Telegram pairing, response timing, and admin user index
|
||||
- AI Markdown reformatting for news articles with usage metering and sidebar cleanup
|
||||
|
||||
|
||||
## 2026-06-17 🟢
|
||||
|
||||
- Backup download restricted to primary admin, admin-hidden projects invisible to other admins
|
||||
- Access token system with CLI management and wildcard file type support
|
||||
- Access token issuance with JSON/form login endpoint and token lifecycle management
|
||||
|
||||
|
||||
## 2026-06-16 🔥 Big day!
|
||||
|
||||
- Gateway admin UI with provider and model routing for OpenAI gateway
|
||||
- Backup management CLI commands and service layer with configurable data directories
|
||||
- Audit logging for admin trash restore/purge and notification clear, plus SEO noindex for private projects and sitemap docs page refactor
|
||||
- Instance lookup by name in addition to uid and slug, new terminal session service
|
||||
- Keyboard-aware input visibility with ResizeObserver fallback for mobile message layout
|
||||
- E2E comment hierarchy seed helpers for gists, news and projects
|
||||
- Optimistic message insertion disabled to prevent duplicate bubbles
|
||||
- Router tree documented in AGENTS.md with 14 new route entries
|
||||
- Add dpc binary to container image and set executable permissions
|
||||
- Remove .html and .svg from allowed upload types and MIME mappings
|
||||
|
||||
|
||||
## 2026-06-15 🟢
|
||||
|
||||
- ASGI lifespan handler with background service orchestration and lock-based worker coordination
|
||||
- Message chunking with sentence-aware splitting and configurable character limits
|
||||
- Enforce hard test-coverage standard across DevPlace workflows and agents
|
||||
- Audio file support with inline player and expanded allowed upload types
|
||||
- Gist comment form integration with card-scoped comment targeting
|
||||
- Bot account API key adoption for per-user gateway spend attribution
|
||||
|
||||
|
||||
## 2026-06-14 🔥 Massive day!
|
||||
|
||||
- Admin/internal database API with CRUD, natural-language query, and read-only SQL execution
|
||||
- DeepSearch research job queue with CLI prune/clear, Chroma vector store, and date-aware system message composition
|
||||
- DeepSearch multi-agent researcher with grounded RAG chat and per-session vector store
|
||||
- SEO Diagnostics tool with CLI management, live WebSocket progress, and static asset cache-busting
|
||||
- OpenAI-compatible embeddings endpoint with model mapping and usage tracking
|
||||
- Stealth HTTP client with curl_cffi transport adapter replacing raw httpx for outbound requests
|
||||
- Bot monitor with live age badges and zoomable screenshots
|
||||
- Author-interleaved feed ordering across all feed views and tabs
|
||||
- Author diversity via interleaving (no per-author cap) for home and feed
|
||||
- devRant API client library and example scripts in Python and JavaScript
|
||||
- TTLCache-backed cache version reads with invalidation on bump
|
||||
- Random client IP spoofing for load-testing traffic
|
||||
- Deepsearch chat component attribute naming from data-* to direct properties
|
||||
- Replace `python -m agents.validator` with `hawk` across all agent markdown files
|
||||
|
||||
|
||||
## 2026-06-13 🔥 Massive day!
|
||||
|
||||
- Three-tier test suite with unit, API, and E2E directories mirroring source and endpoint paths
|
||||
- Soft-delete audit for bookmarks, comments, follows, polls, project files, reactions, and bug create request event
|
||||
- Notification preferences with per-user per-channel toggles and admin defaults
|
||||
- Author diversity enforcement across home page and feed with personalized landing for authenticated users
|
||||
- Shared free-text search across feed, gists, and projects listings
|
||||
- Docs search with agent-powered Docii chat and admin-configurable search mode
|
||||
- Devii agent audit log query action with filterable paginated endpoint
|
||||
- Router directory-tree convention with admin audit log, AI quota, and container management endpoints
|
||||
- Initial maintenance agent fleet with per-dimension code quality enforcers
|
||||
- Unified blob sharding on uuid7 random tail across attachments, project files, and zip service
|
||||
- Consolidated runtime data directory layout with migration CLI command
|
||||
- Context-aware window control button visibility with font size boundary detection and minimize/normalize size presets
|
||||
- Overflow-managed profile tabs with a "more" dropdown for narrow screens
|
||||
- Startup jitter, randomized browser fingerprinting, and short comment styles for bot realism
|
||||
- Sidebar search form with hidden field support and configurable placeholder
|
||||
- Prevent titlebar double-click maximize when clicking buttons in FloatingWindow and DeviiTerminal
|
||||
- Pin test server to single worker and use upsert for rate-limit settings to prevent spurious 429s
|
||||
- DEVPLACE_DISABLE_RATE_LIMIT env var to bypass rate limiter in tests and middleware
|
||||
- Fallback to location.origin when DEVPLACE_DOCS.base is missing
|
||||
- Add claude-manual task-oriented guide page with cross-reference from claude.html
|
||||
- Remove PWA install button and associated installer module
|
||||
- Removed stale test files and fixed Gitea env teardown and ingress proxy test cleanup
|
||||
- Locustfile seed data expansion and route exclusion documentation
|
||||
|
||||
|
||||
## 2026-06-12 🔥 Massive day!
|
||||
|
||||
- Agent report system with codenames, timestamped output streams, and write-budget enforcement
|
||||
- Tool-scoped payload filtering for worker agents with orchestration tool isolation
|
||||
- Agent isolation and result caching in Maestro review sweep
|
||||
- Concurrent read-only fleet check mode with per-agent cost tracking and contextvar-isolated findings
|
||||
- Admin analytics and AI usage API response keys renamed, password change toggle added
|
||||
- Gitea-backed bug tracker with list/detail/comment/status and AI-enhanced filing
|
||||
- Bug detail page with admin/member role rendering and viewer_is_admin context flag
|
||||
- Changed-files fast mode for maintenance agents with write-allowlist guard
|
||||
- Partial config save with error reporting and password manager suppression
|
||||
- Admin route cache-disabling headers via Cache-Control, Pragma and Expires
|
||||
- Dirty-field tracking and server-side value sync for service config forms
|
||||
- Rename `is_admin` to `viewer_is_admin` in bug detail schema, router, and template
|
||||
- Bug tracker unavailable page with JSON and HTML 503 error responses
|
||||
- Bot comments avoid repeating sibling opinions via thread-aware distinctness prompt
|
||||
- Default Gitea repository changed from pydevplace to devplacepy
|
||||
- Remove pytest-xdist parallel test execution, switch to serial single-process test runner
|
||||
|
||||
|
||||
## 2026-06-11 🔥 Massive day!
|
||||
|
||||
- Platform-wide soft delete with deleted_at/deleted_by columns and admin trash management
|
||||
- Owner-or-admin soft-delete enforcement on all content endpoints
|
||||
- Unified image lightbox with attribute-wired opening and per-user media tab with soft delete
|
||||
- Autonomous maintenance agent fleet with CLI entry point, Makefile targets, and dependency-free validator
|
||||
- Seed-finding guided fix mode for maintenance agents with incomplete report tracking
|
||||
- Audit log tables with CLI recording hooks
|
||||
- Resolve merge conflict in pagination template and add admin-audit-log endpoint to docs API
|
||||
- Bots documentation pages and session stop/reset commands
|
||||
- Reduced nested comment indentation from 1.5rem to 0.25rem per depth level
|
||||
- Reduce comment indentation multiplier and padding for nested replies
|
||||
- Switch to dynamic viewport height and remove autofocus from message input
|
||||
- Inline message layout with responsive height and auto-scroll
|
||||
- Optional label attribute with hidden empty state for dp-upload component
|
||||
- Mandatory retoor header added to all devplacepy source files
|
||||
|
||||
|
||||
## 2026-06-10 🟢
|
||||
|
||||
- Port conflict detection and test isolation hardening across admin, avatar, bugs, landing, messages, and customization tests
|
||||
- Container proxy routing via container IP instead of host port, with fake backend network simulation
|
||||
- XDG-compliant devii tasks database path with DEVII_HOME override
|
||||
- Project editing endpoint with 125k char body limit and remote URL attachment guard
|
||||
|
||||
|
||||
## 2026-06-09 🔥 Big day!
|
||||
|
||||
- Container manager with Dockerfile CRUD, image builds, instance lifecycle, ingress proxy, and CLI commands
|
||||
- Async project fork service with job queue, CLI management, and shared container image build
|
||||
- Parallel test execution with per-worker isolated databases, data dirs, and uvicorn subprocesses via pytest-xdist
|
||||
- Per-user customization suppression toggles with profile UI and Devii tool
|
||||
- Customization toggle UI with enable/disable state management
|
||||
- Unified shared Http and Poller utilities across all frontend modules, replacing inline fetch and setInterval patterns
|
||||
- Responsive refinements for sub-360px screens, touch targets, safe-area insets, and mobile window controls
|
||||
- Click-to-open profile dropdown with keyboard and outside-click dismissal
|
||||
- Migrate hardcoded spacing values to CSS custom properties across multiple stylesheets
|
||||
- Unicode escape normalization for emoji constants across codebase
|
||||
- Consolidated upload ignore rules into a single directory-level gitignore entry
|
||||
- Removed unused imports across routers, database, and services
|
||||
- Remove project_set_private from confirmation-required actions and fix async test helpers
|
||||
|
||||
|
||||
## 2026-06-08 🟢
|
||||
|
||||
- Admin AI quota management with CLI and admin panel reset controls
|
||||
- API key management CLI with backfill command and auth support across session, API key, and HTTP Basic
|
||||
- Per-project filesystem with directory and file CRUD, upload, and inline editing
|
||||
- Async zip job framework with CLI management and zip archive download endpoints
|
||||
- Add mistune dependency to project
|
||||
|
||||
|
||||
## 2026-06-06 🟢
|
||||
|
||||
- Reactions, bookmarks, polls, extended sessions, and operational settings
|
||||
|
||||
|
||||
## 2026-06-05 🔥 Massive day!
|
||||
|
||||
- Batch attachment linking, deduplicated mention notifications, and idempotent badge milestone checks
|
||||
- Cursor-based load-more pagination across feed, gists, news and projects
|
||||
- Canonical slug redirects, cursor-based next-page links, and OG image extraction across feed, gists, news, posts, projects, and profile
|
||||
- TTLCache with LRU eviction, CLI role management, content unit helpers, database query functions, follow API with XP rewards, and news service with AI grading
|
||||
- Unified comment form component with mobile touch optimizations across all CSS
|
||||
- Inline comment previews on post cards with per-comment reply forms
|
||||
- Comment template with threaded voting, author display, and attachment support
|
||||
- Post-login redirect with `next` parameter and unauthenticated comment redirect to login
|
||||
- Login redirect for unauthenticated admin, next parameter support with external URL rejection, and inline comment reply forms
|
||||
- Seed comments created for all posts instead of only the first
|
||||
- Replace uuid4 with uuid7 via uuid_utils for push notification JWT jti claims
|
||||
- Coverage instrumentation for CI and local test runs with HTML report artifact
|
||||
- Coverage configuration with subprocess measurement support
|
||||
- Sitemap TTL configurable via environment variable and news_images schema migration
|
||||
- Kill stale server process and add startup failure detection for Locust targets
|
||||
|
||||
|
||||
## 2026-06-02 🟢
|
||||
|
||||
- Multi-worker service lock with cascading vote/comment cleanup on content deletion
|
||||
|
||||
|
||||
## 2026-05-30 🟢
|
||||
|
||||
- Leaderboard route with gamification system (XP, levels, badges, stars) and content creation refactor
|
||||
|
||||
|
||||
## 2026-05-28 🟢
|
||||
|
||||
- Cursor-based pagination for feed, notifications, and votes with thumbnail extension fallback
|
||||
- Push registration returns creation flag and only sends welcome notification on first registration
|
||||
|
||||
|
||||
## 2026-05-27 🟢
|
||||
|
||||
- AJAX vote buttons with live count updates across posts, gists, projects, and comments
|
||||
- CSS-only card-link overlay replacing JS-driven data-href navigation
|
||||
|
||||
|
||||
## 2026-05-25 🟢
|
||||
|
||||
- Unified notification click-to-navigate with comment anchor highlighting and dismiss refactor
|
||||
|
||||
|
||||
## 2026-05-23 🔥 Massive day!
|
||||
|
||||
- Web push notifications with PWA manifest and service worker registration
|
||||
- Web push notifications with PWA offline shell and install prompt
|
||||
- Unified badge, notification, and content enrichment system with star tracking helpers
|
||||
- Aggregate star counts across posts, projects and gists for profile and top-author ranking
|
||||
- Content editing and deletion with cascading cleanup, avatar image helper, HTTP form POST, text input cursor management, and toast flash utility
|
||||
- Share button with clipboard copy across detail pages, structured data schemas for gists and news articles, configurable site URL and rate limit, and production proxy headers support
|
||||
- Production deployment workflow via git merge master into production
|
||||
- Automatic production deployment on successful master push
|
||||
- Removed automatic production deployment from CI pipeline
|
||||
- Admin settings form with Pydantic validation and model-driven save
|
||||
- Pydantic form models with validation for signup, login, password reset, comments, bugs, admin actions, and posts
|
||||
- Type-safe integer settings with empty-value skip on admin save
|
||||
- Input validation tests for votes, posts, profile, and signup endpoints
|
||||
- Rate-limit environment variable and expanded Locust seed data for gists, notifications, and uploads
|
||||
- TTLCache with ETag-based HTTP caching for avatar endpoint
|
||||
- Dynamic language sidebar filtering based on existing gist language codes
|
||||
- Vendor static assets for CodeMirror, highlight.js, marked, and emoji picker
|
||||
- Test server log capture via tempfile with reduced log verbosity
|
||||
- DOMPurify XSS sanitization for client-side rendered markdown content
|
||||
- Add mobile-web-app-capable meta tag for PWA support
|
||||
- Topnav notification bell selector scoped to /notifications href
|
||||
- Fix notification bell icon locator to use explicit href selector instead of first match
|
||||
- Remove stale import of get_users_by_uids from project_detail endpoint
|
||||
|
||||
|
||||
## 2026-05-22 🟢
|
||||
|
||||
- News article HTML sanitization CLI command and database migration
|
||||
|
||||
|
||||
## 2026-05-19 🟢
|
||||
|
||||
- Avatar generation exception logging with full traceback
|
||||
- Fix multiavatar import path and add required arguments to function call
|
||||
|
||||
|
||||
## 2026-05-16 🟢
|
||||
|
||||
- Clickable post titles and content with downvote support on feed and detail pages
|
||||
- Interactive vote buttons and clickable post titles on profile page
|
||||
- Handle @-mention with preceding text in content rendering
|
||||
- Unread notification cache invalidation across comments, follows, messages, votes, and mentions
|
||||
- Compact send button, attachment upload container, and auto-scroll on message thread load
|
||||
- GistEditor lazy init with modal observer, CodeMirror Rust mode removed, emoji picker module type, source textarea required removed, projects tab spacing and settings button removed
|
||||
- Add space between icon and label in feed navigation tabs
|
||||
|
||||
|
||||
## 2026-05-15 🟢
|
||||
|
||||
- Python 3.13 base image, default port 10500, and nginx template to conf.d migration
|
||||
- Responsive mobile navigation and messages layout with hamburger menu and back button
|
||||
- Responsive breakpoint widened from 768px to 1024px for topnav, breadcrumb and page layouts
|
||||
|
||||
|
||||
## 2026-05-14 🟢
|
||||
|
||||
- Migrate from deprecated `datetime.utcnow()` to timezone-aware `datetime.now(timezone.utc)` across the entire codebase
|
||||
- Wait-for-url stabilization in noindex tests for messages and notifications pages
|
||||
- Disable parallel test execution in CI pipeline
|
||||
|
||||
|
||||
## 2026-05-13 🟢
|
||||
|
||||
- Migrate all TemplateResponse calls to pass request as first positional argument
|
||||
- Attachment linking and deletion refactored into dedicated module with batch support
|
||||
- Parallelised integration test suite with xdist worker port isolation
|
||||
- Remove deprecated imghdr dependency and fix icon spacing in bug report buttons
|
||||
- Replace hardcoded pytest.BASE_URL with conftest BASE_URL in attachment tests
|
||||
- CI trigger branch from main to master
|
||||
|
||||
|
||||
## 2026-05-12 🟢
|
||||
|
||||
- News service with admin curation, landing page articles, and comment support
|
||||
- Mention notification system across bugs, comments, messages, posts, and projects with user search API
|
||||
- Gists page with code snippet sharing, voting, and comment integration
|
||||
|
||||
|
||||
## 2026-05-11 🔥 Big day!
|
||||
|
||||
- Unified threaded comment system with polymorphic target support across posts, projects, and bugs
|
||||
- News management system with admin panel, pagination, and SEO sitemap integration
|
||||
- News background service framework with CLI management, bug reports router, and admin services monitoring
|
||||
- Admin panel with user management CLI, SEO metadata, and production deployment config
|
||||
- Multiavatar local SVG generation with WAL mode SQLite and Locust load testing
|
||||
- CI branch target renamed from main to master and test fixtures refactored for explicit login and seeded database
|
||||
- Test fixture improvements with debug logging, stderr capture, and extended startup timeout
|
||||
- Remove hawk static analysis step from CI test workflow
|
||||
|
||||
|
||||
## 2026-05-10 🚀 First commit!
|
||||
|
||||
- Initial project scaffold with FastAPI SSR app, auth, feed, posts, comments, projects, profile, messages, notifications, and voting
|
||||
- DiceBear avatar proxy with style picker on signup and profile, threaded comments
|
||||
- Image upload support for posts with daily topic display on landing and feed
|
||||
|
||||
────────────────────────────────────────────────────────────
|
||||
|
||||
Summary: 194 commits over 29 active days. The project launched on May 10 with the initial FastAPI scaffold, auth, feed, and core content features. The biggest pushes came on June 13 (23 commits) delivering the three-tier test suite, soft-delete audit system, notification preferences, and author diversity enforcement; June 23 (23 commits) adding web push notifications, PWA support, content editing/deletion, and production deployment workflows; and June 14 (14 commits) introducing the admin database API, DeepSearch research system, SEO diagnostics, and the stealth HTTP client.
|
||||
|
||||
24
CLAUDE.md
24
CLAUDE.md
@ -86,8 +86,6 @@ devplace game steals prune # delete Code Farm raid records older than the raid-
|
||||
devplace game era status # show the current Code Farm Era
|
||||
devplace game era start <name> [--days N] # start a Code Farm Era (default 28 days)
|
||||
devplace game era end # end the running Code Farm Era (ranks, awards Stars, records results)
|
||||
devplace accounts pending # list deleted accounts awaiting their purge
|
||||
devplace accounts prune # permanently purge accounts past the deletion grace window (--dry-run to preview)
|
||||
devplace backups list # list recorded backups
|
||||
devplace backups run <database|uploads|keys|full> # enqueue a backup (processed by the running server)
|
||||
devplace backups prune # remove backup records whose archive file is missing
|
||||
@ -121,7 +119,6 @@ Tests run on port 10501 with a tempfile SQLite DB and a dedicated `DEVPLACE_DATA
|
||||
| `DEVPLACE_WEB_WORKERS` / `--workers` | `nproc` (prod) | Uvicorn worker count; `make prod WEB_WORKERS=N` to override. |
|
||||
| `DEVPLACE_PRESENCE_TIMEOUT_SECONDS` | `60` | How long after a user's last activity they still count as online. `config.PRESENCE_WRITE_SECONDS` (half of it) throttles `last_seen` writes per worker. |
|
||||
| `DEVPLACE_PRESENCE_ONLINE_LIMIT` | `30` | Max avatars in the feed's live "Online now" panel. |
|
||||
| `DEVPLACE_PRESENCE_TRACK_LIMIT` | `500` | Size of the online set the presence relay tracks and publishes as the authority for every avatar dot. `PRESENCE_ONLINE_LIMIT` only caps how many of them the feed panel *displays*. |
|
||||
| `DEVPLACE_PRESENCE_ONLINE_MARGIN_SECONDS` | `20` | Grace margin (hysteresis) before an online user is dropped, kills dot/roster flicker at the boundary. |
|
||||
| `DEVPLACE_DATA_DIR` | `<repo>/data` | Single root for ALL runtime/user-generated data OUTSIDE the package and OUTSIDE `/static`. Point at a volume in prod. |
|
||||
| `DEVPLACE_OUTBOUND_PROXY_URL` | unset | Fallback for the `outbound_proxy_url` site setting (below) when the DB/settings row is unavailable (early CLI contexts). Prefer configuring the setting via `/admin/settings` - it applies live with no restart. |
|
||||
@ -140,7 +137,6 @@ Nested `CLAUDE.md` files (loaded automatically by Claude Code only when a file i
|
||||
| `devplacepy/services/devii/CLAUDE.md` | Devii assistant: sessions/channels, scheduler, virtual tools, self-configured behavior, client browser tools |
|
||||
| `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/audit/CLAUDE.md` | Audit log: recorders, categories, retention |
|
||||
| `devplacepy/services/backup/CLAUDE.md` | Backup service: targets, worker, schedules, primary-admin-only download |
|
||||
| `devplacepy/services/telegram/CLAUDE.md` | Telegram bot bridge |
|
||||
@ -163,7 +159,7 @@ Nested `CLAUDE.md` files (loaded automatically by Claude Code only when a file i
|
||||
| `devplacepy/templates/CLAUDE.md` | Modal system, CDN libraries, shared template partials |
|
||||
| `tests/CLAUDE.md` | Detailed testing patterns and pitfalls |
|
||||
|
||||
The AI usage analyzer (`devplace isslop analyze`, the `/tools/isslop` job service) lives entirely inside the package at `devplacepy/services/jobs/isslop/` and is documented in `devplacepy/services/jobs/CLAUDE.md`. There is no repo-root `isslop/` project.
|
||||
`isslop/` at the repo root is a separate, standalone sibling project (own `pyproject.toml`, `Makefile`, port 18732) with its own `isslop/CLAUDE.md` - it is not a nested subsystem of the `devplacepy` package. The integrated engine that DevPlace actually runs (`devplace isslop analyze`, the `/tools/isslop` job service) is a distinct implementation documented in `devplacepy/services/jobs/CLAUDE.md`.
|
||||
|
||||
## Architecture
|
||||
|
||||
@ -198,7 +194,6 @@ Routers in `devplacepy/routers/` are organised as a **directory tree that mirror
|
||||
| `/api` | devrant/ package - see `routers/devrant/CLAUDE.md` |
|
||||
| `/dbapi` | dbapi/ package, **primary-administrator-only, strictly READ-ONLY** - see `services/dbapi/CLAUDE.md` |
|
||||
| `/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` |
|
||||
| (none) | push.py (web push/PWA), docs.py (see `routers/docs/CLAUDE.md`) |
|
||||
| `/pubsub` | pubsub.py - see `services/pubsub/CLAUDE.md` |
|
||||
@ -269,24 +264,18 @@ Devii is also reachable over **Telegram** (one supervised long-poller subprocess
|
||||
|
||||
- **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, push, attachments. SSRF-guarded fetches via `net_guard.guarded_async_client(...)` get the fingerprint automatically. Sole exception: `routers/proxy.py` relays the user's own headers verbatim. **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`.
|
||||
- **All dates shown to users are DD/MM/YYYY** (European), rendered in the viewer's own timezone client-side. Timestamps are stored/emitted as UTC ISO. Use the `local_dt(iso, mode)`/`dt_ago(iso)` Jinja globals for any user-facing instant - they emit `<time data-dt>` and `static/js/LocalTime.js` reformats to local timezone with a `MutationObserver` for dynamic content. `format_date()`/`time_ago()` stay as plain-text helpers for JSON responses, no-JS fallbacks, and non-timestamp date fields (e.g. project `release_date`) - do NOT wrap those in `local_dt`.
|
||||
- **Slug + UUID lookup:** resources with slugs accept either the slug or the bare UUID via `resolve_by_slug()`. Slugs are `make_combined_slug(title, uid)`, prefixed with the **random tail** of the UUID (never the leading bytes - same timestamp-collision reasoning as blob sharding).
|
||||
- **Roles are stored capitalized:** `users.role` is exactly `"Admin"` or `"Member"`. Always test admin-ness through the `is_admin(user)` global (case-sensitive `== "Admin"`) - never hand-roll a lowercase compare. **Any write to `users.role` MUST call `database.invalidate_admins_cache()`.**
|
||||
- **Admin seniority: a junior admin cannot manage a more senior admin.** Every per-user mutation in `routers/admin/users.py` and `routers/admin/moderation.py` is gated by `is_senior_admin(actor, target)` (`routers/admin/_shared.py`) - blocks (audits `result="denied"`) when the target is an Admin who registered earlier. Server-side, so it also covers Devii's admin tools.
|
||||
- **Admin seniority: a junior admin cannot manage a more senior admin.** Every per-user mutation in `routers/admin/users.py` is gated by `_is_senior_admin(actor, target)` - blocks (audits `result="denied"`) when the target is an Admin who registered earlier. Server-side, so it also covers Devii's admin tools.
|
||||
- **Never pass a `respond()` context key that collides with a Jinja global.** `respond(request, template, ctx, model=XOut)` feeds the same `ctx` to both the Pydantic model (JSON) and the template. A key like `is_admin`/`avatar_url`/`is_self` holding a non-callable value shadows the global across the whole inheritance chain, turning `{% if is_admin(user) %}` into `False(user)` -> `TypeError`, a 500 that fires only for the branch that calls the global. Name viewer/permission flags distinctly (`viewer_is_admin`, not `is_admin`) in both schema and context.
|
||||
- **Project visibility (`is_private`) and read-only (`read_only`)** are owner-controlled flags on `projects`. Read access is gated by the single `content.can_view_project(project, user)` predicate at EVERY read surface - never re-implement the check inline. Predicate: `not is_private OR is_owner OR (is_admin AND owner is not an admin)` - a project hidden by a member stays visible to any admin, but one hidden by an admin is visible only to that owner admin. **Containers have their own, stricter isolation predicates** (`owns_instance`, `can_view_project_containers`, `can_view_instance`, `can_manage_instance`) - the primary administrator sees/manages every container; any other admin can VIEW others' containers only on public projects and can MANAGE only instances they own. Read-only is enforced as a single data-layer guard `project_files._guard_writable(project_uid)` at the top of every mutation entrypoint - add it to any NEW file-mutating function. Devii may flip read-only/visibility only after explicit confirmation (`CONFIRM_REQUIRED`). Full UI-level detail in `devplacepy/routers/projects/CLAUDE.md`.
|
||||
- **Deletions ALWAYS require confirmation:** `confirmation_error` gates EVERY content delete tool via `CONFIRM_REQUIRED` (`delete_post`, `delete_comment`, `delete_gist`, `delete_project`, `project_delete_file`, `delete_media`, `delete_attachment`, `admin_delete_news`, container delete, and any `container_exec` matching `dispatcher.DESTRUCTIVE_COMMAND`). The first call is refused; the agent must show the exact target then pass `confirm=true`. **Load-bearing: every confirmation-gated tool MUST also declare a `confirm` boolean param in its catalog spec** - schemas set `additionalProperties: false`, so a gated tool without a declared `confirm` param can never receive it and loops forever.
|
||||
|
||||
## Every user-generated surface is reportable by construction (hard rule)
|
||||
|
||||
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. A table that is genuinely private to its owner goes in `UNREPORTABLE_TABLES` **with its reason** instead. `tests/unit/database/moderation.py` computes the difference and fails the suite on anything unclassified, so report coverage is closed under future additions rather than remembered; the e2e coverage test enforces the third requirement.
|
||||
|
||||
The same rule keeps the untriggered app-store conditionals untriggered: **no social login, no payment path, no purchasable randomness, no advertising, and no cross-app tracking** may be introduced without also implementing the obligations each of them creates (Sign in with Apple, in-app purchase, odds disclosure, ad reporting, App Tracking Transparency). Full detail in `devplacepy/services/moderation/CLAUDE.md`.
|
||||
|
||||
## Modal pattern
|
||||
|
||||
`Application.js` `initModals()` toggles a `.visible` CSS class on `.modal-overlay`; the CSS rule `.modal-overlay.visible { display: flex; }` handles visibility. Triggers usually have `href="#"`, so call `e.preventDefault()`. `.modal-close` is wired generically - no inline JS needed. Full modal/partial/CDN detail in `devplacepy/templates/CLAUDE.md`.
|
||||
@ -344,10 +333,9 @@ A new public read almost always needs all four. The cardinal failure mode is cha
|
||||
2. **Data layer.** `database/` for query/batch helpers (never inline N+1 loops - use `get_users_by_uids`, `build_pagination`, `_in_clause`; guard raw SQL with `if "table" in db.tables`). `models.py` for the Pydantic `Form` input model. `schemas/` for the `*Out` JSON response model - every context key a JSON route exposes via `respond(..., model=XOut)` MUST exist on `XOut` or it is silently dropped. Schema auto-syncs via `dataset`; add indexes in `init_db()` with `CREATE INDEX IF NOT EXISTS`.
|
||||
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.
|
||||
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.
|
||||
5. **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. **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).
|
||||
7. **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.
|
||||
|
||||
Failures at any implementation step block the workflow - never skip a failed step.
|
||||
|
||||
|
||||
24
README.md
24
README.md
@ -15,8 +15,6 @@ make test-headed # same tests in visible browser
|
||||
|
||||
Open `http://localhost:10500`.
|
||||
|
||||
A measured structural overview of the whole application, fifteen Mermaid diagrams covering deployment, request pipeline, URL surface, data layer, AI plane, containers and the real-time plane, is in [ARCHITECTURE.md](ARCHITECTURE.md).
|
||||
|
||||
PDF export (DeepSearch reports, via weasyprint) needs the Pango text stack installed at system level. The production image installs it; on a development host install it once:
|
||||
|
||||
```bash
|
||||
@ -87,10 +85,7 @@ devplacepy/
|
||||
| `/bookmarks` | Save/unsave content; `/bookmarks/saved` personal list |
|
||||
| `/polls` | Vote on post-attached polls |
|
||||
| `/follow` | Follow/unfollow users |
|
||||
| `/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 |
|
||||
| `/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 |
|
||||
| `/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 |
|
||||
| `/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 |
|
||||
@ -206,20 +201,6 @@ The farm refreshes live over the pub/sub bus (a watered build appears on the own
|
||||
|
||||
XP awards are wired at the existing content-creation, vote, and follow hook points in the routers and centralized in `award_xp()` / `check_milestone_badges()` (`devplacepy/utils/`). Existing accounts have their XP and levels backfilled once from prior activity at startup (`init_db()`).
|
||||
|
||||
## Trust and safety
|
||||
|
||||
DevPlace is open in the sense that it does not editorialise technical opinion. It enforces a short, fixed list of prohibited categories, published as the [Community Guidelines](/docs/community-guidelines.html), and it has the machinery to make that enforcement real.
|
||||
|
||||
- **Automated filtering at post time.** Every user-authored text passes a classifier at the five places content is created or changed (content creation, content editing, comments, direct messages, profile and signup fields), covering every surface with no per-route code. The default mode is `review`, not `block`: a match is published **and** raises a report for a human, because a developer platform discusses exploits, malware analysis and violent subject matter as its work and a machine that suppressed that would destroy the product. Only sexual and exploitative content is refused outright. Classification failure falls back to review, never to publication. Tunable live at `/admin/settings` (`moderation_filter_mode`: `off`, `label`, `review`, `block`).
|
||||
- **A report control on every surface.** Posts, comments, gists, projects, project files, news, uploads, direct messages, quizzes, polls, awards, profiles, issues, workspaces and assistant output are all reportable through one polymorphic endpoint keyed on `(target_type, target_uid)`, from one dialog, with one reason list. Reports are private to the reporter; the reported person is never told who filed.
|
||||
- **One queue with a published response window.** `/admin/moderation` is worked oldest-open-first and carries a badge showing the age of the oldest unresolved report against `moderation_sla_hours` (default 24), so a breach is visible rather than assumed. Resolution is a single atomic conditional update: two administrators deciding at once produce exactly one decision, the second gets a 409.
|
||||
- **Real enforcement, with a statement of reasons.** Remove or restore content, warn, suspend for a stated period, ban, lift, dismiss, or escalate. Every decision writes a permanent `moderation_actions` row plus an audit event, and tells the affected user what was decided and why. A suspended account can still read, still see why, still report, and still delete itself; it cannot create. A junior administrator can never action a senior one.
|
||||
- **Blocking**, unchanged and independent of all of the above, is now reachable from the content itself as well as from a profile.
|
||||
- **Age and maturity.** Signup collects a date of birth, derives an age band, and **discards the date**; accounts below `moderation_minimum_age` (default 16) are refused. Content labelled mature is hidden behind an interstitial until the viewer explicitly opts in, and the reveal is never offered to a minor age band for restricted content.
|
||||
- **Consent.** Five versioned, independently withdrawable consents (`terms`, `privacy`, `ai_third_party`, `activity_recording`, `container_credentials`) with full history, managed at `/profile/{username}?tab=privacy` and changeable **only by the account holder** - an administrator reads the record but never grants or withdraws on someone else's behalf. **No content is sent to a third-party AI provider without `ai_third_party` consent**, enforced once at the gateway; the per-feature AI toggles remain as preferences subordinate to it. Withdrawing `activity_recording` stops presence recording. While recording is on, an indicator says so on every page. `container_credentials` is what lets software another member runs in a container receive your API key; running your own container never asks.
|
||||
- **Account deletion.** Self-service at `/profile/{username}/delete`, reauthenticated with the account password. Sessions and tokens are revoked, the profile is anonymised immediately, and all content is removed under one deletion event that stays restorable for `account_deletion_grace_hours` (default 24) before `devplace accounts prune` (and the Moderation housekeeping service) purges it permanently. The devRant `DELETE /api/users/me` routes into the same cascade.
|
||||
- **Legal pages**: [Terms of Service](/docs/terms.html), [Community Guidelines](/docs/community-guidelines.html), [Privacy Policy](/docs/privacy.html), [How moderation works](/docs/content-moderation.html), [Notice and takedown](/docs/intellectual-property.html) and [Contact](/docs/contact.html). All six are indexed in the sitemap and reachable from the docs index; the footer of every page links Terms, Privacy, Community Guidelines and Contact. Contact details come from the `contact_email`, `contact_phone` and `contact_address` settings, so the in-product page and any app-store trader declaration have one source of truth.
|
||||
|
||||
## Vibe coding (Alpha, admin only)
|
||||
|
||||
Build software by talking to an AI agent instead of typing every line. Create a project for storage, attach a container to it (the shared `ppy` image, your files mounted at `/app`), start it, and open a terminal. The whole flow is drivable conversationally through Devii. Inside every container three agents ship preinstalled and run on **your own API key**, so all AI usage is metered to your account: **DevPlace Code (`dpc`)**, a coding agent in the same class as Claude Code; **`botje.py`**, a plug-and-play DevPlace bot you can copy and customise; and **`pagent`**, a minimal zero-dependency agent. Each container is launched with `DEVPLACE_BASE_URL`, `DEVPLACE_OPENAI_URL`, `DEVPLACE_API_KEY`, `DEVPLACE_USER_UID`, `DEVPLACE_CONTAINER_NAME`, `DEVPLACE_CONTAINER_UID`, and `DEVPLACE_INGRESS_URL` already set. Publish a container port to a public URL at `/p/<slug>` by setting an `ingress_slug` and `ingress_port` (ask Devii to do it at create time). The feature is in **Alpha** and currently limited to administrators; the full walkthrough, including a tutorial that vibes a web app and puts it online, is at `/docs/getting-started-vibing.html`.
|
||||
@ -246,7 +227,6 @@ The log is **administrator-only**. `/admin/audit-log` is a paginated, filterable
|
||||
| `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) |
|
||||
| `DEVPLACE_PRESENCE_ONLINE_MARGIN_SECONDS` | `20` | Grace margin before an online user drops to offline (hysteresis): online at the timeout, offline only after timeout + this. Prevents online/offline flicker for users hovering at the boundary |
|
||||
| `DEVPLACE_PRESENCE_TRACK_LIMIT` | `500` | Size of the online set the presence relay tracks and publishes on `public.presence.roster`. That one set is the single source of truth behind every avatar presence dot on every page; `DEVPLACE_PRESENCE_ONLINE_LIMIT` only caps how many of them the feed's Online now panel displays |
|
||||
|
||||
### Runtime settings
|
||||
|
||||
@ -437,7 +417,7 @@ and its full configuration are documented automatically - including future servi
|
||||
|
||||
### 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, and `pagent` at `/usr/bin/pagent.py` 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.
|
||||
|
||||
**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).
|
||||
|
||||
|
||||
1
apple.md
1
apple.md
@ -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
applechanges.md
230
applechanges.md
@ -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
applecomp.md
435
applecomp.md
@ -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
appleimpl.md
583
appleimpl.md
@ -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.
|
||||
@ -2,7 +2,6 @@
|
||||
|
||||
from devplacepy.cli.main import main, build_parser
|
||||
from devplacepy.cli._shared import _audit_cli
|
||||
from devplacepy.cli.accounts import cmd_accounts_pending, cmd_accounts_prune
|
||||
from devplacepy.cli.roles import cmd_role_get, cmd_role_set
|
||||
from devplacepy.cli.apikeys import cmd_apikey_get, cmd_apikey_reset, cmd_apikey_backfill
|
||||
from devplacepy.cli.tokens import (
|
||||
@ -50,8 +49,6 @@ __all__ = [
|
||||
"main",
|
||||
"build_parser",
|
||||
"_audit_cli",
|
||||
"cmd_accounts_pending",
|
||||
"cmd_accounts_prune",
|
||||
"cmd_role_get",
|
||||
"cmd_role_set",
|
||||
"cmd_apikey_get",
|
||||
|
||||
@ -1,46 +0,0 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from devplacepy.cli._shared import _audit_cli
|
||||
|
||||
|
||||
def cmd_accounts_prune(args):
|
||||
from devplacepy.services.moderation import deletion
|
||||
|
||||
pending = deletion.due_purges()
|
||||
if args.dry_run:
|
||||
for row in pending:
|
||||
print(f"{row['uid']} deleted at {row['deletion_requested_at']}")
|
||||
print(f"{len(pending)} account(s) due for purge")
|
||||
return
|
||||
purged = deletion.purge_due()
|
||||
_audit_cli(
|
||||
"cli.accounts.prune",
|
||||
f"CLI purged {purged} deleted account(s) past the grace window",
|
||||
metadata={"count": purged},
|
||||
)
|
||||
print(f"Purged {purged} deleted account(s)")
|
||||
|
||||
|
||||
def cmd_accounts_pending(args):
|
||||
from devplacepy.services.moderation import deletion
|
||||
|
||||
pending = deletion.due_purges()
|
||||
for row in pending:
|
||||
print(f"{row['uid']}\t{row['deletion_requested_at']}")
|
||||
print(f"{len(pending)} account(s) past the {deletion.grace_hours()}h grace window")
|
||||
|
||||
|
||||
def register_accounts(subparsers):
|
||||
accounts = subparsers.add_parser("accounts", help="Deleted account management")
|
||||
accounts_sub = accounts.add_subparsers(title="action", dest="action")
|
||||
prune = accounts_sub.add_parser(
|
||||
"prune", help="Permanently purge accounts past the deletion grace window"
|
||||
)
|
||||
prune.add_argument(
|
||||
"--dry-run", action="store_true", help="List what would be purged and exit"
|
||||
)
|
||||
prune.set_defaults(func=cmd_accounts_prune)
|
||||
pending = accounts_sub.add_parser(
|
||||
"pending", help="List deleted accounts awaiting their purge"
|
||||
)
|
||||
pending.set_defaults(func=cmd_accounts_pending)
|
||||
@ -2,7 +2,6 @@
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
from devplacepy.cli.accounts import register_accounts
|
||||
from devplacepy.cli.roles import register_roles
|
||||
from devplacepy.cli.apikeys import register_apikeys
|
||||
from devplacepy.cli.tokens import register_tokens
|
||||
@ -37,7 +36,6 @@ def build_parser():
|
||||
register_quiz(sub)
|
||||
register_gateway(sub)
|
||||
register_messaging(sub)
|
||||
register_accounts(sub)
|
||||
|
||||
return parser
|
||||
|
||||
|
||||
@ -16,7 +16,6 @@ UPLOADS_DIR = DATA_DIR / "uploads"
|
||||
ATTACHMENTS_DIR = UPLOADS_DIR / "attachments"
|
||||
PROJECT_FILES_DIR = UPLOADS_DIR / "project_files"
|
||||
CONTAINER_WORKSPACES_DIR = DATA_DIR / "container_workspaces"
|
||||
WORKSPACE_STATE_DIR = DATA_DIR / "workspace_state"
|
||||
ZIPS_DIR = DATA_DIR / "zips"
|
||||
ZIP_STAGING_DIR = DATA_DIR / "zip_staging"
|
||||
FORK_STAGING_DIR = DATA_DIR / "fork_staging"
|
||||
@ -51,7 +50,6 @@ SITE_URL = environ.get("DEVPLACE_SITE_URL", "").rstrip("/")
|
||||
PRESENCE_TIMEOUT_SECONDS = int(environ.get("DEVPLACE_PRESENCE_TIMEOUT_SECONDS", "60"))
|
||||
PRESENCE_WRITE_SECONDS = max(1, PRESENCE_TIMEOUT_SECONDS // 2)
|
||||
PRESENCE_ONLINE_LIMIT = int(environ.get("DEVPLACE_PRESENCE_ONLINE_LIMIT", "30"))
|
||||
PRESENCE_TRACK_LIMIT = int(environ.get("DEVPLACE_PRESENCE_TRACK_LIMIT", "500"))
|
||||
PRESENCE_ONLINE_MARGIN_SECONDS = int(
|
||||
environ.get("DEVPLACE_PRESENCE_ONLINE_MARGIN_SECONDS", "20")
|
||||
)
|
||||
@ -108,12 +106,6 @@ INIT_LOCK_FILE = LOCKS_DIR / "devplace-init.lock"
|
||||
CONTAINER_IMAGE = environ.get("DEVPLACE_CONTAINER_IMAGE", "ppy:latest")
|
||||
CONTAINER_PROXY_HOST = environ.get("DEVPLACE_CONTAINER_PROXY_HOST", "").strip()
|
||||
|
||||
WORKSPACE_TUNNEL_DOMAIN = environ.get(
|
||||
"DEVPLACE_WORKSPACE_TUNNEL_DOMAIN", "tunnel.pravda.education"
|
||||
).strip()
|
||||
WORKSPACE_ACTIVITY_WRITE_SECONDS = 30
|
||||
WORKSPACE_METRICS_RING = 720
|
||||
|
||||
VAPID_PRIVATE_KEY_FILE = KEYS_DIR / "notification-private.pem"
|
||||
VAPID_PRIVATE_KEY_PKCS8_FILE = KEYS_DIR / "notification-private.pkcs8.pem"
|
||||
VAPID_PUBLIC_KEY_FILE = KEYS_DIR / "notification-public.pem"
|
||||
@ -125,7 +117,6 @@ DATA_PATHS: dict[str, Path] = {
|
||||
"attachments": ATTACHMENTS_DIR,
|
||||
"project_files": PROJECT_FILES_DIR,
|
||||
"container_workspaces": CONTAINER_WORKSPACES_DIR,
|
||||
"workspace_state": WORKSPACE_STATE_DIR,
|
||||
"zips": ZIPS_DIR,
|
||||
"zip_staging": ZIP_STAGING_DIR,
|
||||
"fork_staging": FORK_STAGING_DIR,
|
||||
|
||||
@ -31,11 +31,6 @@ from devplacepy.database import (
|
||||
soft_delete_engagement,
|
||||
soft_delete_fork_relations,
|
||||
load_comments,
|
||||
band_allows_mature,
|
||||
band_allows_restricted,
|
||||
get_maturity,
|
||||
get_maturity_by_targets,
|
||||
get_int_setting,
|
||||
_now_iso,
|
||||
db,
|
||||
)
|
||||
@ -56,11 +51,6 @@ from devplacepy.services.audit import record as audit
|
||||
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
|
||||
from devplacepy.services.moderation.screening import (
|
||||
record as record_screening,
|
||||
refuse_if_blocked,
|
||||
screen_fields,
|
||||
)
|
||||
|
||||
CREATE_METADATA_KEYS = ("project_type", "is_private", "language", "topic", "status")
|
||||
|
||||
@ -89,30 +79,6 @@ def is_owner(item: dict | None, user: dict | None) -> bool:
|
||||
return bool(item and user and item["user_uid"] == user["uid"])
|
||||
|
||||
|
||||
def mature_hidden_by_default() -> bool:
|
||||
return get_int_setting("moderation_mature_default_hidden", 1) != 0
|
||||
|
||||
|
||||
def maturity_hidden(level: str | None, user: dict | None) -> bool:
|
||||
if not level or level == "general":
|
||||
return False
|
||||
if not mature_hidden_by_default():
|
||||
return False
|
||||
if not user:
|
||||
return True
|
||||
band = user.get("age_band") or "adult"
|
||||
allowed = (
|
||||
band_allows_restricted(band) if level == "restricted" else band_allows_mature(band)
|
||||
)
|
||||
return not (allowed and bool(user.get("mature_opt_in")))
|
||||
|
||||
|
||||
def is_suspended(user: dict | None) -> bool:
|
||||
from devplacepy.database import suspension_active
|
||||
|
||||
return suspension_active(user)
|
||||
|
||||
|
||||
def _owner_is_admin(project: dict) -> bool:
|
||||
owner_uid = project.get("user_uid")
|
||||
owner = get_users_by_uids([owner_uid]).get(owner_uid) if owner_uid else None
|
||||
@ -170,45 +136,6 @@ def can_manage_instance(
|
||||
return is_primary_admin(user) or owns_instance(instance, project, user)
|
||||
|
||||
|
||||
def workspaces_enabled() -> bool:
|
||||
from devplacepy.database import get_setting
|
||||
|
||||
return get_setting("workspace_enabled", "0") == "1"
|
||||
|
||||
|
||||
def can_open_workspace(project: dict | None, user: dict | None) -> bool:
|
||||
if not project or not user or not user.get("uid"):
|
||||
return False
|
||||
if not workspaces_enabled():
|
||||
return False
|
||||
return is_owner(project, user) or is_admin(user)
|
||||
|
||||
|
||||
def owns_workspace(instance: dict | None, user: dict | None) -> bool:
|
||||
if not instance or not user:
|
||||
return False
|
||||
uid = user.get("uid")
|
||||
if not uid:
|
||||
return False
|
||||
return instance.get("workspace_owner_uid") == uid
|
||||
|
||||
|
||||
def can_manage_workspace(
|
||||
instance: dict | None, project: dict | None, user: dict | None
|
||||
) -> bool:
|
||||
if not instance or not user:
|
||||
return False
|
||||
if owns_workspace(instance, user):
|
||||
return True
|
||||
return can_manage_instance(instance, project, user)
|
||||
|
||||
|
||||
def can_manage_tunnel(
|
||||
instance: dict | None, project: dict | None, user: dict | None
|
||||
) -> bool:
|
||||
return can_manage_workspace(instance, project, user)
|
||||
|
||||
|
||||
def canonical_redirect(
|
||||
area: str, item: dict, requested: str
|
||||
) -> RedirectResponse | None:
|
||||
@ -240,8 +167,6 @@ def create_content_item(
|
||||
attachment_uids: list | None,
|
||||
request=None,
|
||||
) -> tuple[str, str]:
|
||||
screening = screen_fields(table_name, fields)
|
||||
refuse_if_blocked(screening)
|
||||
uid = generate_uid()
|
||||
slug = make_combined_slug(slug_source, uid)
|
||||
get_table(table_name).insert(
|
||||
@ -287,13 +212,6 @@ def create_content_item(
|
||||
metadata=metadata or None,
|
||||
links=links,
|
||||
)
|
||||
record_screening(
|
||||
screening,
|
||||
target_type=target_type,
|
||||
target_uid=uid,
|
||||
actor_uid=user["uid"],
|
||||
request=request,
|
||||
)
|
||||
schedule_correction(user, table_name, uid, request)
|
||||
schedule_modification(user, table_name, uid, request)
|
||||
schedule_seo_meta_for_table(table_name, uid)
|
||||
@ -410,8 +328,6 @@ def create_comment_record(
|
||||
parent_uid: str | None = None,
|
||||
attachment_uids: list | None = None,
|
||||
) -> tuple[str, str]:
|
||||
screening = screen_fields("comments", {"content": content})
|
||||
refuse_if_blocked(screening)
|
||||
comment_uid = generate_uid()
|
||||
redirect_url = resolve_object_url(target_type, target_uid)
|
||||
insert = {
|
||||
@ -462,13 +378,6 @@ def create_comment_record(
|
||||
)
|
||||
|
||||
create_mention_notifications(content, user["uid"], comment_url)
|
||||
record_screening(
|
||||
screening,
|
||||
target_type="comment",
|
||||
target_uid=comment_uid,
|
||||
actor_uid=user["uid"],
|
||||
request=request,
|
||||
)
|
||||
schedule_correction(user, "comments", comment_uid, request)
|
||||
schedule_modification(user, "comments", comment_uid, request)
|
||||
logger.info(f"Comment by {user['username']} on {target_type} {target_uid}")
|
||||
@ -493,19 +402,10 @@ def create_comment_record(
|
||||
def edit_comment_record(request, user: dict, comment: dict, content: str) -> str:
|
||||
target_type = comment.get("target_type", "post")
|
||||
target_uid = comment.get("target_uid") or comment.get("post_uid", "")
|
||||
screening = screen_fields("comments", {"content": content})
|
||||
refuse_if_blocked(screening)
|
||||
updated_at = datetime.now(timezone.utc).isoformat()
|
||||
get_table("comments").update(
|
||||
{"uid": comment["uid"], "content": content, "updated_at": updated_at}, ["uid"]
|
||||
)
|
||||
record_screening(
|
||||
screening,
|
||||
target_type="comment",
|
||||
target_uid=comment["uid"],
|
||||
actor_uid=user["uid"],
|
||||
request=request,
|
||||
)
|
||||
schedule_correction(user, "comments", comment["uid"], request)
|
||||
schedule_modification(user, "comments", comment["uid"], request)
|
||||
logger.info(f"Comment {comment['uid']} edited by {user['username']}")
|
||||
@ -628,7 +528,6 @@ def detail_context(
|
||||
"bookmarked": detail.get("bookmarked", False),
|
||||
"poll": detail.get("poll"),
|
||||
"project_link": detail.get("project_link"),
|
||||
"maturity": detail.get("maturity", "general"),
|
||||
}
|
||||
if extra:
|
||||
context.update(extra)
|
||||
@ -663,20 +562,11 @@ def edit_content_item(
|
||||
if wants_json(request):
|
||||
return json_error(403, "Not allowed")
|
||||
return RedirectResponse(url=redirect_fail, status_code=302)
|
||||
screening = screen_fields(table_name, update_fields)
|
||||
refuse_if_blocked(screening)
|
||||
update_fields = {
|
||||
**update_fields,
|
||||
"updated_at": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
table.update({"uid": item["uid"], **update_fields}, ["uid"])
|
||||
record_screening(
|
||||
screening,
|
||||
target_type=kind,
|
||||
target_uid=item["uid"],
|
||||
actor_uid=user["uid"],
|
||||
request=request,
|
||||
)
|
||||
schedule_correction(user, table_name, item["uid"], request)
|
||||
schedule_modification(user, table_name, item["uid"], request)
|
||||
schedule_seo_meta_for_table(table_name, item["uid"], regenerate=True)
|
||||
@ -818,7 +708,6 @@ def load_detail(
|
||||
"bookmarked": bookmarked,
|
||||
"poll": get_poll_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"],
|
||||
}
|
||||
|
||||
|
||||
@ -834,7 +723,6 @@ def enrich_items(
|
||||
user_votes = (
|
||||
get_user_votes(user["uid"], [item["uid"] for item in items]) if user else {}
|
||||
)
|
||||
maturity = get_maturity_by_targets(key, [item["uid"] for item in items])
|
||||
enriched = []
|
||||
for item in items:
|
||||
entry = {
|
||||
@ -842,7 +730,6 @@ def enrich_items(
|
||||
"author": authors.get(item["user_uid"]),
|
||||
"time_ago": time_ago(item[ts_field]),
|
||||
"my_vote": user_votes.get(item["uid"], 0),
|
||||
"maturity": maturity.get(item["uid"], {}).get("level", "general"),
|
||||
}
|
||||
for name, source in extra_maps.items():
|
||||
entry[name] = (
|
||||
|
||||
@ -28,8 +28,6 @@ _index(db, "news", "idx_news_status", ["status"]) # now safe - column exists
|
||||
|
||||
Do this with `get_table(name)` (NOT `if name in db.tables`): `get_table` + `create_column_by_example` creates the table if it is absent, so the schema is guaranteed regardless of which process or which insert touches the table first. Symptom to watch for: a page renders fine in isolation but shows empty data or 500s only after a CLI/maintenance test runs earlier in the suite (it created the table partially).
|
||||
|
||||
**This applies to `users` too, and an `if "users" not in db.tables: return` guard silently defeats it.** `backfill_api_keys()` is the `users` ensure-block (every non-signup column: `api_key`, `last_seen`, the AI correction/modifier settings, `avatar_seed`, `award_count`/`last_award_at`/`last_award_slug`/`last_award_uid`, ...). It used to early-return when `users` was absent, which is exactly the state on a **brand-new database**: `init_db()` runs before the first signup, so the ensure-block was skipped entirely, and the first `/auth/signup` then created `users` with only the columns of that INSERT. Every ensured-but-unwritten column was therefore missing from the long-running server's reflected metadata, so `users.find_one(...)` returned rows without them **for the whole process lifetime** - the feature reading them looked simply switched off (this is what made the awards tab, the prominent-award banner, and the avatar award badge invisible on a fresh DB, and it is invisible in production only because the columns happen to exist from an older boot). It now calls `get_table("users")` unconditionally, so the table is born with the full ensured column set. Never reintroduce a `db.tables` guard in front of a column-ensure block.
|
||||
|
||||
The `_index(...)` helper supports `where=` (partial) and `unique=` indexes; every table with a `uid` column gets a UNIQUE `idx_<table>_uid`, soft-delete tables get a PARTIAL `idx_<table>_trash` (`WHERE deleted_at IS NOT NULL`) and NEVER a bare `deleted_at` index (it mis-steers the planner on live reads), and "live newest-first" listings need a composite/live-partial index that carries the sort column (see "Indexing conventions" below). `init_db()` finishes with `ANALYZE`/`PRAGMA optimize`. Verify any index change with `EXPLAIN QUERY PLAN`.
|
||||
|
||||
**SQLite is synchronous by design and will never be made async (hard rule).** The `dataset`/SQLite layer is synchronous and is called directly inside the async route handlers. This is intentional and more than fast enough for this platform: the database is a local file tuned with WAL, `synchronous=NORMAL`, a 30s busy timeout, an 8MB page cache, and a 256MB mmap, so queries are sub-millisecond and never meaningfully stall the event loop. Do NOT convert the database layer to async, do NOT wrap DB calls in a threadpool/`run_in_executor`/`to_thread`, and do NOT raise the synchronous DB access as a performance, blocking-I/O, or "requests aren't fully async" concern. This is a settled, sophisticated design choice and is not open for revisiting.
|
||||
@ -146,23 +144,6 @@ The profile **Media** tab (`/profile/{username}?tab=media`, public) is a paginat
|
||||
- **Devii:** `list_media` (public) and `delete_media` (auth, in `CONFIRM_REQUIRED`) in the catalog.
|
||||
- **Docs visibility (deliberate):** members and guests must never be told this is a *soft* delete. The public prose page `docs/media-gallery.html` (General) and the member-facing `media-delete` API endpoint (Profiles group) describe deletion as a plain "remove" - no soft-delete, restore, trash, or purge language. All moderation mechanics live on the admin-only `docs/media-moderation` prose page (`admin: True`) and in the admin API group (`media-restore`, `admin-media`, `admin-media-purge`, all `auth="admin"`), which `docs_search` excludes from member results and `routers/docs/` package 404s for non-admins. Because `docs_search._strip` keeps the text *inside* `{% if %}` blocks, admin content must live on a separate `admin: True` page, never inline-gated on a public page (a public page may only carry an admin-gated *link*). The member `MediaItemOut` schema omits `deleted_at`; the admin-only `AdminMediaItemOut` adds it.
|
||||
|
||||
## Moderation, consent and maturity tables (`database/moderation.py`)
|
||||
|
||||
Four soft-deletable tables carry the trust-and-safety layer; the full subsystem is documented in `devplacepy/services/moderation/CLAUDE.md`.
|
||||
|
||||
| Table | Shape | Notes |
|
||||
|---|---|---|
|
||||
| `content_reports` | `(target_type, target_uid)` + `reporter_uid` + `owner_uid` | The one queue. `owner_uid` is denormalised at insert so the admin list never N+1s. Indexes `(status, created_at)` for the queue and SLA scan, `(target_type, target_uid)` for duplicate detection, `(reporter_uid)`, `(owner_uid)` |
|
||||
| `moderation_actions` | one row per moderator decision, linked to its report | Queryable moderation state with its own lifecycle - deliberately separate from the append-only audit log, the same way `workspace_flags` is |
|
||||
| `content_maturity` | `(target_type, target_uid)` -> `level` | Polymorphic age label. Read through the batch helper `get_maturity_by_targets`, never per row. **Absence of a row means `general`**, so nothing needed backfilling |
|
||||
| `user_consents` | `(owner_kind, owner_id, kind)` | Append-only in effect: withdrawing stamps `withdrawn_at` on the current row and inserts a new one, so the history is provable |
|
||||
|
||||
`REPORTABLE_TARGETS` (target type -> table) is the registry every consumer reads, exactly like `VOTABLE_TARGETS`. `UNREPORTABLE_TABLES` is its explicit counterpart: each entry names a soft-deletable table and **why** it carries no reportable content. `tests/unit/database/moderation.py` asserts the two partition `SOFT_DELETE_TABLES`, so a new user-generated table cannot be added without classifying it.
|
||||
|
||||
The `users` columns added alongside are ensured in `backfill_api_keys()` like every other non-signup column: `terms_version`, `terms_accepted_at`, `age_band`, `age_declared_at`, `mature_opt_in`, `suspended_until`, `suspension_reason`, `deletion_requested_at`. `mature_opt_in` is normalised from NULL to `0` in the same `with db:` block that fixes the AI-modifier defaults, because it is read as a flag. **No date of birth is ever stored** - only the derived `age_band`.
|
||||
|
||||
Two atomic conditional updates protect this data and must never become read-then-write: `queue.claim_open` (report resolution) and `deletion.claim_deletion` (the account-deletion cascade). The latter's precondition is `COALESCE(deletion_requested_at, '') = ''` because the column is SQL `NULL` on rows that predate it - the exact `NULL = 0` trap recorded above.
|
||||
|
||||
## Role-based visibility (generic + DRY)
|
||||
|
||||
- **One source of truth for role/visibility checks**, registered as Jinja globals in `templating.py` - never hand-roll `user.get('role') == 'Admin'` or `user['uid'] == x['user_uid']` in a template again:
|
||||
@ -170,7 +151,6 @@ Two atomic conditional updates protect this data and must never become read-then
|
||||
- `owns(item, user)` (= `content.is_owner`) - per-item ownership (e.g. each comment). Page-level detail templates keep using the `is_owner` **bool** passed in their context (post/gist/project/profile); do not call `is_owner(...)` as a function - that name is a context bool and shadows globals.
|
||||
- `is_self(user, uid)` - "is this me" (profile follow vs edit, leaderboard highlight).
|
||||
- `guest_disabled(user)` -> emits ` disabled aria-disabled="true" title="Log in to participate"` for guests (empty for members); `login_hint(user)` -> a small login link. Both return `Markup`.
|
||||
- **Account enabled/disabled is `database.is_account_active(row)` - never read `is_active` inline.** `is_active` is a nullable column, so a row written before it existed (or by any insert that omits it) holds SQL `NULL`, and the obvious `bool(row.get("is_active"))` reads that as *disabled*. `.get("is_active", True)` is no better: the default only applies when the key is **absent**, and a `SELECT *` row always has the key with value `None`. The predicate is `is_active is None or bool(is_active)` - unknown means active, only an explicit `0`/`False` disables. This is the same NULL-vs-0 trap as the `COALESCE` rule for atomic updates. It gates session auth, API-key auth, Basic auth, the login and access-token routes, the devRant token/auth paths, the admin enable/disable toggle, and `_can_hold_primary_admin`; every one of them goes through this single function. A NULL row previously could not log in at all, and the admin toggle could not disable it. (The `is_active` on a `gateway_models` row is a different table with its own semantics and is deliberately not routed through this.)
|
||||
- **Role values are stored capitalized:** `users.role` is exactly `"Admin"` or `"Member"` (first registered user is `"Admin"`, `auth.py`); `is_admin` compares `== "Admin"` case-sensitively. The CLI is the only lowercase surface (`devplace role set ... <member|admin>` writes `role.capitalize()`; `role get` prints `.lower()`). A lowercase role in the DB silently defeats every admin check - never write a raw lowercase role.
|
||||
- **The shadow rule generalizes beyond `is_owner` to ANY Jinja global** (`is_admin`, `avatar_url`, `format_date`, `is_self`, `owns`, `guest_disabled`): `respond(req, tmpl, ctx, model=XOut)` hands the **same** `ctx` to the Pydantic model and the template, and a context key shadows the same-named global across the whole `base.html` chain. A bool named `is_admin` in the context makes `base.html`'s `{% if is_admin(user) %}` raise `TypeError: 'bool' object is not callable` - a 500 that only fires for the branch invoking the global (logged-in users, not guests, which is why guest-only smoke tests miss it). Name viewer/permission flags distinctly (`viewer_is_admin`) in both schema and context. Real issue fixed on `/issues/{number}`; regression-guarded by `tests/api/issues/create.py::test_issue_detail_renders_for_{member,admin}` (they render the page as an authenticated Member/Admin and assert 200 + the admin-only control).
|
||||
- **Policy enforced everywhere:** guests see all non-admin content read-only with action controls **shown but disabled** (`guest_disabled` on vote/star/react/poll/bookmark/follow/comment submit; create FABs become `/auth/login` links via `.feed-fab.login-required`); members get full member actions; **role badges render only to admin viewers** (`{% if is_admin(user) %}` around every `*.role` label). Backend stays the real gate (`require_user`/`require_admin`).
|
||||
@ -209,15 +189,6 @@ Site settings are seeded on startup (`site_settings` table):
|
||||
| `maintenance_message` | scheduled-maintenance text | Body shown on the maintenance 503 page |
|
||||
| `customization_enabled` | `"1"` | When `"0"`, `custom_css_tag`/`custom_js_tag` inject nothing (feature off) |
|
||||
| `customization_js_enabled` | `"1"` | When `"0"`, custom CSS still serves but custom JS is suppressed |
|
||||
| `moderation_sla_hours` | `"24"` | The published moderation response window; the admin queue badge turns red past it |
|
||||
| `moderation_filter_mode` | `"review"` | `off`/`label`/`review`/`block` - how the content filter acts on a match |
|
||||
| `moderation_filter_review_score` | `"2"` | Rule score at which a match becomes a report rather than a label |
|
||||
| `moderation_minimum_age` | `"16"` | Signup floor; only the derived age band is stored |
|
||||
| `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` |
|
||||
| `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).
|
||||
|
||||
@ -4,7 +4,7 @@ from .core import dataset, logging, Path, or_, defaultdict, datetime, timedelta,
|
||||
from .core import refresh_snapshot, _local_cache_versions, _cache_version_cache, _cache_state_ready, _ensure_cache_state, get_cache_version, bump_cache_version, sync_local_cache, _index, _drop_index, _uid_index, get_table, _in_clause, _now_iso
|
||||
from .atomic import conditional_update_row
|
||||
from .settings import _settings_cache, get_setting, get_int_setting, set_setting, clear_settings_cache, internal_gateway_key
|
||||
from .users import get_users_by_uids, _admins_cache, invalidate_admins_cache, get_admin_uids, set_user_timezone, set_last_seen, get_online_users, get_primary_admin_uid, is_account_active, search_users_by_username
|
||||
from .users import get_users_by_uids, _admins_cache, invalidate_admins_cache, get_admin_uids, set_user_timezone, set_last_seen, get_online_users, get_primary_admin_uid, search_users_by_username
|
||||
from .relations import _relations_cache, get_user_relations, get_blocked_uids, get_muted_uids, get_silenced_uids, invalidate_user_relations
|
||||
from .pagination import PAGE_SIZE, paginate, interleave_by_author, paginate_diverse, get_user_post_count, clear_user_post_count, build_pagination
|
||||
from .soft_delete import SOFT_DELETE_TABLES, ensure_soft_delete_columns, soft_delete, soft_delete_in, restore, purge, list_deleted, count_deleted, restore_event, purge_event
|
||||
@ -36,43 +36,6 @@ from .forks import record_fork, get_fork_parent, count_forks, soft_delete_fork_r
|
||||
from .follows import get_follow_counts, get_follow_list, get_following_among
|
||||
from .deepsearch import _ds_now, create_deepsearch_session, update_deepsearch_session, get_deepsearch_session, add_deepsearch_message, get_deepsearch_messages, get_cached_deepsearch_url, upsert_deepsearch_url_cache
|
||||
from .ranking import VOTABLE_TARGETS, STAR_TARGETS, _authors_cache, _ranked_authors, _rank_map, get_top_authors, get_leaderboard, get_user_rank, get_user_stars, clear_user_stars, update_target_stars, soft_delete_engagement, delete_engagement, get_target_owner_uid
|
||||
from .moderation import (
|
||||
ACTIONS_TABLE,
|
||||
ADULT_AGE,
|
||||
AGE_BANDS,
|
||||
CONSENTS_TABLE,
|
||||
CONSENT_KINDS,
|
||||
CONSENT_STATES,
|
||||
MATURITY_LEVELS,
|
||||
MATURITY_SOURCES,
|
||||
MATURITY_TABLE,
|
||||
MATURITY_TARGETS,
|
||||
MODERATION_ACTIONS,
|
||||
MODERATION_TABLES,
|
||||
REPORTABLE_TARGETS,
|
||||
REPORTS_TABLE,
|
||||
REPORT_OPEN_STATUSES,
|
||||
REPORT_ORIGINS,
|
||||
REPORT_REASONS,
|
||||
REPORT_SEVERITIES,
|
||||
REPORT_STATUSES,
|
||||
SYSTEM_ACTOR,
|
||||
UNREPORTABLE_TABLES,
|
||||
age_band_for,
|
||||
band_allows_mature,
|
||||
band_allows_restricted,
|
||||
consent_granted,
|
||||
consent_state,
|
||||
get_maturity,
|
||||
get_maturity_by_targets,
|
||||
list_consents,
|
||||
minimum_age,
|
||||
report_reason_options,
|
||||
set_consent,
|
||||
set_maturity,
|
||||
suspension_active,
|
||||
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 .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
|
||||
@ -125,7 +88,6 @@ __all__ = [
|
||||
"set_last_seen",
|
||||
"get_online_users",
|
||||
"get_primary_admin_uid",
|
||||
"is_account_active",
|
||||
"search_users_by_username",
|
||||
"_relations_cache",
|
||||
"get_user_relations",
|
||||
@ -254,40 +216,6 @@ __all__ = [
|
||||
"soft_delete_engagement",
|
||||
"delete_engagement",
|
||||
"get_target_owner_uid",
|
||||
"ACTIONS_TABLE",
|
||||
"ADULT_AGE",
|
||||
"AGE_BANDS",
|
||||
"CONSENTS_TABLE",
|
||||
"CONSENT_KINDS",
|
||||
"CONSENT_STATES",
|
||||
"MATURITY_LEVELS",
|
||||
"MATURITY_SOURCES",
|
||||
"MATURITY_TABLE",
|
||||
"MATURITY_TARGETS",
|
||||
"MODERATION_ACTIONS",
|
||||
"MODERATION_TABLES",
|
||||
"REPORTABLE_TARGETS",
|
||||
"REPORTS_TABLE",
|
||||
"REPORT_OPEN_STATUSES",
|
||||
"REPORT_ORIGINS",
|
||||
"REPORT_REASONS",
|
||||
"REPORT_SEVERITIES",
|
||||
"REPORT_STATUSES",
|
||||
"SYSTEM_ACTOR",
|
||||
"UNREPORTABLE_TABLES",
|
||||
"age_band_for",
|
||||
"band_allows_mature",
|
||||
"band_allows_restricted",
|
||||
"consent_granted",
|
||||
"consent_state",
|
||||
"get_maturity",
|
||||
"get_maturity_by_targets",
|
||||
"list_consents",
|
||||
"report_reason_options",
|
||||
"set_consent",
|
||||
"set_maturity",
|
||||
"suspension_active",
|
||||
"years_between",
|
||||
"_drop_blocked",
|
||||
"_build_comment_items",
|
||||
"load_comments",
|
||||
|
||||
@ -56,44 +56,6 @@ def resolve_object_url(target_type: str, target_uid: str) -> str:
|
||||
receiver = get_table("users").find_one(uid=award.get("receiver_uid", ""))
|
||||
if receiver:
|
||||
return f"/profile/{receiver['username']}?tab=awards#award-{award.get('slug', '')}"
|
||||
return "/feed"
|
||||
if target_type == "user":
|
||||
person = get_table("users").find_one(uid=target_uid)
|
||||
return f"/profile/{person['username']}" if person else "/feed"
|
||||
if target_type == "project_file":
|
||||
node = get_table("project_files").find_one(uid=target_uid)
|
||||
if not node:
|
||||
return "/projects"
|
||||
project = get_table("projects").find_one(uid=node.get("project_uid", ""))
|
||||
if not project:
|
||||
return "/projects"
|
||||
slug = project.get("slug") or project["uid"]
|
||||
return f"/projects/{slug}/files?path={node.get('path', '')}"
|
||||
if target_type == "attachment":
|
||||
attachment = get_table("attachments").find_one(uid=target_uid)
|
||||
if not attachment:
|
||||
return "/feed"
|
||||
parent_type = attachment.get("target_type") or ""
|
||||
parent_uid = attachment.get("target_uid") or ""
|
||||
if parent_type and parent_uid:
|
||||
return resolve_object_url(parent_type, parent_uid)
|
||||
owner = get_table("users").find_one(uid=attachment.get("user_uid", ""))
|
||||
return f"/profile/{owner['username']}?tab=media" if owner else "/feed"
|
||||
if target_type == "message":
|
||||
message = get_table("messages").find_one(uid=target_uid)
|
||||
if not message:
|
||||
return "/messages"
|
||||
return f"/messages?with_uid={message.get('sender_uid', '')}"
|
||||
if target_type == "poll":
|
||||
poll = get_table("polls").find_one(uid=target_uid)
|
||||
if not poll:
|
||||
return "/feed"
|
||||
return resolve_object_url("post", poll.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"
|
||||
if target_type == "devii_output":
|
||||
return "/devii"
|
||||
return "/feed"
|
||||
|
||||
|
||||
|
||||
@ -1,328 +0,0 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from datetime import date, datetime, timezone
|
||||
|
||||
from .core import _in_clause, _now_iso, db, get_table
|
||||
from .settings import get_int_setting
|
||||
|
||||
|
||||
REPORTABLE_TARGETS: dict[str, str] = {
|
||||
"post": "posts",
|
||||
"comment": "comments",
|
||||
"gist": "gists",
|
||||
"project": "projects",
|
||||
"project_file": "project_files",
|
||||
"news": "news",
|
||||
"attachment": "attachments",
|
||||
"message": "messages",
|
||||
"quiz": "quizzes",
|
||||
"poll": "polls",
|
||||
"award": "awards",
|
||||
"user": "users",
|
||||
"issue": "issue_tickets",
|
||||
"workspace": "instances",
|
||||
"devii_output": "devii_conversations",
|
||||
}
|
||||
|
||||
|
||||
MATURITY_TARGETS: set[str] = {
|
||||
"post",
|
||||
"comment",
|
||||
"gist",
|
||||
"project",
|
||||
"news",
|
||||
"attachment",
|
||||
"quiz",
|
||||
}
|
||||
|
||||
|
||||
UNREPORTABLE_TABLES: dict[str, str] = {
|
||||
"news_images": "child rows of a reportable news article",
|
||||
"poll_options": "child rows of a reportable poll",
|
||||
"quiz_questions": "child rows of a reportable quiz",
|
||||
"quiz_options": "child rows of a reportable quiz",
|
||||
"tunnels": "child rows of a reportable workspace instance",
|
||||
"issue_comment_authors": "authorship index for reportable issue comments",
|
||||
"votes": "engagement counters, carry no authored content",
|
||||
"reactions": "engagement counters, carry no authored content",
|
||||
"bookmarks": "private to the owner",
|
||||
"follows": "relationship rows, carry no authored content",
|
||||
"poll_votes": "private ballots",
|
||||
"quiz_attempts": "private to the participant",
|
||||
"quiz_answers": "private to the participant",
|
||||
"sessions": "authentication state",
|
||||
"access_tokens": "authentication state",
|
||||
"devrant_tokens": "authentication state",
|
||||
"user_relations": "private block and mute lists",
|
||||
"notification_preferences": "private to the owner",
|
||||
"user_customizations": "runs only in the owner's own browser",
|
||||
"devii_tasks": "private to the owner",
|
||||
"devii_lessons": "private to the owner",
|
||||
"devii_virtual_tools": "private to the owner",
|
||||
"deepsearch_sessions": "private to the owner",
|
||||
"deepsearch_messages": "private to the owner",
|
||||
"isslop_analyses": "generated from a public URL, not authored content",
|
||||
"email_accounts": "private mailbox credentials",
|
||||
"instance_schedules": "child rows of a reportable workspace instance",
|
||||
"workspace_flags": "moderation records, not authored content",
|
||||
"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",
|
||||
"user_consents": "private consent history of the account holder",
|
||||
"backup_schedules": "operator configuration",
|
||||
"project_forks": "lineage index for reportable projects",
|
||||
"seo_metadata": "generated metadata for reportable content",
|
||||
}
|
||||
|
||||
|
||||
REPORT_REASONS: dict[str, str] = {
|
||||
"hate": "Hate speech or discriminatory content",
|
||||
"violence": "Realistic violence or threats",
|
||||
"weapons": "Weapons or dangerous instructions",
|
||||
"sexual": "Sexual or pornographic content",
|
||||
"religious": "Content targeting religion or belief",
|
||||
"misinformation": "False or misleading information",
|
||||
"exploitative": "Content exploiting a person",
|
||||
"harassment": "Harassment or bullying",
|
||||
"spam": "Spam or unwanted promotion",
|
||||
"intellectual_property": "Copyright or trademark infringement",
|
||||
"self_harm": "Self-harm or suicide",
|
||||
"illegal": "Illegal activity",
|
||||
"other": "Something else",
|
||||
}
|
||||
|
||||
|
||||
def report_reason_options() -> list[dict[str, str]]:
|
||||
return [{"key": key, "label": label} for key, label in REPORT_REASONS.items()]
|
||||
|
||||
|
||||
REPORT_STATUSES: tuple[str, ...] = ("open", "acknowledged", "actioned", "dismissed")
|
||||
REPORT_OPEN_STATUSES: tuple[str, ...] = ("open", "acknowledged")
|
||||
REPORT_SEVERITIES: tuple[str, ...] = ("info", "warn", "critical")
|
||||
REPORT_ORIGINS: tuple[str, ...] = ("member", "filter")
|
||||
|
||||
MODERATION_ACTIONS: tuple[str, ...] = (
|
||||
"remove_content",
|
||||
"restore_content",
|
||||
"warn",
|
||||
"suspend",
|
||||
"ban",
|
||||
"lift",
|
||||
"dismiss",
|
||||
"escalate",
|
||||
)
|
||||
|
||||
MATURITY_LEVELS: tuple[str, ...] = ("general", "mature", "restricted")
|
||||
MATURITY_SOURCES: tuple[str, ...] = ("author", "filter", "moderator")
|
||||
|
||||
CONSENT_KINDS: dict[str, str] = {
|
||||
"terms": "Terms of Service and Community Guidelines",
|
||||
"privacy": "Privacy Policy",
|
||||
"ai_third_party": "Processing of your content by a third-party AI provider",
|
||||
"activity_recording": "Recording of your presence and session activity",
|
||||
"container_credentials": (
|
||||
"Sharing your DevPlace credentials with software another member runs "
|
||||
"in a container"
|
||||
),
|
||||
}
|
||||
CONSENT_STATES: tuple[str, ...] = ("granted", "withdrawn")
|
||||
|
||||
AGE_BANDS: tuple[str, ...] = ("under_min", "13_15", "16_17", "adult")
|
||||
|
||||
ADULT_AGE = 18
|
||||
TEEN_AGE = 16
|
||||
YOUNG_TEEN_AGE = 13
|
||||
|
||||
MINIMUM_AGE_FLOOR = YOUNG_TEEN_AGE
|
||||
DEFAULT_MINIMUM_AGE = TEEN_AGE
|
||||
|
||||
SYSTEM_ACTOR = "system"
|
||||
|
||||
REPORTS_TABLE = "content_reports"
|
||||
ACTIONS_TABLE = "moderation_actions"
|
||||
MATURITY_TABLE = "content_maturity"
|
||||
CONSENTS_TABLE = "user_consents"
|
||||
|
||||
MODERATION_TABLES: tuple[str, ...] = (
|
||||
REPORTS_TABLE,
|
||||
ACTIONS_TABLE,
|
||||
MATURITY_TABLE,
|
||||
CONSENTS_TABLE,
|
||||
)
|
||||
|
||||
|
||||
def years_between(born: date, today: date) -> int:
|
||||
years = today.year - born.year
|
||||
if (today.month, today.day) < (born.month, born.day):
|
||||
years -= 1
|
||||
return years
|
||||
|
||||
|
||||
def minimum_age() -> int:
|
||||
return max(
|
||||
MINIMUM_AGE_FLOOR,
|
||||
get_int_setting("moderation_minimum_age", DEFAULT_MINIMUM_AGE),
|
||||
)
|
||||
|
||||
|
||||
def age_band_for(age: int) -> str:
|
||||
if age >= ADULT_AGE:
|
||||
return "adult"
|
||||
if age >= TEEN_AGE:
|
||||
return "16_17"
|
||||
if age >= YOUNG_TEEN_AGE:
|
||||
return "13_15"
|
||||
return "under_min"
|
||||
|
||||
|
||||
def band_allows_mature(band: str) -> bool:
|
||||
return band == "adult"
|
||||
|
||||
|
||||
def band_allows_restricted(band: str) -> bool:
|
||||
return band == "adult"
|
||||
|
||||
|
||||
def get_maturity_by_targets(target_type: str, uids: list[str]) -> dict[str, dict]:
|
||||
uids = [uid for uid in (uids or []) if uid]
|
||||
if not uids or MATURITY_TABLE not in db.tables:
|
||||
return {}
|
||||
placeholders, params = _in_clause(uids)
|
||||
params["tt"] = target_type
|
||||
rows = db.query(
|
||||
f"SELECT target_uid, level, source FROM {MATURITY_TABLE} "
|
||||
f"WHERE target_type = :tt AND target_uid IN ({placeholders}) "
|
||||
f"AND deleted_at IS NULL",
|
||||
**params,
|
||||
)
|
||||
return {
|
||||
row["target_uid"]: {"level": row["level"], "source": row["source"]}
|
||||
for row in rows
|
||||
}
|
||||
|
||||
|
||||
def get_maturity(target_type: str, target_uid: str) -> dict:
|
||||
found = get_maturity_by_targets(target_type, [target_uid])
|
||||
return found.get(target_uid, {"level": "general", "source": ""})
|
||||
|
||||
|
||||
def set_maturity(
|
||||
target_type: str, target_uid: str, level: str, source: str, set_by: str
|
||||
) -> dict | None:
|
||||
if target_type not in MATURITY_TARGETS or level not in MATURITY_LEVELS:
|
||||
return None
|
||||
from devplacepy.utils import generate_uid
|
||||
|
||||
table = get_table(MATURITY_TABLE)
|
||||
existing = table.find_one(target_type=target_type, target_uid=target_uid)
|
||||
now = _now_iso()
|
||||
if existing:
|
||||
table.update(
|
||||
{
|
||||
"id": existing["id"],
|
||||
"level": level,
|
||||
"source": source,
|
||||
"set_by": set_by,
|
||||
"updated_at": now,
|
||||
"deleted_at": None,
|
||||
"deleted_by": None,
|
||||
},
|
||||
["id"],
|
||||
)
|
||||
return table.find_one(id=existing["id"])
|
||||
uid = generate_uid()
|
||||
table.insert(
|
||||
{
|
||||
"uid": uid,
|
||||
"target_type": target_type,
|
||||
"target_uid": target_uid,
|
||||
"level": level,
|
||||
"source": source,
|
||||
"set_by": set_by,
|
||||
"created_at": now,
|
||||
"updated_at": now,
|
||||
"deleted_at": None,
|
||||
"deleted_by": None,
|
||||
}
|
||||
)
|
||||
return table.find_one(uid=uid)
|
||||
|
||||
|
||||
def list_consents(owner_kind: str, owner_id: str) -> list[dict]:
|
||||
if not owner_id or CONSENTS_TABLE not in db.tables:
|
||||
return []
|
||||
return list(
|
||||
get_table(CONSENTS_TABLE).find(
|
||||
owner_kind=owner_kind,
|
||||
owner_id=owner_id,
|
||||
deleted_at=None,
|
||||
order_by=["-created_at"],
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def consent_state(owner_kind: str, owner_id: str, kind: str) -> dict | None:
|
||||
if not owner_id or CONSENTS_TABLE not in db.tables:
|
||||
return None
|
||||
rows = list(
|
||||
get_table(CONSENTS_TABLE).find(
|
||||
owner_kind=owner_kind,
|
||||
owner_id=owner_id,
|
||||
kind=kind,
|
||||
deleted_at=None,
|
||||
order_by=["-created_at", "-id"],
|
||||
_limit=1,
|
||||
)
|
||||
)
|
||||
return rows[0] if rows else None
|
||||
|
||||
|
||||
def consent_granted(owner_kind: str, owner_id: str, kind: str) -> bool:
|
||||
row = consent_state(owner_kind, owner_id, kind)
|
||||
return bool(row and row.get("state") == "granted")
|
||||
|
||||
|
||||
def set_consent(
|
||||
owner_kind: str, owner_id: str, kind: str, granted: bool, version: str = "1"
|
||||
) -> dict | None:
|
||||
if kind not in CONSENT_KINDS or not owner_id:
|
||||
return None
|
||||
from devplacepy.utils import generate_uid
|
||||
|
||||
table = get_table(CONSENTS_TABLE)
|
||||
now = _now_iso()
|
||||
current = consent_state(owner_kind, owner_id, kind)
|
||||
if current and not granted and current.get("state") == "granted":
|
||||
table.update({"id": current["id"], "withdrawn_at": now}, ["id"])
|
||||
uid = generate_uid()
|
||||
table.insert(
|
||||
{
|
||||
"uid": uid,
|
||||
"owner_kind": owner_kind,
|
||||
"owner_id": owner_id,
|
||||
"kind": kind,
|
||||
"version": version,
|
||||
"state": "granted" if granted else "withdrawn",
|
||||
"granted_at": now if granted else "",
|
||||
"withdrawn_at": "" if granted else now,
|
||||
"created_at": now,
|
||||
"deleted_at": None,
|
||||
"deleted_by": None,
|
||||
}
|
||||
)
|
||||
return table.find_one(uid=uid)
|
||||
|
||||
|
||||
def suspension_active(user: dict | None) -> bool:
|
||||
if not user:
|
||||
return False
|
||||
until = (user.get("suspended_until") or "").strip()
|
||||
if not until:
|
||||
return False
|
||||
try:
|
||||
expiry = datetime.fromisoformat(until)
|
||||
except ValueError:
|
||||
return False
|
||||
if expiry.tzinfo is None:
|
||||
expiry = expiry.replace(tzinfo=timezone.utc)
|
||||
return expiry > datetime.now(timezone.utc)
|
||||
@ -19,8 +19,6 @@ 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": "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)"},
|
||||
]
|
||||
|
||||
|
||||
348
devplacepy/database/remote.py
Normal file
348
devplacepy/database/remote.py
Normal file
@ -0,0 +1,348 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import inspect
|
||||
import os
|
||||
|
||||
import httpx
|
||||
|
||||
from devplacepy.cache import TTLCache
|
||||
from devplacepy_services.base.db_codec import (
|
||||
decode_value,
|
||||
encode_args,
|
||||
is_write,
|
||||
is_write_sql,
|
||||
)
|
||||
|
||||
_SERVICE_URL = os.environ.get("DEVPLACE_DB_SERVICE_URL", "http://127.0.0.1:10601").rstrip("/")
|
||||
_INTERNAL_KEY = os.environ.get("DEVPLACE_GATEWAY_INTERNAL_KEY", "").strip()
|
||||
_CLIENT: httpx.Client | None = None
|
||||
|
||||
# Section 7.3: settings reads tolerate up to 5s staleness. patch_module()
|
||||
# generically RPCs every devplacepy.database call, bypassing the local
|
||||
# TTL cache get_setting/get_int_setting had in-process - without this,
|
||||
# every settings read (rate limiting, maintenance mode, admin dashboards)
|
||||
# pays a full HTTP round trip to the database broker.
|
||||
_SETTINGS_CACHE_TTL_SECONDS = 5
|
||||
_SETTINGS_CACHE = TTLCache(ttl=_SETTINGS_CACHE_TTL_SECONDS, max_size=512)
|
||||
_CACHED_SETTINGS_FNS = frozenset({"get_setting", "get_int_setting"})
|
||||
|
||||
|
||||
def _service_url() -> str:
|
||||
return os.environ.get("DEVPLACE_DB_SERVICE_URL", _SERVICE_URL).rstrip("/")
|
||||
|
||||
|
||||
def _headers() -> dict[str, str]:
|
||||
headers: dict[str, str] = {}
|
||||
key = os.environ.get("DEVPLACE_GATEWAY_INTERNAL_KEY", _INTERNAL_KEY).strip()
|
||||
if key:
|
||||
headers["X-Internal-Key"] = key
|
||||
return headers
|
||||
|
||||
|
||||
def _client() -> httpx.Client:
|
||||
global _CLIENT
|
||||
if _CLIENT is None:
|
||||
_CLIENT = httpx.Client(timeout=30.0)
|
||||
return _CLIENT
|
||||
|
||||
|
||||
def _post(path: str, body: dict) -> object:
|
||||
response = _client().post(
|
||||
f"{_service_url()}/{path.lstrip('/')}",
|
||||
json=body,
|
||||
headers=_headers(),
|
||||
)
|
||||
if response.status_code >= 400:
|
||||
payload = response.json() if response.content else {}
|
||||
message = payload.get("error", "Database service request failed")
|
||||
raise RuntimeError(message)
|
||||
if not response.content:
|
||||
return None
|
||||
return decode_value(response.json())
|
||||
|
||||
|
||||
def _invoke_cached(fn_name: str, args, kwargs):
|
||||
cache_key = f"{fn_name}:{args!r}:{sorted(kwargs.items())!r}"
|
||||
cached = _SETTINGS_CACHE.get(cache_key)
|
||||
if cached is not None:
|
||||
return cached
|
||||
value = _invoke(fn_name, args, kwargs, write=False)
|
||||
_SETTINGS_CACHE.set(cache_key, value)
|
||||
return value
|
||||
|
||||
|
||||
def _invoke(fn_name: str, args, kwargs, *, write: bool = False):
|
||||
encoded_args, encoded_kwargs = encode_args(args, kwargs)
|
||||
payload = {
|
||||
"fn": fn_name,
|
||||
"args": encoded_args,
|
||||
"kwargs": encoded_kwargs,
|
||||
"write": write,
|
||||
}
|
||||
result = _post("internal/invoke", payload)
|
||||
if isinstance(result, dict) and "result" in result:
|
||||
return result["result"]
|
||||
return result
|
||||
|
||||
|
||||
class RemoteSearchClause:
|
||||
def __init__(self, term, fields, author_field=None):
|
||||
self.term = term.strip()
|
||||
self.fields = tuple(fields)
|
||||
self.author_field = author_field
|
||||
|
||||
|
||||
class RemoteUidInClause:
|
||||
def __init__(self, field, uids):
|
||||
self.field = field
|
||||
self.uids = frozenset(uids)
|
||||
|
||||
|
||||
class RemoteTable:
|
||||
def __init__(self, db: "RemoteDb", name: str) -> None:
|
||||
self._db = db
|
||||
self._name = name
|
||||
self._column_cache = None
|
||||
|
||||
def __getattr__(self, name: str):
|
||||
def caller(*args, **kwargs):
|
||||
return self._db._table_op(self._name, name, args, kwargs)
|
||||
|
||||
return caller
|
||||
|
||||
def has_column(self, name: str) -> bool:
|
||||
cache = self._column_cache
|
||||
if cache is None:
|
||||
sample = self.find(_limit=1)
|
||||
row = next(iter(sample), None)
|
||||
cache = set(row.keys()) if row else set()
|
||||
self._column_cache = cache
|
||||
return name in cache
|
||||
|
||||
def count(self, **kwargs):
|
||||
return self._db._table_op(self._name, "count", [], kwargs)
|
||||
|
||||
@property
|
||||
def table(self):
|
||||
return self
|
||||
|
||||
@property
|
||||
def exists(self) -> bool:
|
||||
return self._name in self._db.tables
|
||||
|
||||
class RemoteDb:
|
||||
def __init__(self) -> None:
|
||||
self._tables_cache: list[str] | None = None
|
||||
|
||||
@property
|
||||
def tables(self) -> list[str]:
|
||||
if self._tables_cache is None:
|
||||
result = _post("internal/db-op", {"op": "tables"})
|
||||
self._tables_cache = list(result or [])
|
||||
return self._tables_cache
|
||||
|
||||
def __getitem__(self, name: str) -> RemoteTable:
|
||||
return RemoteTable(self, name)
|
||||
|
||||
def query(self, sql: str, **params):
|
||||
encoded_args, encoded_kwargs = encode_args((sql,), params)
|
||||
result = _post(
|
||||
"internal/db-op",
|
||||
{
|
||||
"op": "query",
|
||||
"args": encoded_args,
|
||||
"kwargs": encoded_kwargs,
|
||||
"write": is_write_sql(sql),
|
||||
},
|
||||
)
|
||||
return result or []
|
||||
|
||||
def _table_op(self, table: str, method: str, args, kwargs, *, write: bool = False):
|
||||
encoded_args, encoded_kwargs = encode_args(args, kwargs)
|
||||
result = _post(
|
||||
"internal/db-op",
|
||||
{
|
||||
"op": "table_op",
|
||||
"table": table,
|
||||
"method": method,
|
||||
"args": encoded_args,
|
||||
"kwargs": encoded_kwargs,
|
||||
"write": write,
|
||||
},
|
||||
)
|
||||
if method in {"insert", "update", "delete"}:
|
||||
self._tables_cache = None
|
||||
return result
|
||||
|
||||
@property
|
||||
def executable(self):
|
||||
return self
|
||||
|
||||
@property
|
||||
def in_transaction(self) -> bool:
|
||||
return False
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc, tb):
|
||||
return False
|
||||
|
||||
|
||||
_LOCAL_REMOTE = frozenset(
|
||||
{
|
||||
"get_table",
|
||||
"refresh_snapshot",
|
||||
"_in_clause",
|
||||
"_now_iso",
|
||||
"text_search_clause",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _remote_text_search_clause(
|
||||
table, search, fields=("title", "description"), author_field=None
|
||||
):
|
||||
term = (search or "").strip()
|
||||
if not term:
|
||||
return None
|
||||
if type(table).__name__ == "RemoteTable":
|
||||
return RemoteSearchClause(term, fields, author_field)
|
||||
from devplacepy.database.content import text_search_clause as local_clause
|
||||
|
||||
return local_clause(table, search, fields, author_field=author_field)
|
||||
|
||||
|
||||
def _remote_get_table(name: str):
|
||||
import devplacepy.database.core as core
|
||||
|
||||
return core.db[name]
|
||||
|
||||
|
||||
def _remote_refresh_snapshot() -> None:
|
||||
return None
|
||||
|
||||
|
||||
def patch_module(module) -> None:
|
||||
import devplacepy.database as db_module
|
||||
|
||||
for name in db_module.__all__:
|
||||
if name in _LOCAL_REMOTE:
|
||||
continue
|
||||
target = getattr(module, name, None)
|
||||
if target is None or not callable(target):
|
||||
continue
|
||||
if inspect.isclass(target):
|
||||
continue
|
||||
|
||||
def make_wrapper(fn_name: str, fn_write: bool):
|
||||
if fn_name in _CACHED_SETTINGS_FNS:
|
||||
|
||||
def wrapper(*args, **kwargs):
|
||||
return _invoke_cached(fn_name, args, kwargs)
|
||||
|
||||
wrapper.__name__ = fn_name
|
||||
return wrapper
|
||||
|
||||
def wrapper(*args, **kwargs):
|
||||
return _invoke(fn_name, args, kwargs, write=fn_write)
|
||||
|
||||
wrapper.__name__ = fn_name
|
||||
return wrapper
|
||||
|
||||
setattr(module, name, make_wrapper(name, is_write(name)))
|
||||
|
||||
|
||||
def activate() -> None:
|
||||
import devplacepy.database.core as core
|
||||
|
||||
core.db = RemoteDb()
|
||||
import devplacepy.database as db_module
|
||||
|
||||
patch_module(db_module)
|
||||
for submodule_name in (
|
||||
"settings",
|
||||
"users",
|
||||
"relations",
|
||||
"pagination",
|
||||
"soft_delete",
|
||||
"engagement",
|
||||
"usage",
|
||||
"awards",
|
||||
"seo_meta",
|
||||
"activity",
|
||||
"customization",
|
||||
"email",
|
||||
"notifications",
|
||||
"forks",
|
||||
"follows",
|
||||
"deepsearch",
|
||||
"ranking",
|
||||
"comments",
|
||||
"content",
|
||||
"attachments_data",
|
||||
"stats",
|
||||
"schema",
|
||||
):
|
||||
try:
|
||||
submodule = __import__(
|
||||
f"devplacepy.database.{submodule_name}",
|
||||
fromlist=[submodule_name],
|
||||
)
|
||||
except ImportError:
|
||||
continue
|
||||
patch_module(submodule)
|
||||
for external_name in (
|
||||
"devplacepy.services.statistics.tracking",
|
||||
"devplacepy.services.base",
|
||||
"devplacepy.attachments",
|
||||
"devplacepy.project_files",
|
||||
):
|
||||
try:
|
||||
external = __import__(external_name, fromlist=[external_name.split(".")[-1]])
|
||||
except ImportError:
|
||||
continue
|
||||
if hasattr(external, "db"):
|
||||
external.db = RemoteDb()
|
||||
db_module.db = core.db
|
||||
db_module.get_table = _remote_get_table
|
||||
core.get_table = _remote_get_table
|
||||
db_module.refresh_snapshot = _remote_refresh_snapshot
|
||||
core.refresh_snapshot = _remote_refresh_snapshot
|
||||
db_module.text_search_clause = _remote_text_search_clause
|
||||
import devplacepy.database.content as content_module
|
||||
|
||||
content_module.text_search_clause = _remote_text_search_clause
|
||||
for submodule_name in (
|
||||
"settings",
|
||||
"users",
|
||||
"relations",
|
||||
"pagination",
|
||||
"soft_delete",
|
||||
"engagement",
|
||||
"usage",
|
||||
"awards",
|
||||
"seo_meta",
|
||||
"activity",
|
||||
"customization",
|
||||
"email",
|
||||
"notifications",
|
||||
"forks",
|
||||
"follows",
|
||||
"deepsearch",
|
||||
"ranking",
|
||||
"comments",
|
||||
"content",
|
||||
"attachments_data",
|
||||
"stats",
|
||||
"schema",
|
||||
):
|
||||
try:
|
||||
submodule = __import__(
|
||||
f"devplacepy.database.{submodule_name}",
|
||||
fromlist=[submodule_name],
|
||||
)
|
||||
except ImportError:
|
||||
continue
|
||||
if hasattr(submodule, "db"):
|
||||
submodule.db = core.db
|
||||
@ -277,7 +277,7 @@ def init_db():
|
||||
defaults = {
|
||||
"site_name": "DevPlace",
|
||||
"site_description": "The Developer Social Network",
|
||||
"site_tagline": "Track industry shifts. Discover bold releases. Share what you are building in an open environment built by developers, for developers.",
|
||||
"site_tagline": "Track industry shifts. Discover bold releases. Share what you are building in an open, uncensored environment.",
|
||||
}
|
||||
for key, value in defaults.items():
|
||||
existing = db["site_settings"].find_one(key=key)
|
||||
@ -558,9 +558,6 @@ def init_db():
|
||||
("slug", ""),
|
||||
("name", ""),
|
||||
("status", ""),
|
||||
("created_at", ""),
|
||||
("owner_uid", ""),
|
||||
("created_by", ""),
|
||||
("desired_state", ""),
|
||||
("container_id", ""),
|
||||
("ingress_slug", ""),
|
||||
@ -571,110 +568,10 @@ def init_db():
|
||||
("boot_language", "none"),
|
||||
("boot_script", ""),
|
||||
("start_on_boot", 0),
|
||||
("is_workspace", 0),
|
||||
("workspace_owner_uid", ""),
|
||||
("editor_port", 0),
|
||||
("editor_host_port", 0),
|
||||
("editor_password", ""),
|
||||
("tunnel_name", ""),
|
||||
("last_active_at", ""),
|
||||
("idle_warned_at", ""),
|
||||
("delete_warned_at", ""),
|
||||
("disk_bytes", 0),
|
||||
("disk_sampled_at", ""),
|
||||
("egress_bytes", 0),
|
||||
("request_count", 0),
|
||||
("flagged_at", ""),
|
||||
("flag_reason", ""),
|
||||
("suspended_at", ""),
|
||||
("suspended_by", ""),
|
||||
):
|
||||
if not instances.has_column(column):
|
||||
instances.create_column_by_example(column, example)
|
||||
|
||||
tunnels = get_table("tunnels")
|
||||
for column, example in (
|
||||
("uid", ""),
|
||||
("instance_uid", ""),
|
||||
("project_uid", ""),
|
||||
("user_uid", ""),
|
||||
("hostname", ""),
|
||||
("label", ""),
|
||||
("container_port", 0),
|
||||
("desired_state", "present"),
|
||||
("status", "pending"),
|
||||
("cert_status", ""),
|
||||
("cert_checked_at", ""),
|
||||
("request_count", 0),
|
||||
("bytes_out", 0),
|
||||
("last_request_at", ""),
|
||||
("last_error", ""),
|
||||
("last_synced_at", ""),
|
||||
("created_at", ""),
|
||||
("updated_at", ""),
|
||||
):
|
||||
if not tunnels.has_column(column):
|
||||
tunnels.create_column_by_example(column, example)
|
||||
|
||||
quota_rules = get_table("workspace_quota_rules")
|
||||
for column, example in (
|
||||
("uid", ""),
|
||||
("owner_kind", ""),
|
||||
("owner_id", ""),
|
||||
("label", ""),
|
||||
("max_workspaces", 0),
|
||||
("max_tunnels", 0),
|
||||
("disk_quota_mb", 0),
|
||||
("egress_quota_mb", 0),
|
||||
("idle_stop_minutes", 0),
|
||||
("retention_days", 0),
|
||||
("created_at", ""),
|
||||
("updated_at", ""),
|
||||
):
|
||||
if not quota_rules.has_column(column):
|
||||
quota_rules.create_column_by_example(column, example)
|
||||
|
||||
flags = get_table("workspace_flags")
|
||||
for column, example in (
|
||||
("uid", ""),
|
||||
("instance_uid", ""),
|
||||
("user_uid", ""),
|
||||
("kind", ""),
|
||||
("severity", "warn"),
|
||||
("detail", ""),
|
||||
("metric_value", 0.0),
|
||||
("threshold", 0.0),
|
||||
("status", "open"),
|
||||
("resolved_by", ""),
|
||||
("resolved_at", ""),
|
||||
("created_at", ""),
|
||||
("updated_at", ""),
|
||||
):
|
||||
if not flags.has_column(column):
|
||||
flags.create_column_by_example(column, example)
|
||||
|
||||
_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"])
|
||||
_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"])
|
||||
_index(
|
||||
db,
|
||||
"workspace_quota_rules",
|
||||
"idx_workspace_quota_owner",
|
||||
["owner_kind", "owner_id"],
|
||||
)
|
||||
_index(db, "workspace_flags", "idx_workspace_flags_open", ["status", "created_at"])
|
||||
_index(
|
||||
db,
|
||||
"workspace_flags",
|
||||
"idx_workspace_flags_instance",
|
||||
["instance_uid", "kind"],
|
||||
)
|
||||
_index(db, "workspace_flags", "idx_workspace_flags_user", ["user_uid"])
|
||||
|
||||
_index(db, "instances", "idx_instances_project", ["project_uid"])
|
||||
_index(db, "instances", "idx_instances_slug", ["slug"])
|
||||
_index(db, "instances", "idx_instances_name", ["name"])
|
||||
@ -1553,8 +1450,6 @@ def init_db():
|
||||
["user_uid", "created_at"],
|
||||
)
|
||||
|
||||
_ensure_moderation_tables()
|
||||
|
||||
for table in db.tables:
|
||||
_uid_index(db, table)
|
||||
|
||||
@ -1693,19 +1588,6 @@ def init_db():
|
||||
"outbound_proxy_url": "",
|
||||
"devii_lessons_max_per_owner": "500",
|
||||
"devii_lessons_max_age_days": "90",
|
||||
"moderation_sla_hours": "24",
|
||||
"moderation_filter_mode": "review",
|
||||
"moderation_filter_review_score": "2",
|
||||
"moderation_minimum_age": "16",
|
||||
"moderation_mature_default_hidden": "1",
|
||||
"contact_email": "",
|
||||
"contact_phone": "",
|
||||
"contact_address": "",
|
||||
"terms_version": "1",
|
||||
"privacy_version": "1",
|
||||
"guidelines_version": "1",
|
||||
"ai_third_party_provider": "",
|
||||
"account_deletion_grace_hours": "24",
|
||||
}
|
||||
for key, value in operational_defaults.items():
|
||||
existing = db["site_settings"].find_one(key=key)
|
||||
@ -1786,83 +1668,6 @@ def init_db():
|
||||
_refresh_query_planner_stats()
|
||||
|
||||
|
||||
MODERATION_COLUMNS: dict[str, tuple[tuple[str, object], ...]] = {
|
||||
"content_reports": (
|
||||
("uid", ""),
|
||||
("reporter_uid", ""),
|
||||
("target_type", ""),
|
||||
("target_uid", ""),
|
||||
("owner_uid", ""),
|
||||
("reason", ""),
|
||||
("detail", ""),
|
||||
("severity", "warn"),
|
||||
("status", "open"),
|
||||
("origin", "member"),
|
||||
("categories", ""),
|
||||
("resolved_by", ""),
|
||||
("resolved_at", ""),
|
||||
("created_at", ""),
|
||||
("updated_at", ""),
|
||||
),
|
||||
"moderation_actions": (
|
||||
("uid", ""),
|
||||
("report_uid", ""),
|
||||
("actor_uid", ""),
|
||||
("action", ""),
|
||||
("target_type", ""),
|
||||
("target_uid", ""),
|
||||
("subject_uid", ""),
|
||||
("reason", ""),
|
||||
("notes", ""),
|
||||
("expires_at", ""),
|
||||
("created_at", ""),
|
||||
),
|
||||
"content_maturity": (
|
||||
("uid", ""),
|
||||
("target_type", ""),
|
||||
("target_uid", ""),
|
||||
("level", "general"),
|
||||
("source", ""),
|
||||
("set_by", ""),
|
||||
("created_at", ""),
|
||||
("updated_at", ""),
|
||||
),
|
||||
"user_consents": (
|
||||
("uid", ""),
|
||||
("owner_kind", ""),
|
||||
("owner_id", ""),
|
||||
("kind", ""),
|
||||
("version", "1"),
|
||||
("state", ""),
|
||||
("granted_at", ""),
|
||||
("withdrawn_at", ""),
|
||||
("created_at", ""),
|
||||
),
|
||||
}
|
||||
|
||||
MODERATION_INDEXES: tuple[tuple[str, str, list[str]], ...] = (
|
||||
("content_reports", "idx_content_reports_queue", ["status", "created_at"]),
|
||||
("content_reports", "idx_content_reports_target", ["target_type", "target_uid"]),
|
||||
("content_reports", "idx_content_reports_reporter", ["reporter_uid"]),
|
||||
("content_reports", "idx_content_reports_owner", ["owner_uid"]),
|
||||
("moderation_actions", "idx_moderation_actions_report", ["report_uid"]),
|
||||
("moderation_actions", "idx_moderation_actions_subject", ["subject_uid"]),
|
||||
("moderation_actions", "idx_moderation_actions_created", ["created_at"]),
|
||||
("content_maturity", "idx_content_maturity_target", ["target_type", "target_uid"]),
|
||||
("user_consents", "idx_user_consents_owner", ["owner_kind", "owner_id", "kind"]),
|
||||
)
|
||||
|
||||
|
||||
def _ensure_moderation_tables() -> None:
|
||||
for table_name, columns in MODERATION_COLUMNS.items():
|
||||
table = get_table(table_name)
|
||||
for column, example in columns:
|
||||
if not table.has_column(column):
|
||||
table.create_column_by_example(column, example)
|
||||
for table_name, index_name, columns in MODERATION_INDEXES:
|
||||
_index(db, table_name, index_name, columns)
|
||||
|
||||
|
||||
def _refresh_query_planner_stats() -> None:
|
||||
try:
|
||||
has_stats = bool(
|
||||
@ -1915,7 +1720,9 @@ def migrate_ai_gateway_settings() -> None:
|
||||
|
||||
|
||||
def backfill_api_keys() -> int:
|
||||
users = get_table("users")
|
||||
if "users" not in db.tables:
|
||||
return 0
|
||||
users = db["users"]
|
||||
if not users.has_column("api_key"):
|
||||
users.create_column_by_example("api_key", "")
|
||||
if not users.has_column("created_at"):
|
||||
@ -1952,22 +1759,6 @@ def backfill_api_keys() -> int:
|
||||
users.create_column_by_example("last_award_slug", "")
|
||||
if not users.has_column("last_award_uid"):
|
||||
users.create_column_by_example("last_award_uid", "")
|
||||
if not users.has_column("terms_version"):
|
||||
users.create_column_by_example("terms_version", "")
|
||||
if not users.has_column("terms_accepted_at"):
|
||||
users.create_column_by_example("terms_accepted_at", "")
|
||||
if not users.has_column("age_band"):
|
||||
users.create_column_by_example("age_band", "")
|
||||
if not users.has_column("age_declared_at"):
|
||||
users.create_column_by_example("age_declared_at", "")
|
||||
if not users.has_column("mature_opt_in"):
|
||||
users.create_column_by_example("mature_opt_in", 0)
|
||||
if not users.has_column("suspended_until"):
|
||||
users.create_column_by_example("suspended_until", "")
|
||||
if not users.has_column("suspension_reason"):
|
||||
users.create_column_by_example("suspension_reason", "")
|
||||
if not users.has_column("deletion_requested_at"):
|
||||
users.create_column_by_example("deletion_requested_at", "")
|
||||
with db:
|
||||
db.query(
|
||||
"UPDATE users SET ai_modifier_enabled = 1 WHERE ai_modifier_enabled IS NULL"
|
||||
@ -1982,7 +1773,6 @@ def backfill_api_keys() -> int:
|
||||
"UPDATE users SET interactions_enabled = -1 "
|
||||
"WHERE interactions_enabled IS NULL"
|
||||
)
|
||||
db.query("UPDATE users SET mature_opt_in = 0 WHERE mature_opt_in IS NULL")
|
||||
import uuid_utils
|
||||
|
||||
updated = 0
|
||||
|
||||
@ -23,8 +23,6 @@ SOFT_DELETE_TABLES = [
|
||||
"sessions",
|
||||
"instances",
|
||||
"instance_schedules",
|
||||
"tunnels",
|
||||
"workspace_flags",
|
||||
"backup_schedules",
|
||||
"devii_conversations",
|
||||
"devii_tasks",
|
||||
@ -49,10 +47,6 @@ SOFT_DELETE_TABLES = [
|
||||
"quiz_options",
|
||||
"quiz_attempts",
|
||||
"quiz_answers",
|
||||
"content_reports",
|
||||
"moderation_actions",
|
||||
"content_maturity",
|
||||
"user_consents",
|
||||
]
|
||||
|
||||
|
||||
|
||||
@ -74,15 +74,13 @@ def get_online_users(cutoff_iso: str, limit: int = 30) -> list:
|
||||
)
|
||||
|
||||
|
||||
def is_account_active(row) -> bool:
|
||||
is_active = (row or {}).get("is_active")
|
||||
return is_active is None or bool(is_active)
|
||||
|
||||
|
||||
def _can_hold_primary_admin(row, tracks_active):
|
||||
if row.get("deleted_at"):
|
||||
return False
|
||||
return not tracks_active or is_account_active(row)
|
||||
if not tracks_active:
|
||||
return True
|
||||
is_active = row.get("is_active")
|
||||
return is_active is None or bool(is_active)
|
||||
|
||||
|
||||
def get_primary_admin_uid():
|
||||
|
||||
29
devplacepy/db_client.py
Normal file
29
devplacepy/db_client.py
Normal file
@ -0,0 +1,29 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import os
|
||||
|
||||
|
||||
def _activate() -> None:
|
||||
if os.environ.get("DEVPLACE_DB_SERVICE") == "1":
|
||||
return
|
||||
if os.environ.get("DEVPLACE_REMOTE_DB") == "1":
|
||||
from devplacepy.database.remote import activate
|
||||
|
||||
activate()
|
||||
|
||||
|
||||
_activate()
|
||||
|
||||
import devplacepy.database as _database
|
||||
|
||||
|
||||
def _remote_table(table) -> bool:
|
||||
return type(table).__name__ == "RemoteTable"
|
||||
|
||||
|
||||
def __getattr__(name: str):
|
||||
return getattr(_database, name)
|
||||
|
||||
|
||||
def __dir__():
|
||||
return sorted(name for name in dir(_database) if not name.startswith("_"))
|
||||
@ -8,12 +8,10 @@ from . import (
|
||||
content,
|
||||
profiles,
|
||||
messaging,
|
||||
moderation,
|
||||
notifications,
|
||||
uploads,
|
||||
project_files,
|
||||
containers,
|
||||
workspaces,
|
||||
tools,
|
||||
push,
|
||||
issues,
|
||||
@ -32,12 +30,10 @@ ORDERED_GROUPS = [
|
||||
content.GROUP,
|
||||
profiles.GROUP,
|
||||
messaging.GROUP,
|
||||
moderation.GROUP,
|
||||
notifications.GROUP,
|
||||
uploads.GROUP,
|
||||
project_files.GROUP,
|
||||
containers.GROUP,
|
||||
workspaces.GROUP,
|
||||
tools.GROUP,
|
||||
push.GROUP,
|
||||
issues.GROUP,
|
||||
|
||||
@ -50,13 +50,6 @@ as a `422` with the shape `{ "fields": {...}, "messages": [...] }`.
|
||||
field("email", "form", "string", True, "alice@example.com", "Email address; must be unique and contain an @."),
|
||||
field("password", "form", "string", True, "mysecret", "Password, 6+ characters."),
|
||||
field("confirm_password", "form", "string", True, "mysecret", "Must match password."),
|
||||
field("birth_date", "form", "string", True, "01/01/1990", "Date of birth, DD/MM/YYYY or YYYY-MM-DD. Only the derived age band is stored; the date is discarded."),
|
||||
field("accept_terms", "form", "enum", True, "1", "Acceptance of the Terms of Service and Community Guidelines.", ["1"]),
|
||||
],
|
||||
notes=[
|
||||
"Signup is refused below the platform minimum age (`moderation_minimum_age`).",
|
||||
"Accepting records the terms, privacy and activity-recording consents; "
|
||||
"third-party AI processing stays off until it is granted separately.",
|
||||
],
|
||||
),
|
||||
endpoint(
|
||||
|
||||
@ -1,489 +0,0 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from .._shared import endpoint, field
|
||||
from devplacepy.database.moderation import (
|
||||
CONSENT_KINDS,
|
||||
MODERATION_ACTIONS,
|
||||
REPORTABLE_TARGETS,
|
||||
REPORT_REASONS,
|
||||
REPORT_STATUSES,
|
||||
)
|
||||
|
||||
REPORT_TARGETS = list(REPORTABLE_TARGETS)
|
||||
REASON_KEYS = list(REPORT_REASONS)
|
||||
CONSENT_KEYS = list(CONSENT_KINDS)
|
||||
|
||||
SAMPLE_REPORT = {
|
||||
"uid": "REPORT_UID",
|
||||
"target_type": "post",
|
||||
"target_uid": "POST_UID",
|
||||
"target_url": "/posts/a-post",
|
||||
"reason": "harassment",
|
||||
"reason_label": "Harassment or bullying",
|
||||
"detail": "Repeated personal attacks in the thread.",
|
||||
"severity": "warn",
|
||||
"status": "open",
|
||||
"origin": "member",
|
||||
"categories": [],
|
||||
"created_at": "2026-01-05T10:00:00+00:00",
|
||||
"resolved_at": "",
|
||||
"reporter_name": "alice",
|
||||
"owner_name": "bob",
|
||||
"report_count": 2,
|
||||
}
|
||||
|
||||
GROUP = {
|
||||
"slug": "moderation",
|
||||
"title": "Reporting & Moderation",
|
||||
"intro": """
|
||||
# Reporting & Moderation
|
||||
|
||||
Every externally visible surface on DevPlace is reportable through one polymorphic
|
||||
endpoint, and every report lands in one queue with one state machine. The reason
|
||||
list is served by `GET /reports/reasons`, so a native client renders the same
|
||||
dialog the web UI does.
|
||||
|
||||
DevPlace commits to reviewing every report within the window published on the
|
||||
[content moderation](/docs/content-moderation.html) page. Filing a report always
|
||||
returns an acknowledgement carrying that window.
|
||||
|
||||
The moderation endpoints under `/admin/moderation` are administrator-only and are
|
||||
subject to the admin seniority rule: a junior administrator cannot action a more
|
||||
senior one.
|
||||
|
||||
Every endpoint follows the shared [Conventions & Errors](/docs/conventions.html) (auth, content
|
||||
negotiation, pagination, status codes); see [Authentication](/docs/authentication.html) for the
|
||||
four ways to sign requests.
|
||||
""",
|
||||
"endpoints": [
|
||||
endpoint(
|
||||
id="report-reasons",
|
||||
method="GET",
|
||||
path="/reports/reasons",
|
||||
title="List report reasons",
|
||||
summary="The reason keys a report may be filed under, with their labels.",
|
||||
auth="public",
|
||||
sample_response={
|
||||
"reasons": [{"key": "harassment", "label": "Harassment or bullying"}],
|
||||
"severities": ["info", "warn", "critical"],
|
||||
},
|
||||
),
|
||||
endpoint(
|
||||
id="report-create",
|
||||
method="POST",
|
||||
path="/reports/{target_type}/{target_uid}",
|
||||
title="Report content",
|
||||
summary="File a report against any user-generated surface.",
|
||||
auth="user",
|
||||
encoding="form",
|
||||
destructive=False,
|
||||
params=[
|
||||
field(
|
||||
"target_type",
|
||||
"path",
|
||||
"enum",
|
||||
True,
|
||||
"post",
|
||||
"The kind of content being reported.",
|
||||
REPORT_TARGETS,
|
||||
),
|
||||
field(
|
||||
"target_uid",
|
||||
"path",
|
||||
"string",
|
||||
True,
|
||||
"POST_UID",
|
||||
"UID of the reported item.",
|
||||
),
|
||||
field(
|
||||
"reason",
|
||||
"form",
|
||||
"enum",
|
||||
True,
|
||||
"harassment",
|
||||
"Why the content breaks the guidelines.",
|
||||
REASON_KEYS,
|
||||
),
|
||||
field(
|
||||
"detail",
|
||||
"form",
|
||||
"string",
|
||||
False,
|
||||
"",
|
||||
"Free text for the moderator, up to 2000 characters.",
|
||||
),
|
||||
],
|
||||
notes=[
|
||||
"A second report on the same target by the same reporter updates the "
|
||||
"open report instead of creating a duplicate.",
|
||||
"You cannot report your own content.",
|
||||
],
|
||||
sample_response={
|
||||
"ok": True,
|
||||
"redirect": "/reports/mine",
|
||||
"data": {
|
||||
"uid": "REPORT_UID",
|
||||
"status": "open",
|
||||
"severity": "warn",
|
||||
"sla_hours": 24,
|
||||
},
|
||||
},
|
||||
),
|
||||
endpoint(
|
||||
id="reports-mine",
|
||||
method="GET",
|
||||
path="/reports/mine",
|
||||
title="List your reports",
|
||||
summary="The reports you filed and the outcome of each.",
|
||||
auth="user",
|
||||
params=[
|
||||
field(
|
||||
"status",
|
||||
"query",
|
||||
"enum",
|
||||
False,
|
||||
"open",
|
||||
"Filter by report status.",
|
||||
list(REPORT_STATUSES),
|
||||
),
|
||||
field("page", "query", "integer", False, "1", "Page number."),
|
||||
],
|
||||
sample_response={
|
||||
"reports": [SAMPLE_REPORT],
|
||||
"pagination": {"page": 1, "total": 1, "total_pages": 1},
|
||||
"status": "",
|
||||
},
|
||||
),
|
||||
endpoint(
|
||||
id="admin-moderation",
|
||||
method="GET",
|
||||
path="/admin/moderation",
|
||||
title="The moderation queue",
|
||||
summary="Reported content awaiting a decision, oldest open first.",
|
||||
auth="admin",
|
||||
params=[
|
||||
field(
|
||||
"status",
|
||||
"query",
|
||||
"enum",
|
||||
False,
|
||||
"open",
|
||||
"Filter by report status.",
|
||||
list(REPORT_STATUSES),
|
||||
),
|
||||
field("page", "query", "integer", False, "1", "Page number."),
|
||||
],
|
||||
sample_response={
|
||||
"reports": [SAMPLE_REPORT],
|
||||
"counts": {"open": 1, "acknowledged": 0, "actioned": 0, "dismissed": 0},
|
||||
"sla": {
|
||||
"sla_hours": 24,
|
||||
"oldest_open_hours": 1.5,
|
||||
"breached": 0,
|
||||
"within_sla": True,
|
||||
},
|
||||
},
|
||||
),
|
||||
endpoint(
|
||||
id="admin-moderation-report",
|
||||
method="GET",
|
||||
path="/admin/moderation/{uid}",
|
||||
title="Read one report",
|
||||
summary="One report with its decisions and the author's history.",
|
||||
auth="admin",
|
||||
params=[
|
||||
field("uid", "path", "string", True, "REPORT_UID", "Report UID."),
|
||||
],
|
||||
sample_response={
|
||||
"report": SAMPLE_REPORT,
|
||||
"actions": [],
|
||||
"history": [],
|
||||
"available_actions": list(MODERATION_ACTIONS),
|
||||
"can_remove": True,
|
||||
},
|
||||
),
|
||||
endpoint(
|
||||
id="admin-moderation-status",
|
||||
method="POST",
|
||||
path="/admin/moderation/{uid}/status",
|
||||
title="Set a report status",
|
||||
summary="Move a report through the triage state machine.",
|
||||
auth="admin",
|
||||
encoding="form",
|
||||
params=[
|
||||
field("uid", "path", "string", True, "REPORT_UID", "Report UID."),
|
||||
field(
|
||||
"status",
|
||||
"form",
|
||||
"enum",
|
||||
True,
|
||||
"acknowledged",
|
||||
"New status.",
|
||||
list(REPORT_STATUSES),
|
||||
),
|
||||
],
|
||||
sample_response={"ok": True, "redirect": "/admin/moderation/REPORT_UID"},
|
||||
),
|
||||
endpoint(
|
||||
id="admin-moderation-decide",
|
||||
method="POST",
|
||||
path="/admin/moderation/{uid}/decide",
|
||||
title="Decide a report",
|
||||
summary="Apply a moderation decision and notify the affected user.",
|
||||
auth="admin",
|
||||
encoding="form",
|
||||
destructive=True,
|
||||
params=[
|
||||
field("uid", "path", "string", True, "REPORT_UID", "Report UID."),
|
||||
field(
|
||||
"action",
|
||||
"form",
|
||||
"enum",
|
||||
True,
|
||||
"dismiss",
|
||||
"The decision to apply.",
|
||||
list(MODERATION_ACTIONS),
|
||||
),
|
||||
field(
|
||||
"reason",
|
||||
"form",
|
||||
"string",
|
||||
False,
|
||||
"",
|
||||
"Reason shown to the affected user.",
|
||||
),
|
||||
field("notes", "form", "string", False, "", "Internal notes."),
|
||||
field(
|
||||
"duration_hours",
|
||||
"form",
|
||||
"integer",
|
||||
False,
|
||||
"24",
|
||||
"Suspension length in hours.",
|
||||
),
|
||||
],
|
||||
notes=[
|
||||
"A report already resolved by another moderator answers 409.",
|
||||
"Content removal is unavailable for targets that have no removal "
|
||||
"path (direct messages, accounts, workspaces, polls, assistant "
|
||||
"output); act on the account instead.",
|
||||
],
|
||||
sample_response={"ok": True, "redirect": "/admin/moderation/REPORT_UID"},
|
||||
),
|
||||
endpoint(
|
||||
id="admin-user-suspend",
|
||||
method="POST",
|
||||
path="/admin/users/{uid}/suspend",
|
||||
title="Suspend an account",
|
||||
summary="Suspend an account for a fixed period with a stated reason.",
|
||||
auth="admin",
|
||||
encoding="form",
|
||||
destructive=True,
|
||||
params=[
|
||||
field("uid", "path", "string", True, "USER_UID", "User UID."),
|
||||
field("reason", "form", "string", False, "", "Reason shown to the user."),
|
||||
field(
|
||||
"duration_hours",
|
||||
"form",
|
||||
"integer",
|
||||
False,
|
||||
"24",
|
||||
"Suspension length in hours.",
|
||||
),
|
||||
],
|
||||
notes=[
|
||||
"A suspended account can still read, still see why, and still delete "
|
||||
"itself, but cannot create content.",
|
||||
],
|
||||
sample_response={"ok": True, "redirect": "/admin/users"},
|
||||
),
|
||||
endpoint(
|
||||
id="admin-user-lift",
|
||||
method="POST",
|
||||
path="/admin/users/{uid}/lift",
|
||||
title="Lift a restriction",
|
||||
summary="Clear a suspension or ban and restore the account.",
|
||||
auth="admin",
|
||||
encoding="form",
|
||||
params=[field("uid", "path", "string", True, "USER_UID", "User UID.")],
|
||||
sample_response={"ok": True, "redirect": "/admin/users"},
|
||||
),
|
||||
endpoint(
|
||||
id="admin-user-ban",
|
||||
method="POST",
|
||||
path="/admin/users/{uid}/ban",
|
||||
title="Ban an account",
|
||||
summary="Permanently close an account and revoke every credential.",
|
||||
auth="admin",
|
||||
encoding="form",
|
||||
destructive=True,
|
||||
params=[
|
||||
field("uid", "path", "string", True, "USER_UID", "User UID."),
|
||||
field("reason", "form", "string", False, "", "Reason shown to the user."),
|
||||
],
|
||||
sample_response={"ok": True, "redirect": "/admin/users"},
|
||||
),
|
||||
endpoint(
|
||||
id="auth-accept-terms-page",
|
||||
method="GET",
|
||||
path="/auth/accept-terms",
|
||||
title="The terms acceptance page",
|
||||
summary="The Terms of Service version in force and the version this account accepted.",
|
||||
auth="user",
|
||||
sample_response={"terms_version": "1", "accepted_version": ""},
|
||||
),
|
||||
endpoint(
|
||||
id="auth-accept-terms",
|
||||
method="POST",
|
||||
path="/auth/accept-terms",
|
||||
title="Accept the terms",
|
||||
summary="Record acceptance of the Terms of Service version in force.",
|
||||
auth="user",
|
||||
encoding="form",
|
||||
notes=[
|
||||
"A member whose accepted version is behind the version in force is "
|
||||
"redirected here on any mutating request. Reading, the docs, the "
|
||||
"safety controls and account deletion are never blocked.",
|
||||
],
|
||||
sample_response={"ok": True, "redirect": "/feed", "data": {"terms_version": "1"}},
|
||||
),
|
||||
endpoint(
|
||||
id="profile-consent",
|
||||
method="POST",
|
||||
path="/profile/{username}/consent",
|
||||
title="Grant or withdraw a consent",
|
||||
summary="Change one consent on your own account. Withdrawal takes effect at once.",
|
||||
auth="user",
|
||||
encoding="form",
|
||||
params=[
|
||||
field("username", "path", "string", True, "USERNAME", "Your own username."),
|
||||
field(
|
||||
"kind",
|
||||
"form",
|
||||
"enum",
|
||||
True,
|
||||
"ai_third_party",
|
||||
"Consent to change.",
|
||||
CONSENT_KEYS,
|
||||
),
|
||||
field("granted", "form", "enum", True, "1", "1 grants, 0 withdraws.", ["1", "0"]),
|
||||
],
|
||||
notes=[
|
||||
"Withdrawing `ai_third_party` makes the AI gateway refuse every call "
|
||||
"that would send your own content to the provider, whatever the "
|
||||
"per-feature preference says.",
|
||||
"Withdrawing `activity_recording` stops presence writes; you simply "
|
||||
"appear offline.",
|
||||
"Only the account holder can change a consent. An administrator "
|
||||
"reads the record but never grants or withdraws it for someone else.",
|
||||
],
|
||||
sample_response={
|
||||
"ok": True,
|
||||
"redirect": "/profile/USERNAME?tab=privacy",
|
||||
"data": {"kind": "ai_third_party", "state": "granted"},
|
||||
},
|
||||
),
|
||||
endpoint(
|
||||
id="profile-mature-content",
|
||||
method="POST",
|
||||
path="/profile/{username}/mature-content",
|
||||
title="Set the mature-content preference",
|
||||
summary="Show or hide content labelled mature for your own account.",
|
||||
auth="user",
|
||||
encoding="form",
|
||||
params=[
|
||||
field("username", "path", "string", True, "USERNAME", "Your own username."),
|
||||
field(
|
||||
"mature_opt_in",
|
||||
"form",
|
||||
"enum",
|
||||
True,
|
||||
"1",
|
||||
"1 shows mature content, 0 hides it.",
|
||||
["1", "0"],
|
||||
),
|
||||
],
|
||||
notes=[
|
||||
"Only the account holder can change this preference. An administrator "
|
||||
"reads the privacy tab but never sets it for someone else.",
|
||||
],
|
||||
sample_response={
|
||||
"ok": True,
|
||||
"redirect": "/profile/USERNAME?tab=privacy",
|
||||
"data": {"mature_opt_in": True},
|
||||
},
|
||||
),
|
||||
endpoint(
|
||||
id="profile-delete",
|
||||
method="GET",
|
||||
path="/profile/{username}/delete",
|
||||
title="Account deletion page",
|
||||
summary="What deletion removes, what is retained, and the grace window.",
|
||||
auth="user",
|
||||
params=[
|
||||
field("username", "path", "string", True, "USERNAME", "Your own username."),
|
||||
],
|
||||
sample_response={
|
||||
"username": "USERNAME",
|
||||
"grace_hours": 24,
|
||||
"removed": ["Your account record, username, email address and password"],
|
||||
"retained": ["Append-only audit and moderation records"],
|
||||
},
|
||||
),
|
||||
endpoint(
|
||||
id="profile-delete-confirm",
|
||||
method="POST",
|
||||
path="/profile/{username}/delete",
|
||||
title="Delete your account",
|
||||
summary="Permanently delete your account and personal data.",
|
||||
auth="user",
|
||||
encoding="form",
|
||||
destructive=True,
|
||||
params=[
|
||||
field("username", "path", "string", True, "USERNAME", "Your own username."),
|
||||
field("password", "form", "string", True, "PASSWORD", "Your account password."),
|
||||
],
|
||||
notes=[
|
||||
"Only the account holder can delete an account; an administrator uses "
|
||||
"a ban instead.",
|
||||
"Sessions and tokens are revoked and the profile is anonymised "
|
||||
"immediately; the deletion event is purged after the grace window.",
|
||||
],
|
||||
sample_response={
|
||||
"ok": True,
|
||||
"redirect": "/",
|
||||
"data": {"stamp": "2026-01-05T10:00:00+00:00", "rows": 42, "grace_hours": 24},
|
||||
},
|
||||
),
|
||||
endpoint(
|
||||
id="workspaces-index",
|
||||
method="GET",
|
||||
path="/workspaces/index",
|
||||
title="Published workspace index",
|
||||
summary="Every workspace published to the public ingress, with its link.",
|
||||
auth="public",
|
||||
params=[field("page", "query", "integer", False, "1", "Page number.")],
|
||||
notes=[
|
||||
"The project-derived `description` and `project_url` come back empty "
|
||||
"unless you may view the workspace's project, so a private project "
|
||||
"never leaks its title or description through this public listing.",
|
||||
],
|
||||
sample_response={
|
||||
"workspaces": [
|
||||
{
|
||||
"uid": "INSTANCE_UID",
|
||||
"name": "demo",
|
||||
"slug": "demo",
|
||||
"owner_uid": "USER_UID",
|
||||
"url": "{{ base }}/p/demo",
|
||||
"description": "A demo workspace.",
|
||||
"owner": "alice",
|
||||
"maturity": "general",
|
||||
"project_url": "/projects/demo",
|
||||
}
|
||||
],
|
||||
"total": 1,
|
||||
},
|
||||
),
|
||||
],
|
||||
}
|
||||
@ -1,167 +0,0 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from .._shared import endpoint, field
|
||||
|
||||
GROUP = {
|
||||
"slug": "workspaces",
|
||||
"title": "Dev Workspaces",
|
||||
"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 **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.
|
||||
|
||||
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
|
||||
arrives as a `workspace` notification and states exactly what happens next and when.
|
||||
""",
|
||||
"endpoints": [
|
||||
endpoint(
|
||||
id="workspace-get",
|
||||
method="GET",
|
||||
path="/projects/{slug}/workspace",
|
||||
title="Read workspace",
|
||||
summary=(
|
||||
"State, quota usage, idle countdown, tunnels and open moderation flags "
|
||||
"for your workspace on this project."
|
||||
),
|
||||
auth="user",
|
||||
params=[
|
||||
field("slug", "path", "string", True, "my-project", "Project slug or uid."),
|
||||
],
|
||||
sample_response={
|
||||
"has_workspace": True,
|
||||
"viewer_can_workspace": True,
|
||||
"workspace_count": 1,
|
||||
"max_workspaces": 2,
|
||||
"editor_url": "/projects/my-project/containers/instances/INSTANCE_UID/code/",
|
||||
"workspace": {
|
||||
"uid": "INSTANCE_UID",
|
||||
"status": "running",
|
||||
"suspended": False,
|
||||
"tunnel_name": "brave-otter",
|
||||
"primary_url": "https://brave-otter.tunnel.pravda.education",
|
||||
"disk_bytes": 5242880,
|
||||
"disk_quota_mb": 2048,
|
||||
"disk_percent": 1,
|
||||
"egress_bytes": 10240,
|
||||
"egress_quota_mb": 10240,
|
||||
"egress_percent": 0,
|
||||
"idle_stop_minutes": 60,
|
||||
"retention_days": 14,
|
||||
"max_tunnels": 5,
|
||||
"tunnels": [],
|
||||
"flags": [],
|
||||
},
|
||||
},
|
||||
),
|
||||
endpoint(
|
||||
id="workspace-open",
|
||||
method="POST",
|
||||
path="/projects/{slug}/workspace",
|
||||
title="Open or resume workspace",
|
||||
summary=(
|
||||
"Create the workspace if you have none for this project, otherwise resume "
|
||||
"it. Idempotent. Refused when you are at your workspace limit, over disk "
|
||||
"quota, or suspended."
|
||||
),
|
||||
auth="user",
|
||||
params=[
|
||||
field("slug", "path", "string", True, "my-project", "Project slug or uid."),
|
||||
],
|
||||
sample_response={"ok": True, "redirect": "/projects/my-project/workspace"},
|
||||
),
|
||||
endpoint(
|
||||
id="workspace-stop",
|
||||
method="POST",
|
||||
path="/projects/{slug}/workspace/stop",
|
||||
title="Stop workspace",
|
||||
summary="Stop the container. Files and tunnels are kept.",
|
||||
auth="user",
|
||||
params=[
|
||||
field("slug", "path", "string", True, "my-project", "Project slug or uid."),
|
||||
],
|
||||
sample_response={"ok": True, "redirect": "/projects/my-project/workspace"},
|
||||
),
|
||||
endpoint(
|
||||
id="workspace-delete",
|
||||
method="POST",
|
||||
path="/projects/{slug}/workspace/delete",
|
||||
title="Delete workspace",
|
||||
summary="Remove the workspace and its tunnels. An administrator can restore it.",
|
||||
auth="user",
|
||||
params=[
|
||||
field("slug", "path", "string", True, "my-project", "Project slug or uid."),
|
||||
],
|
||||
sample_response={"ok": True, "redirect": "/projects/my-project/workspace"},
|
||||
),
|
||||
endpoint(
|
||||
id="workspace-tunnels-list",
|
||||
method="GET",
|
||||
path="/projects/{slug}/workspace/tunnels",
|
||||
title="List tunnels",
|
||||
summary="Every public tunnel published by this workspace.",
|
||||
auth="user",
|
||||
params=[
|
||||
field("slug", "path", "string", True, "my-project", "Project slug or uid."),
|
||||
],
|
||||
sample_response={
|
||||
"tunnels": [
|
||||
{
|
||||
"uid": "TUNNEL_UID",
|
||||
"hostname": "8080-brave-otter.tunnel.pravda.education",
|
||||
"label": "web",
|
||||
"container_port": 8080,
|
||||
"status": "active",
|
||||
"cert_status": "valid",
|
||||
"request_count": 12,
|
||||
"bytes_out": 40960,
|
||||
}
|
||||
]
|
||||
},
|
||||
),
|
||||
endpoint(
|
||||
id="workspace-tunnel-create",
|
||||
method="POST",
|
||||
path="/projects/{slug}/workspace/tunnels",
|
||||
title="Create tunnel",
|
||||
summary=(
|
||||
"Publish a container port on a public HTTPS hostname. The URL is public "
|
||||
"and unauthenticated. Refused past the tunnel limit."
|
||||
),
|
||||
auth="user",
|
||||
params=[
|
||||
field("slug", "path", "string", True, "my-project", "Project slug or uid."),
|
||||
field("container_port", "body", "integer", True, 8080, "Port inside the container."),
|
||||
field("label", "body", "string", False, "web", "Human label."),
|
||||
],
|
||||
sample_response={
|
||||
"ok": True,
|
||||
"data": {
|
||||
"uid": "TUNNEL_UID",
|
||||
"hostname": "8080-brave-otter.tunnel.pravda.education",
|
||||
"status": "pending",
|
||||
},
|
||||
},
|
||||
),
|
||||
endpoint(
|
||||
id="workspace-tunnel-delete",
|
||||
method="POST",
|
||||
path="/projects/{slug}/workspace/tunnels/{uid}/delete",
|
||||
title="Delete tunnel",
|
||||
summary="Remove a tunnel. The public URL stops serving immediately.",
|
||||
auth="user",
|
||||
params=[
|
||||
field("slug", "path", "string", True, "my-project", "Project slug or uid."),
|
||||
field("uid", "path", "string", True, "TUNNEL_UID", "Tunnel uid."),
|
||||
],
|
||||
sample_response={"ok": True, "redirect": "/projects/my-project/workspace"},
|
||||
),
|
||||
],
|
||||
}
|
||||
@ -8,7 +8,7 @@ import time
|
||||
from collections import defaultdict
|
||||
from contextlib import asynccontextmanager, contextmanager
|
||||
from pathlib import Path
|
||||
from fastapi import FastAPI, Request, WebSocket
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
@ -72,7 +72,6 @@ from devplacepy.routers import (
|
||||
push,
|
||||
leaderboard,
|
||||
reactions,
|
||||
reports,
|
||||
bookmarks,
|
||||
polls,
|
||||
docs,
|
||||
@ -87,7 +86,6 @@ from devplacepy.routers import (
|
||||
dbapi,
|
||||
pubsub,
|
||||
game,
|
||||
workspaces,
|
||||
)
|
||||
from devplacepy.services.manager import service_manager
|
||||
from devplacepy.services.background import background
|
||||
@ -114,11 +112,8 @@ from devplacepy.services.jobs.deepsearch.service import DeepsearchService
|
||||
from devplacepy.services.jobs.isslop.service import IsslopService
|
||||
from devplacepy.services.gitea.service import IssueTrackerService
|
||||
from devplacepy.services.containers.service import ContainerService
|
||||
from devplacepy.services.containers.workspace_service import WorkspaceService
|
||||
from devplacepy.services.xmlrpc import XmlrpcService
|
||||
from devplacepy.services.audit import AuditService
|
||||
from devplacepy.services.moderation.service import ModerationService
|
||||
from devplacepy.services.moderation.screening import ContentRefused
|
||||
from devplacepy.services.audit import record as audit
|
||||
from devplacepy.services.push import PushService
|
||||
from devplacepy.services.telegram import TelegramService
|
||||
@ -279,10 +274,8 @@ async def lifespan(app: FastAPI):
|
||||
service_manager.register(PlanningReportService())
|
||||
service_manager.register(IssueTrackerService())
|
||||
service_manager.register(ContainerService())
|
||||
service_manager.register(WorkspaceService())
|
||||
service_manager.register(XmlrpcService())
|
||||
service_manager.register(AuditService())
|
||||
service_manager.register(ModerationService())
|
||||
service_manager.register(PushService())
|
||||
service_manager.register(TelegramService())
|
||||
service_manager.register(TelegramOutboxService())
|
||||
@ -307,9 +300,6 @@ async def lifespan(app: FastAPI):
|
||||
flush_visits()
|
||||
await service_manager.shutdown_all()
|
||||
await background.stop()
|
||||
from devplacepy.services.containers import forward
|
||||
|
||||
await forward.close_client()
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
@ -379,30 +369,6 @@ async def server_error(request: Request, exc):
|
||||
)
|
||||
|
||||
|
||||
@app.exception_handler(ContentRefused)
|
||||
async def content_refused(request: Request, exc: ContentRefused):
|
||||
logger.info("content refused on %s %s: %s", request.method, request.url.path, exc.message)
|
||||
if wants_json(request):
|
||||
return json_error(400, exc.message, categories=list(exc.categories))
|
||||
seo_ctx = base_seo_context(
|
||||
request,
|
||||
title="Content not published - DevPlace",
|
||||
description=exc.message,
|
||||
robots="noindex",
|
||||
)
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"error.html",
|
||||
{
|
||||
**seo_ctx,
|
||||
"request": request,
|
||||
"error_code": 400,
|
||||
"error_message": exc.message,
|
||||
},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
|
||||
_AUTH_FORM_PAGES = {
|
||||
"/auth/signup": ("signup.html", "Join DevPlace"),
|
||||
"/auth/login": ("login.html", "Sign In"),
|
||||
@ -473,7 +439,6 @@ app.include_router(messages.router, prefix="/messages")
|
||||
app.include_router(notifications.router, prefix="/notifications")
|
||||
app.include_router(votes.router, prefix="/votes")
|
||||
app.include_router(reactions.router, prefix="/reactions")
|
||||
app.include_router(reports.router, prefix="/reports")
|
||||
app.include_router(bookmarks.router, prefix="/bookmarks")
|
||||
app.include_router(polls.router, prefix="/polls")
|
||||
app.include_router(avatar.router, prefix="/avatar")
|
||||
@ -502,7 +467,6 @@ 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(workspaces.router, prefix="/workspaces")
|
||||
|
||||
|
||||
@app.middleware("http")
|
||||
@ -623,53 +587,6 @@ async def maintenance_middleware(request: Request, call_next):
|
||||
)
|
||||
|
||||
|
||||
_TERMS_ALLOWED_PREFIXES = (
|
||||
"/static",
|
||||
"/avatar",
|
||||
"/auth",
|
||||
"/docs",
|
||||
"/reports",
|
||||
"/block",
|
||||
"/mute",
|
||||
"/openai",
|
||||
)
|
||||
|
||||
_TERMS_GATED_METHODS = ("POST", "PUT", "DELETE", "PATCH")
|
||||
|
||||
|
||||
def _terms_exempt(path: str) -> bool:
|
||||
if path.startswith(_TERMS_ALLOWED_PREFIXES):
|
||||
return True
|
||||
return path.startswith("/profile/") and (
|
||||
path.endswith("/delete") or path.endswith("/consent")
|
||||
)
|
||||
|
||||
|
||||
@app.middleware("http")
|
||||
async def terms_acceptance_gate(request: Request, call_next):
|
||||
if request.method not in _TERMS_GATED_METHODS or _terms_exempt(request.url.path):
|
||||
return await call_next(request)
|
||||
from devplacepy.routers.auth.terms import (
|
||||
TERMS_ACCEPTANCE_CODE,
|
||||
current_terms_version,
|
||||
needs_acceptance,
|
||||
)
|
||||
|
||||
user = get_current_user(request)
|
||||
if not needs_acceptance(user):
|
||||
return await call_next(request)
|
||||
message = "Accept the updated Terms of Service to continue."
|
||||
if wants_json(request):
|
||||
return json_error(
|
||||
403,
|
||||
message,
|
||||
code=TERMS_ACCEPTANCE_CODE,
|
||||
redirect="/auth/accept-terms",
|
||||
terms_version=current_terms_version(),
|
||||
)
|
||||
return RedirectResponse(url="/auth/accept-terms", status_code=303)
|
||||
|
||||
|
||||
@app.middleware("http")
|
||||
async def track_presence(request: Request, call_next):
|
||||
path = request.url.path
|
||||
@ -698,42 +615,6 @@ async def response_timing(request: Request, call_next):
|
||||
return response
|
||||
|
||||
|
||||
class TunnelDispatchMiddleware:
|
||||
def __init__(self, app):
|
||||
self.app = app
|
||||
|
||||
@staticmethod
|
||||
def _host(scope) -> str:
|
||||
for key, value in scope.get("headers") or []:
|
||||
if key == b"host":
|
||||
return value.decode("latin-1")
|
||||
return ""
|
||||
|
||||
async def __call__(self, scope, receive, send):
|
||||
if scope["type"] not in ("http", "websocket"):
|
||||
await self.app(scope, receive, send)
|
||||
return
|
||||
from devplacepy.routers import tunnel as tunnel_router
|
||||
from devplacepy.services.containers.workspace import naming
|
||||
|
||||
try:
|
||||
if not naming.is_tunnel_host(self._host(scope)):
|
||||
await self.app(scope, receive, send)
|
||||
return
|
||||
except Exception:
|
||||
await self.app(scope, receive, send)
|
||||
return
|
||||
path = (scope.get("path") or "/").lstrip("/")
|
||||
if scope["type"] == "websocket":
|
||||
websocket = WebSocket(scope, receive, send)
|
||||
await tunnel_router.handle_ws(websocket, path)
|
||||
return
|
||||
request = Request(scope, receive)
|
||||
response = await tunnel_router.handle_http(request, path)
|
||||
await response(scope, receive, send)
|
||||
|
||||
|
||||
app.add_middleware(TunnelDispatchMiddleware)
|
||||
app.add_middleware(GZipMiddleware, minimum_size=512, compresslevel=5)
|
||||
|
||||
|
||||
@ -822,7 +703,7 @@ async def landing(request: Request):
|
||||
seo_ctx = base_seo_context(
|
||||
request,
|
||||
title="DevPlace - The Developer Social Network",
|
||||
description="Track industry shifts. Discover bold releases. Share what you're building in an open environment built by developers, for developers.",
|
||||
description="Track industry shifts. Discover bold releases. Share what you're building in an open, uncensored environment.",
|
||||
breadcrumbs=[],
|
||||
schemas=[website_schema(base)],
|
||||
)
|
||||
|
||||
@ -48,53 +48,11 @@ def normalize_poll_options(value):
|
||||
return value
|
||||
|
||||
|
||||
def _declared_age(birth_date: str) -> int:
|
||||
from datetime import date
|
||||
|
||||
from devplacepy.database.moderation import years_between
|
||||
|
||||
normalized = normalize_european_date(birth_date)
|
||||
if not normalized:
|
||||
raise ValueError("Date of birth is required")
|
||||
born = date.fromisoformat(normalized)
|
||||
today = date.today()
|
||||
if born > today:
|
||||
raise ValueError("Date of birth cannot be in the future")
|
||||
return years_between(born, today)
|
||||
|
||||
|
||||
class SignupForm(BaseModel):
|
||||
username: str = Field(min_length=3, max_length=32)
|
||||
email: str = Field(min_length=1, max_length=255)
|
||||
password: str = Field(min_length=6, max_length=128)
|
||||
confirm_password: str = Field(min_length=1, max_length=128)
|
||||
birth_date: str = Field(min_length=1, max_length=20)
|
||||
accept_terms: str = Field(default="")
|
||||
|
||||
@property
|
||||
def age_band(self) -> str:
|
||||
from devplacepy.database.moderation import age_band_for
|
||||
|
||||
return age_band_for(_declared_age(self.birth_date))
|
||||
|
||||
@field_validator("birth_date")
|
||||
@classmethod
|
||||
def old_enough(cls, value):
|
||||
from devplacepy.database import minimum_age
|
||||
|
||||
minimum = minimum_age()
|
||||
if _declared_age(value) < minimum:
|
||||
raise ValueError(f"You must be at least {minimum} years old to join")
|
||||
return value
|
||||
|
||||
@field_validator("accept_terms")
|
||||
@classmethod
|
||||
def terms_accepted(cls, value):
|
||||
if value.strip().lower() not in ("1", "on", "true", "yes"):
|
||||
raise ValueError(
|
||||
"You must accept the Terms of Service and Community Guidelines"
|
||||
)
|
||||
return value
|
||||
|
||||
@field_validator("username")
|
||||
@classmethod
|
||||
@ -105,10 +63,6 @@ class SignupForm(BaseModel):
|
||||
raise ValueError(
|
||||
"Username can only contain letters, numbers, hyphens, and underscores"
|
||||
)
|
||||
from devplacepy.services.moderation.filter import classify
|
||||
|
||||
if classify(value).verdict == "block":
|
||||
raise ValueError("That username breaks the community guidelines")
|
||||
return value
|
||||
|
||||
@field_validator("email")
|
||||
@ -633,29 +587,8 @@ class AdminSettingsForm(BaseModel):
|
||||
maintenance_message: str = Field(default="", max_length=300)
|
||||
docs_search_mode: str = Field(default="", max_length=20)
|
||||
outbound_proxy_url: str = Field(default="", max_length=500)
|
||||
moderation_sla_hours: str = Field(default="", max_length=10)
|
||||
moderation_filter_mode: str = Field(default="", max_length=10)
|
||||
moderation_minimum_age: str = Field(default="", max_length=3)
|
||||
moderation_mature_default_hidden: str = Field(default="", max_length=1)
|
||||
account_deletion_grace_hours: str = Field(default="", max_length=10)
|
||||
contact_email: str = Field(default="", max_length=200)
|
||||
contact_phone: str = Field(default="", max_length=60)
|
||||
contact_address: str = Field(default="", max_length=500)
|
||||
terms_version: str = Field(default="", max_length=20)
|
||||
privacy_version: str = Field(default="", max_length=20)
|
||||
guidelines_version: str = Field(default="", max_length=20)
|
||||
ai_third_party_provider: str = Field(default="", max_length=120)
|
||||
extra_head: str = Field(default="", max_length=50000)
|
||||
|
||||
@field_validator("moderation_filter_mode")
|
||||
@classmethod
|
||||
def validate_filter_mode(cls, value):
|
||||
from devplacepy.services.moderation.rules import FILTER_MODES
|
||||
|
||||
if value and value not in FILTER_MODES:
|
||||
raise ValueError(f"Filter mode must be one of {', '.join(FILTER_MODES)}")
|
||||
return value
|
||||
|
||||
@field_validator("outbound_proxy_url")
|
||||
@classmethod
|
||||
def validate_outbound_proxy_url(cls, value):
|
||||
@ -952,117 +885,3 @@ class QuizImportForm(BaseModel):
|
||||
except ValueError as exc:
|
||||
raise ValueError("document must be valid JSON") from exc
|
||||
return value
|
||||
|
||||
|
||||
class TunnelForm(BaseModel):
|
||||
label: str = Field(default="", max_length=64)
|
||||
container_port: int = Field(default=0, ge=0, le=65535)
|
||||
|
||||
|
||||
class WorkspaceQuotaForm(BaseModel):
|
||||
owner_id: str = Field(default="", max_length=36)
|
||||
label: str = Field(default="", max_length=64)
|
||||
max_workspaces: int = Field(default=0, ge=0, le=100)
|
||||
max_tunnels: int = Field(default=0, ge=0, le=100)
|
||||
disk_quota_mb: int = Field(default=0, ge=0)
|
||||
egress_quota_mb: int = Field(default=0, ge=0)
|
||||
idle_stop_minutes: int = Field(default=0, ge=0)
|
||||
retention_days: int = Field(default=0, ge=0)
|
||||
|
||||
|
||||
class WorkspaceFlagForm(BaseModel):
|
||||
kind: str = Field(default="manual", max_length=40)
|
||||
severity: str = Field(default="warn", max_length=16)
|
||||
detail: str = Field(default="", max_length=500)
|
||||
|
||||
|
||||
class WorkspaceSuspendForm(BaseModel):
|
||||
reason: str = Field(default="", max_length=500)
|
||||
|
||||
|
||||
class ReportForm(BaseModel):
|
||||
reason: str = Field(min_length=1, max_length=40)
|
||||
detail: str = Field(default="", max_length=2000)
|
||||
|
||||
@field_validator("reason")
|
||||
@classmethod
|
||||
def known_reason(cls, value):
|
||||
from devplacepy.database.moderation import REPORT_REASONS
|
||||
|
||||
if value not in REPORT_REASONS:
|
||||
raise ValueError("Unknown report reason")
|
||||
return value
|
||||
|
||||
|
||||
class ReportStatusForm(BaseModel):
|
||||
status: str = Field(default="acknowledged", max_length=20)
|
||||
|
||||
@field_validator("status")
|
||||
@classmethod
|
||||
def known_status(cls, value):
|
||||
from devplacepy.database.moderation import REPORT_STATUSES
|
||||
|
||||
if value not in REPORT_STATUSES:
|
||||
raise ValueError("Unknown report status")
|
||||
return value
|
||||
|
||||
|
||||
class ModerationDecisionForm(BaseModel):
|
||||
action: str = Field(min_length=1, max_length=30)
|
||||
reason: str = Field(default="", max_length=200)
|
||||
notes: str = Field(default="", max_length=2000)
|
||||
duration_hours: int = Field(default=24, ge=1, le=8760)
|
||||
|
||||
@field_validator("action")
|
||||
@classmethod
|
||||
def known_action(cls, value):
|
||||
from devplacepy.database.moderation import MODERATION_ACTIONS
|
||||
|
||||
if value not in MODERATION_ACTIONS:
|
||||
raise ValueError("Unknown moderation action")
|
||||
return value
|
||||
|
||||
|
||||
class SuspensionForm(BaseModel):
|
||||
reason: str = Field(default="", max_length=200)
|
||||
duration_hours: int = Field(default=24, ge=1, le=8760)
|
||||
|
||||
|
||||
class BanForm(BaseModel):
|
||||
reason: str = Field(default="", max_length=200)
|
||||
|
||||
|
||||
class ConsentForm(BaseModel):
|
||||
kind: str = Field(min_length=1, max_length=40)
|
||||
granted: str = Field(default="0", max_length=5)
|
||||
|
||||
@field_validator("kind")
|
||||
@classmethod
|
||||
def known_kind(cls, value):
|
||||
from devplacepy.database.moderation import CONSENT_KINDS
|
||||
|
||||
if value not in CONSENT_KINDS:
|
||||
raise ValueError("Unknown consent kind")
|
||||
return value
|
||||
|
||||
|
||||
class MaturityForm(BaseModel):
|
||||
level: str = Field(default="general", max_length=20)
|
||||
|
||||
@field_validator("level")
|
||||
@classmethod
|
||||
def known_level(cls, value):
|
||||
from devplacepy.database.moderation import MATURITY_LEVELS
|
||||
|
||||
if value not in MATURITY_LEVELS:
|
||||
raise ValueError("Unknown maturity level")
|
||||
return value
|
||||
|
||||
|
||||
class MaturePreferenceForm(BaseModel):
|
||||
mature_opt_in: str = Field(default="0", max_length=5)
|
||||
|
||||
|
||||
class AccountDeleteForm(BaseModel):
|
||||
password: str = Field(min_length=1, max_length=128)
|
||||
confirm_text: str = Field(default="", max_length=40)
|
||||
|
||||
@ -23,11 +23,9 @@ Prefixes are wired in `main.py`:
|
||||
| `/polls` | polls.py - poll voting: `POST /polls/{poll_uid}/vote` |
|
||||
| `/avatar` | avatar.py |
|
||||
| `/follow` | follow.py |
|
||||
| `/reports` | reports.py - polymorphic content reporting: `POST /reports/{target_type}/{target_uid}` (member), `GET /reports/mine` (member), `GET /reports/reasons` (public). See `devplacepy/services/moderation/CLAUDE.md` |
|
||||
| `/workspaces` | workspaces.py - `GET /workspaces/index`, the public index of every workspace published to the `/p/{slug}` ingress, with owner, project, maturity label and absolute link. Indexed in the sitemap. Publishing an ingress slug is the deliberate public act, so the workspace itself is always listed, but the project-derived fields (`description` and `project_url`, whose slug carries the project title) are withheld unless `content.can_view_project(project, viewer)` passes - a private project must not leak its title or description through this public listing |
|
||||
| (none) | relations.py - per-user block/mute relations: `POST /block/{username}`, `/block/unblock/{username}`, `/mute/{username}`, `/mute/unmute/{username}` (soft-deletable `user_relations` rows) |
|
||||
| `/leaderboard` | leaderboard.py - `GET /leaderboard` XP/stars leaderboard page |
|
||||
| `/admin` | admin/ package - one leaf per sub-resource (`index`, `users`, `aiusage`, `aiquota`, `media`, `trash`, `settings`, `notifications`, `news`, `auditlog`, `backups`, `game`) plus the folded-in `services.py` and `containers.py` (mounted with `/services` and `/containers` sub-prefixes). `main.py` mounts the whole `/admin` tree from this one package. The `backups` leaf is the admin **Backups** dashboard (`BackupService`, kind `backup`): storage usage, backup archives, and interval/cron backup schedules (CRUD + rotation). **Archive download is restricted to the primary administrator** (the earliest-created Admin, resolved by `database.get_primary_admin_uid` / `utils.is_primary_admin`): `GET /admin/backups/{uid}/download` 403s every other admin, the `download_url` field is withheld from them at every endpoint (`can_download = is_primary_admin(admin)`, surfaced as `BackupDashboardOut.can_download_backups`), and `BackupMonitor.js` renders their Download control as a disabled button tooltipped `Not available`. See `devplacepy/services/backup/CLAUDE.md`. The `moderation` leaf (`/admin/moderation`) is the report queue: the list (oldest-open-first, status tabs, the SLA badge), the per-report detail with the offender's history, `POST /{uid}/status` for triage and `POST /{uid}/decide` for decisions; the per-user enforcement routes `POST /admin/users/{uid}/{suspend,lift,ban}` live in the `users` leaf alongside the legacy `toggle`. Both share `is_senior_admin`/`deny_senior` from `admin/_shared.py`. The `game` leaf (`/admin/game`) is the Code Farm Era admin page: `GET /admin/game` (status), `POST /admin/game/era/start` and `/era/end` - see `devplacepy/services/game/CLAUDE.md` |
|
||||
| `/admin` | admin/ package - one leaf per sub-resource (`index`, `users`, `aiusage`, `aiquota`, `media`, `trash`, `settings`, `notifications`, `news`, `auditlog`, `backups`, `game`) plus the folded-in `services.py` and `containers.py` (mounted with `/services` and `/containers` sub-prefixes). `main.py` mounts the whole `/admin` tree from this one package. The `backups` leaf is the admin **Backups** dashboard (`BackupService`, kind `backup`): storage usage, backup archives, and interval/cron backup schedules (CRUD + rotation). **Archive download is restricted to the primary administrator** (the earliest-created Admin, resolved by `database.get_primary_admin_uid` / `utils.is_primary_admin`): `GET /admin/backups/{uid}/download` 403s every other admin, the `download_url` field is withheld from them at every endpoint (`can_download = is_primary_admin(admin)`, surfaced as `BackupDashboardOut.can_download_backups`), and `BackupMonitor.js` renders their Download control as a disabled button tooltipped `Not available`. See `devplacepy/services/backup/CLAUDE.md`. The `game` leaf (`/admin/game`) is the Code Farm Era admin page: `GET /admin/game` (status), `POST /admin/game/era/start` and `/era/end` - see `devplacepy/services/game/CLAUDE.md` |
|
||||
| `/admin/services` | admin/services.py |
|
||||
| `/issues` | issues/ package - issue tracker backed by Gitea (no local issue store): `index.py` (list `?state=`/`?page=`, detail `/{number}` with comments), `create.py` (async AI-enhanced filing `/create` enqueues a `issue_create` job, status at `/jobs/{uid}`), `comment.py` (synchronous, pushes to Gitea + notifies admins), `status.py` (admin open/closed), `attachments.py` (file attachments on open issues + comments, mirrored to Gitea native assets; add/list/delete with owner-or-admin + open-state guards) |
|
||||
| `/gists` | gists.py |
|
||||
@ -62,7 +60,6 @@ Every page/redirect endpoint also returns JSON when the client asks. Core in `de
|
||||
|
||||
- **Page GETs:** `return respond(request, "x.html", context, model=XOut)` - HTML renders the template; JSON does `XOut.model_validate(context).model_dump()`. One context, two renderings.
|
||||
- **Action POSTs:** `return action_result(request, url, data=<resource|None>)` - HTML 302 redirects; JSON returns `{ok, redirect, data}`. (Set cookies on the returned response after calling it, as `auth.py` login/signup do.)
|
||||
- **Refusals:** `return json_error(status_code, message)` - **status first, message second**. Swapping them is not a lint-level mistake, it is a guaranteed 500: `JSONResponse(..., status_code="some message")` raises `TypeError` inside `Response.init_headers` before any byte is written, so the branch that was supposed to explain a limit to the user crashes instead. It was a real production bug - every refusal in the two workspace routers was written `json_error(message, status)`, so a member already at the default 2-workspace quota got a 500 from the workspace page's **Open workspace** button rather than the quota message. `tests/unit/responses.py` now AST-scans the whole package for both argument orders, so the swap cannot come back anywhere.
|
||||
|
||||
Response models live in `devplacepy/schemas.py` (Pydantic v2, `extra="ignore"`, all-Optional so they validate the existing context dicts directly). **Always project users through `UserOut`** (and `AdminUserOut`) - the raw user rows contain `email`/`api_key`/`password_hash`, and the models drop them; never serialize a raw user row. List item shapes vary (feed/gists/news/admin-news are wrapped `{post|gist|article: ...}`; projects are flat rows with `author_name`/`my_vote`) - match the context exactly. Errors negotiate centrally: `main.py` 404/500/validation handlers and the rate-limit/maintenance middleware, plus `utils.require_user`/`require_admin` (401/403 for JSON, 303 redirect for browsers). The four legacy AJAX endpoints (votes/reactions/bookmarks/polls) keep their original flat JSON shapes and are left untouched. Documented in `docs_api.py`'s Conventions group.
|
||||
|
||||
@ -317,14 +314,6 @@ All SEO features are implemented across the following locations:
|
||||
- `database.py` helpers: `get_follow_counts(uid)` (`{followers, following}`), `get_follow_list(uid, mode, page)` (paginated people + `build_pagination`, ordered newest-first), and `get_following_among(follower_uid, target_uids)` (single IN-clause set used to set `is_following` per row, avoiding N+1). `mode` is `"followers"` (people who follow `uid`) or `"following"` (people `uid` follows).
|
||||
- Devii catalog tools `list_followers` / `list_following` (`requires_auth=False`) map to the JSON endpoints; documented in `docs_api.py` under the `profiles` group.
|
||||
|
||||
### Reporting and moderation
|
||||
|
||||
`_report_button.html` is the single report control, included with the same two-variable idiom as `_reaction_bar.html` at **fifteen** sites (`_post_card`, `_comment`, `post`, `gist_detail`, `project_detail`, `news_detail`, `quiz`, `_media_gallery`, `_awards_gallery`, `messages`, `profile`, `project_files`, `issue_detail`, `containers_instance`, `workspace_index`). Locals: `_type`, `_uid`, `_owner` (owner uid, so the control hides on your own content), `_owner_name` (optional; when present the partial also renders the **Block** form, which is what makes blocking reachable from the content rather than only from a profile) and `_class` (the surrounding button class so it inherits each surface's visual language).
|
||||
|
||||
`_report_dialog.html` is included once in `base.html` for signed-in users and driven by `static/js/ReportDialog.js` (`app.reportDialog`) through the standard `.modal-overlay`/`.visible` pattern and `Http.sendForm`. The reason list is the `REPORT_REASONS` Jinja global, sourced from `database/moderation.py`, so the dialog, the API enum, the docs enum and the guidelines page can never drift.
|
||||
|
||||
An e2e coverage test asserts the control is reachable on every include site; the registry test asserts every reportable target resolves. See `devplacepy/services/moderation/CLAUDE.md`.
|
||||
|
||||
### Block and mute (`routers/relations.py`)
|
||||
A logged-in user can **block** or **mute** another user; both are one-directional and reversible. **Block** hides every piece of the blocked user's content from the blocker - posts, comments (any category), feed, listings, issue list, detail pages, and DMs - everywhere EXCEPT the blocked user's own profile page (kept fully visible so the blocker can review and unblock), and it also suppresses any notification that user would generate. **Mute** is the lighter option: it only suppresses the muted user's notifications while their content stays visible. The blocked/muted user is unaffected and is not told.
|
||||
|
||||
|
||||
@ -8,13 +8,11 @@ from devplacepy.routers.admin import (
|
||||
backups,
|
||||
bots,
|
||||
containers,
|
||||
workspaces,
|
||||
devii_tasks,
|
||||
game,
|
||||
gateway_configs,
|
||||
issues,
|
||||
media,
|
||||
moderation,
|
||||
news,
|
||||
notifications,
|
||||
services,
|
||||
@ -31,7 +29,6 @@ router.include_router(aiusage.router)
|
||||
router.include_router(statistics.router)
|
||||
router.include_router(aiquota.router)
|
||||
router.include_router(media.router)
|
||||
router.include_router(moderation.router)
|
||||
router.include_router(trash.router)
|
||||
router.include_router(settings.router)
|
||||
router.include_router(notifications.router)
|
||||
@ -45,4 +42,3 @@ router.include_router(devii_tasks.router)
|
||||
router.include_router(game.router)
|
||||
router.include_router(services.router, prefix="/services")
|
||||
router.include_router(containers.router, prefix="/containers")
|
||||
router.include_router(workspaces.router)
|
||||
|
||||
@ -2,45 +2,6 @@
|
||||
|
||||
import json
|
||||
|
||||
from fastapi import Request
|
||||
|
||||
from devplacepy.responses import action_result
|
||||
from devplacepy.services.audit import record as audit
|
||||
|
||||
|
||||
def seniority_key(user: dict) -> tuple[str, int]:
|
||||
return (user.get("created_at") or "", user.get("id") or 0)
|
||||
|
||||
|
||||
def is_senior_admin(actor: dict, target: dict | None) -> bool:
|
||||
if not target or target.get("role") != "Admin":
|
||||
return False
|
||||
if target.get("uid") == actor.get("uid"):
|
||||
return False
|
||||
return seniority_key(target) < seniority_key(actor)
|
||||
|
||||
|
||||
def deny_senior(
|
||||
request: Request,
|
||||
admin: dict,
|
||||
uid: str,
|
||||
target: dict,
|
||||
event_key: str,
|
||||
redirect_url: str = "/admin/users",
|
||||
):
|
||||
audit.record(
|
||||
request,
|
||||
event_key,
|
||||
user=admin,
|
||||
result="denied",
|
||||
target_type="user",
|
||||
target_uid=uid,
|
||||
target_label=target.get("username"),
|
||||
summary=f"admin {admin['username']} cannot manage senior admin {target.get('username')}",
|
||||
links=[audit.target("user", uid, target.get("username"))],
|
||||
)
|
||||
return action_result(request, redirect_url)
|
||||
|
||||
|
||||
def parse_metadata(raw: str | dict | None) -> dict | None:
|
||||
if not raw:
|
||||
|
||||
@ -1,323 +0,0 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import logging
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
|
||||
from devplacepy.database import (
|
||||
REPORT_STATUSES,
|
||||
SYSTEM_ACTOR,
|
||||
get_table,
|
||||
get_users_by_uids,
|
||||
)
|
||||
from devplacepy.dependencies import json_or_form
|
||||
from devplacepy.models import ModerationDecisionForm, ReportStatusForm
|
||||
from devplacepy.responses import action_result, json_error, respond
|
||||
from devplacepy.routers.admin._shared import deny_senior, is_senior_admin
|
||||
from devplacepy.schemas import AdminModerationOut, AdminReportOut
|
||||
from devplacepy.seo import base_seo_context, site_url, website_schema
|
||||
from devplacepy.services.audit import record as audit
|
||||
from devplacepy.services.moderation import enforcement, queue, sla
|
||||
from devplacepy.utils import create_notification, not_found, require_admin
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
QUEUE_URL = "/admin/moderation"
|
||||
|
||||
STATUS_TABS = [
|
||||
{"key": "open", "label": "Open"},
|
||||
{"key": "acknowledged", "label": "Acknowledged"},
|
||||
{"key": "actioned", "label": "Actioned"},
|
||||
{"key": "dismissed", "label": "Dismissed"},
|
||||
]
|
||||
|
||||
SUBJECT_ACTIONS = ("warn", "suspend", "ban", "lift")
|
||||
|
||||
ENFORCEMENT_EVENTS = {
|
||||
"remove_content": "moderation.remove",
|
||||
"restore_content": "moderation.restore",
|
||||
"warn": "moderation.warn",
|
||||
"suspend": "moderation.suspend",
|
||||
"ban": "moderation.ban",
|
||||
"lift": "moderation.lift",
|
||||
}
|
||||
|
||||
DECISION_MESSAGES = {
|
||||
"remove_content": "Your {target} was removed after a moderation review.",
|
||||
"restore_content": "Your {target} was restored after a moderation review.",
|
||||
"warn": "A moderator issued a warning about your {target}.",
|
||||
"suspend": "Your account is suspended following a moderation review.",
|
||||
"ban": "Your account has been closed following a moderation review.",
|
||||
"lift": "Your account restriction has been lifted.",
|
||||
}
|
||||
|
||||
|
||||
def _breadcrumbs(extra: list[dict] | None = None) -> list[dict]:
|
||||
trail = [
|
||||
{"name": "Home", "url": "/feed"},
|
||||
{"name": "Admin", "url": "/admin"},
|
||||
{"name": "Moderation", "url": QUEUE_URL},
|
||||
]
|
||||
return trail + (extra or [])
|
||||
|
||||
|
||||
def _available_actions(target_type: str) -> list[str]:
|
||||
actions = ["dismiss", "escalate"]
|
||||
if enforcement.can_remove(target_type):
|
||||
actions = ["remove_content", "restore_content"] + actions
|
||||
return actions + list(SUBJECT_ACTIONS)
|
||||
|
||||
|
||||
def _subject(report: dict) -> dict | None:
|
||||
owner_uid = report.get("owner_uid") or ""
|
||||
if not owner_uid:
|
||||
return None
|
||||
return get_users_by_uids([owner_uid]).get(owner_uid)
|
||||
|
||||
|
||||
def _action_view(rows: list[dict]) -> list[dict]:
|
||||
actors = get_users_by_uids([row.get("actor_uid") for row in rows if row.get("actor_uid")])
|
||||
view = []
|
||||
for row in rows:
|
||||
actor = actors.get(row.get("actor_uid"))
|
||||
view.append(
|
||||
{
|
||||
"uid": row["uid"],
|
||||
"report_uid": row.get("report_uid", ""),
|
||||
"action": row.get("action", ""),
|
||||
"actor_name": actor["username"] if actor else row.get("actor_uid", ""),
|
||||
"reason": row.get("reason", ""),
|
||||
"notes": row.get("notes", ""),
|
||||
"expires_at": row.get("expires_at", ""),
|
||||
"created_at": row.get("created_at", ""),
|
||||
}
|
||||
)
|
||||
return view
|
||||
|
||||
|
||||
@router.get("/moderation", response_class=HTMLResponse)
|
||||
async def admin_moderation(request: Request, status: str = "open", page: int = 1):
|
||||
admin = require_admin(request)
|
||||
if status not in REPORT_STATUSES:
|
||||
status = "open"
|
||||
reports, pagination = queue.list_reports(status=status, page=page)
|
||||
counts = queue.status_counts()
|
||||
base = site_url(request)
|
||||
seo_ctx = base_seo_context(
|
||||
request,
|
||||
title="Moderation - Admin",
|
||||
description="Triage reported content and apply moderation decisions.",
|
||||
robots="noindex,nofollow",
|
||||
breadcrumbs=_breadcrumbs(),
|
||||
schemas=[website_schema(base)],
|
||||
)
|
||||
return respond(
|
||||
request,
|
||||
"admin_moderation.html",
|
||||
{
|
||||
**seo_ctx,
|
||||
"request": request,
|
||||
"user": admin,
|
||||
"reports": reports,
|
||||
"pagination": pagination,
|
||||
"status": status,
|
||||
"statuses": [
|
||||
{**tab, "count": counts.get(tab["key"], 0), "active": tab["key"] == status}
|
||||
for tab in STATUS_TABS
|
||||
],
|
||||
"counts": counts,
|
||||
"sla": sla.snapshot(),
|
||||
"admin_section": "moderation",
|
||||
},
|
||||
model=AdminModerationOut,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/moderation/{uid}", response_class=HTMLResponse)
|
||||
async def admin_report_detail(request: Request, uid: str):
|
||||
admin = require_admin(request)
|
||||
report = queue.get_report(uid)
|
||||
if not report:
|
||||
raise not_found("Report not found")
|
||||
view = queue.enrich_reports([report])[0]
|
||||
subject = _subject(report)
|
||||
base = site_url(request)
|
||||
seo_ctx = base_seo_context(
|
||||
request,
|
||||
title=f"Report {uid} - Admin",
|
||||
description="One reported item and the decisions taken on it.",
|
||||
robots="noindex,nofollow",
|
||||
breadcrumbs=_breadcrumbs([{"name": "Report", "url": f"{QUEUE_URL}/{uid}"}]),
|
||||
schemas=[website_schema(base)],
|
||||
)
|
||||
return respond(
|
||||
request,
|
||||
"admin_report.html",
|
||||
{
|
||||
**seo_ctx,
|
||||
"request": request,
|
||||
"user": admin,
|
||||
"report": view,
|
||||
"actions": _action_view(queue.actions_for_report(uid)),
|
||||
"history": _action_view(
|
||||
queue.actions_for_subject(report.get("owner_uid", ""))
|
||||
),
|
||||
"available_actions": _available_actions(report["target_type"]),
|
||||
"can_remove": enforcement.can_remove(report["target_type"]),
|
||||
"subject": subject,
|
||||
"sla": sla.snapshot(),
|
||||
"admin_section": "moderation",
|
||||
},
|
||||
model=AdminReportOut,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/moderation/{uid}/status")
|
||||
async def admin_report_status(
|
||||
request: Request,
|
||||
uid: str,
|
||||
data: Annotated[ReportStatusForm, Depends(json_or_form(ReportStatusForm))],
|
||||
):
|
||||
admin = require_admin(request)
|
||||
report = queue.get_report(uid)
|
||||
if not report:
|
||||
raise not_found("Report not found")
|
||||
updated = queue.set_status(uid, data.status, admin["uid"])
|
||||
if not updated:
|
||||
return json_error(400, "Report status could not be changed")
|
||||
logger.info(f"Admin {admin['username']} set report {uid} to {data.status}")
|
||||
audit.record(
|
||||
request,
|
||||
"report.status",
|
||||
user=admin,
|
||||
target_type=report["target_type"],
|
||||
target_uid=report["target_uid"],
|
||||
old_value=report.get("status"),
|
||||
new_value=data.status,
|
||||
metadata={"report_uid": uid},
|
||||
summary=f"{admin['username']} set report {uid} to {data.status}",
|
||||
links=[audit.target(report["target_type"], report["target_uid"])],
|
||||
)
|
||||
return action_result(request, f"{QUEUE_URL}/{uid}")
|
||||
|
||||
|
||||
@router.post("/moderation/{uid}/decide")
|
||||
async def admin_report_decide(
|
||||
request: Request,
|
||||
uid: str,
|
||||
data: Annotated[ModerationDecisionForm, Depends(json_or_form(ModerationDecisionForm))],
|
||||
):
|
||||
admin = require_admin(request)
|
||||
report = queue.get_report(uid)
|
||||
if not report:
|
||||
raise not_found("Report not found")
|
||||
action = data.action
|
||||
if action not in _available_actions(report["target_type"]):
|
||||
return json_error(400, "That action does not apply to this target")
|
||||
subject = _subject(report)
|
||||
if action in SUBJECT_ACTIONS:
|
||||
if not subject:
|
||||
return json_error(400, "This report has no account to act on")
|
||||
if is_senior_admin(admin, subject):
|
||||
return deny_senior(
|
||||
request,
|
||||
admin,
|
||||
subject["uid"],
|
||||
subject,
|
||||
f"moderation.{action}",
|
||||
redirect_url=f"{QUEUE_URL}/{uid}",
|
||||
)
|
||||
if action == "escalate":
|
||||
queue.escalate(uid)
|
||||
else:
|
||||
outcome = "dismissed" if action == "dismiss" else "actioned"
|
||||
if not queue.claim_open(uid, outcome, admin["uid"]):
|
||||
return json_error(409, "This report was already resolved")
|
||||
expires_at = ""
|
||||
if action == "remove_content":
|
||||
enforcement.remove_content(
|
||||
request, admin, report["target_type"], report["target_uid"]
|
||||
)
|
||||
elif action == "restore_content":
|
||||
enforcement.restore_content(report["target_type"], report["target_uid"])
|
||||
elif action == "suspend":
|
||||
expires_at = enforcement.suspend_user(subject, data.duration_hours, data.reason)
|
||||
elif action == "ban":
|
||||
enforcement.ban_user(subject, data.reason)
|
||||
elif action == "lift":
|
||||
enforcement.lift_suspension(subject)
|
||||
enforcement.unban_user(subject)
|
||||
queue.record_action(
|
||||
report_uid=uid,
|
||||
actor_uid=admin["uid"],
|
||||
action=action,
|
||||
target_type=report["target_type"],
|
||||
target_uid=report["target_uid"],
|
||||
subject_uid=subject["uid"] if subject else "",
|
||||
reason=data.reason,
|
||||
notes=data.notes,
|
||||
expires_at=expires_at,
|
||||
)
|
||||
logger.info(f"Admin {admin['username']} applied {action} to report {uid}")
|
||||
metadata = {
|
||||
"report_uid": uid,
|
||||
"action": action,
|
||||
"reason": data.reason,
|
||||
"subject_uid": subject["uid"] if subject else "",
|
||||
}
|
||||
links = [audit.target(report["target_type"], report["target_uid"])]
|
||||
audit.record(
|
||||
request,
|
||||
"report.decide",
|
||||
user=admin,
|
||||
target_type=report["target_type"],
|
||||
target_uid=report["target_uid"],
|
||||
metadata=metadata,
|
||||
summary=f"{admin['username']} applied {action} on report {uid}",
|
||||
links=links,
|
||||
)
|
||||
enforcement_key = ENFORCEMENT_EVENTS.get(action)
|
||||
if enforcement_key:
|
||||
audit.record(
|
||||
request,
|
||||
enforcement_key,
|
||||
user=admin,
|
||||
target_type=report["target_type"],
|
||||
target_uid=report["target_uid"],
|
||||
metadata=metadata,
|
||||
summary=f"{admin['username']} applied {action} from report {uid}",
|
||||
links=links,
|
||||
)
|
||||
_notify_subject(subject, action, report, data.reason)
|
||||
if action != "escalate":
|
||||
_notify_reporter(report, action)
|
||||
return action_result(request, f"{QUEUE_URL}/{uid}")
|
||||
|
||||
|
||||
def _notify_subject(subject: dict | None, action: str, report: dict, reason: str) -> None:
|
||||
template = DECISION_MESSAGES.get(action)
|
||||
if not subject or not template:
|
||||
return
|
||||
message = template.format(target=report["target_type"])
|
||||
if reason:
|
||||
message = f"{message} Reason: {reason}."
|
||||
enforcement.notify_subject(subject["uid"], message)
|
||||
|
||||
|
||||
def _notify_reporter(report: dict, action: str) -> None:
|
||||
reporter_uid = report.get("reporter_uid") or ""
|
||||
if not reporter_uid or reporter_uid == SYSTEM_ACTOR:
|
||||
return
|
||||
if not get_table("users").find_one(uid=reporter_uid):
|
||||
return
|
||||
verb = "dismissed" if action == "dismiss" else "actioned"
|
||||
create_notification(
|
||||
reporter_uid,
|
||||
"moderation",
|
||||
f"Your report on a {report['target_type']} was {verb}.",
|
||||
reporter_uid,
|
||||
"/reports/mine",
|
||||
)
|
||||
@ -4,13 +4,12 @@ import logging
|
||||
from typing import Annotated
|
||||
from fastapi import Depends, APIRouter, Request
|
||||
from fastapi.responses import HTMLResponse, JSONResponse
|
||||
from devplacepy.models import AdminRoleForm, AdminPasswordForm, BanForm, SuspensionForm
|
||||
from devplacepy.models import AdminRoleForm, AdminPasswordForm
|
||||
from devplacepy.database import (
|
||||
get_table,
|
||||
build_pagination,
|
||||
get_post_counts_by_user_uids,
|
||||
invalidate_admins_cache,
|
||||
is_account_active,
|
||||
)
|
||||
from devplacepy.utils import (
|
||||
require_admin,
|
||||
@ -21,16 +20,37 @@ from devplacepy.seo import base_seo_context, site_url, website_schema
|
||||
from devplacepy.responses import respond, action_result
|
||||
from devplacepy.schemas import AdminUsersOut, UserAiUsageOut
|
||||
from devplacepy.services.audit import record as audit
|
||||
from devplacepy.services.moderation import enforcement, queue
|
||||
from devplacepy.services.manager import service_manager
|
||||
from devplacepy.services.openai_gateway.analytics import build_user_usage
|
||||
from devplacepy.services.openai_gateway.usage import pricing_from_cfg
|
||||
from devplacepy.dependencies import json_or_form
|
||||
from devplacepy.routers.admin._shared import deny_senior, is_senior_admin
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
def _seniority_key(u: dict) -> tuple[str, int]:
|
||||
return (u.get("created_at") or "", u.get("id") or 0)
|
||||
|
||||
def _is_senior_admin(actor: dict, target: dict | None) -> bool:
|
||||
if not target or target.get("role") != "Admin":
|
||||
return False
|
||||
if target.get("uid") == actor.get("uid"):
|
||||
return False
|
||||
return _seniority_key(target) < _seniority_key(actor)
|
||||
|
||||
def _deny_senior(request: Request, admin: dict, uid: str, target: dict, event_key: str):
|
||||
audit.record(
|
||||
request,
|
||||
event_key,
|
||||
user=admin,
|
||||
result="denied",
|
||||
target_type="user",
|
||||
target_uid=uid,
|
||||
target_label=target.get("username"),
|
||||
summary=f"admin {admin['username']} cannot manage senior admin {target.get('username')}",
|
||||
links=[audit.target("user", uid, target.get("username"))],
|
||||
)
|
||||
return action_result(request, "/admin/users")
|
||||
|
||||
@router.get("/users/{uid}/ai-usage")
|
||||
async def admin_user_ai_usage(request: Request, uid: str, hours: int = 24):
|
||||
@ -103,8 +123,8 @@ async def admin_user_role(
|
||||
return action_result(request, "/admin/users")
|
||||
users = get_table("users")
|
||||
target_user = users.find_one(uid=uid)
|
||||
if is_senior_admin(admin, target_user):
|
||||
return deny_senior(request, admin, uid, target_user, "admin.user.role.change")
|
||||
if _is_senior_admin(admin, target_user):
|
||||
return _deny_senior(request, admin, uid, target_user, "admin.user.role.change")
|
||||
old_role = target_user.get("role") if target_user else None
|
||||
users.update({"uid": uid, "role": role}, ["uid"])
|
||||
clear_user_cache(uid)
|
||||
@ -135,8 +155,8 @@ async def admin_user_password(
|
||||
admin = require_admin(request)
|
||||
users = get_table("users")
|
||||
target_user = users.find_one(uid=uid)
|
||||
if is_senior_admin(admin, target_user):
|
||||
return deny_senior(request, admin, uid, target_user, "admin.user.password.reset")
|
||||
if _is_senior_admin(admin, target_user):
|
||||
return _deny_senior(request, admin, uid, target_user, "admin.user.password.reset")
|
||||
users.update({"uid": uid, "password_hash": await hash_password_async(data.password)}, ["uid"])
|
||||
logger.info(f"Admin {admin['username']} changed password for user {uid}")
|
||||
audit.record(
|
||||
@ -173,10 +193,10 @@ async def admin_user_toggle(request: Request, uid: str):
|
||||
return action_result(request, "/admin/users")
|
||||
users = get_table("users")
|
||||
user = users.find_one(uid=uid)
|
||||
if is_senior_admin(admin, user):
|
||||
return deny_senior(request, admin, uid, user, "admin.user.active.disable")
|
||||
if _is_senior_admin(admin, user):
|
||||
return _deny_senior(request, admin, uid, user, "admin.user.active.disable")
|
||||
if user:
|
||||
new_state = not is_account_active(user)
|
||||
new_state = not user.get("is_active", True)
|
||||
users.update({"uid": uid, "is_active": new_state}, ["uid"])
|
||||
clear_user_cache(uid)
|
||||
logger.info(
|
||||
@ -195,121 +215,12 @@ async def admin_user_toggle(request: Request, uid: str):
|
||||
)
|
||||
return action_result(request, "/admin/users")
|
||||
|
||||
def _enforcement_target(request: Request, admin: dict, uid: str, event_key: str):
|
||||
if uid == admin["uid"]:
|
||||
audit.record(
|
||||
request,
|
||||
event_key,
|
||||
user=admin,
|
||||
result="denied",
|
||||
target_type="user",
|
||||
target_uid=uid,
|
||||
target_label=admin.get("username"),
|
||||
summary=f"admin {admin['username']} cannot enforce against their own account",
|
||||
links=[audit.target("user", uid, admin.get("username"))],
|
||||
)
|
||||
return None, action_result(request, "/admin/users")
|
||||
target_user = get_table("users").find_one(uid=uid)
|
||||
if not target_user:
|
||||
return None, action_result(request, "/admin/users")
|
||||
if is_senior_admin(admin, target_user):
|
||||
return None, deny_senior(request, admin, uid, target_user, event_key)
|
||||
return target_user, None
|
||||
|
||||
|
||||
def _record_enforcement(
|
||||
request: Request, admin: dict, target_user: dict, event_key: str, metadata: dict
|
||||
):
|
||||
logger.info(
|
||||
f"Admin {admin['username']} applied {event_key} to {target_user['username']}"
|
||||
)
|
||||
audit.record(
|
||||
request,
|
||||
event_key,
|
||||
user=admin,
|
||||
target_type="user",
|
||||
target_uid=target_user["uid"],
|
||||
target_label=target_user.get("username"),
|
||||
metadata=metadata,
|
||||
summary=f"{admin['username']} applied {event_key} to {target_user['username']}",
|
||||
links=[audit.target("user", target_user["uid"], target_user.get("username"))],
|
||||
)
|
||||
queue.record_action(
|
||||
report_uid="",
|
||||
actor_uid=admin["uid"],
|
||||
action=event_key.rsplit(".", 1)[-1],
|
||||
target_type="user",
|
||||
target_uid=target_user["uid"],
|
||||
subject_uid=target_user["uid"],
|
||||
reason=metadata.get("reason", ""),
|
||||
expires_at=metadata.get("expires_at", ""),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/users/{uid}/suspend")
|
||||
async def admin_user_suspend(
|
||||
request: Request,
|
||||
uid: str,
|
||||
data: Annotated[SuspensionForm, Depends(json_or_form(SuspensionForm))],
|
||||
):
|
||||
admin = require_admin(request)
|
||||
target_user, refusal = _enforcement_target(request, admin, uid, "moderation.suspend")
|
||||
if refusal is not None:
|
||||
return refusal
|
||||
expires_at = enforcement.suspend_user(target_user, data.duration_hours, data.reason)
|
||||
_record_enforcement(
|
||||
request,
|
||||
admin,
|
||||
target_user,
|
||||
"moderation.suspend",
|
||||
{"reason": data.reason, "expires_at": expires_at, "hours": data.duration_hours},
|
||||
)
|
||||
enforcement.notify_subject(
|
||||
uid,
|
||||
f"Your account is suspended until {expires_at}. Reason: {data.reason or 'policy violation'}.",
|
||||
)
|
||||
return action_result(request, "/admin/users")
|
||||
|
||||
|
||||
@router.post("/users/{uid}/lift")
|
||||
async def admin_user_lift(request: Request, uid: str):
|
||||
admin = require_admin(request)
|
||||
target_user, refusal = _enforcement_target(request, admin, uid, "moderation.lift")
|
||||
if refusal is not None:
|
||||
return refusal
|
||||
enforcement.lift_suspension(target_user)
|
||||
enforcement.unban_user(target_user)
|
||||
_record_enforcement(request, admin, target_user, "moderation.lift", {})
|
||||
enforcement.notify_subject(uid, "Your account restriction has been lifted.")
|
||||
return action_result(request, "/admin/users")
|
||||
|
||||
|
||||
@router.post("/users/{uid}/ban")
|
||||
async def admin_user_ban(
|
||||
request: Request,
|
||||
uid: str,
|
||||
data: Annotated[BanForm, Depends(json_or_form(BanForm))],
|
||||
):
|
||||
admin = require_admin(request)
|
||||
target_user, refusal = _enforcement_target(request, admin, uid, "moderation.ban")
|
||||
if refusal is not None:
|
||||
return refusal
|
||||
enforcement.ban_user(target_user, data.reason)
|
||||
_record_enforcement(
|
||||
request, admin, target_user, "moderation.ban", {"reason": data.reason}
|
||||
)
|
||||
enforcement.notify_subject(
|
||||
uid, f"Your account has been closed. Reason: {data.reason or 'policy violation'}."
|
||||
)
|
||||
return action_result(request, "/admin/users")
|
||||
|
||||
|
||||
@router.post("/users/{uid}/reset-ai-quota")
|
||||
async def admin_user_reset_ai_quota(request: Request, uid: str):
|
||||
admin = require_admin(request)
|
||||
target_user = get_table("users").find_one(uid=uid)
|
||||
if is_senior_admin(admin, target_user):
|
||||
return deny_senior(request, admin, uid, target_user, "admin.user.ai_quota.reset")
|
||||
if _is_senior_admin(admin, target_user):
|
||||
return _deny_senior(request, admin, uid, target_user, "admin.user.ai_quota.reset")
|
||||
devii = service_manager.get_service("devii")
|
||||
removed = devii.reset_quota("user", uid) if devii is not None else 0
|
||||
logger.info(
|
||||
|
||||
@ -1,282 +0,0 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
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.dependencies import json_or_form
|
||||
from devplacepy.models import (
|
||||
WorkspaceFlagForm,
|
||||
WorkspaceQuotaForm,
|
||||
WorkspaceSuspendForm,
|
||||
)
|
||||
from devplacepy.responses import action_result, json_error, respond
|
||||
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.utils import create_notification, generate_uid, not_found, require_admin
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _decorate(rows: list[dict]) -> list[dict]:
|
||||
owner_uids = {row.get("workspace_owner_uid") for row in rows if row.get("workspace_owner_uid")}
|
||||
owners = get_users_by_uids(list(owner_uids)) if owner_uids else {}
|
||||
projects = {}
|
||||
if "projects" in db.tables:
|
||||
project_uids = {row.get("project_uid") for row in rows if row.get("project_uid")}
|
||||
for uid in project_uids:
|
||||
found = get_table("projects").find_one(uid=uid)
|
||||
if found:
|
||||
projects[uid] = found
|
||||
decorated = []
|
||||
for row in rows:
|
||||
view = provision.view(row)
|
||||
owner = owners.get(row.get("workspace_owner_uid", "")) or {}
|
||||
project = projects.get(row.get("project_uid", "")) or {}
|
||||
view["owner_username"] = owner.get("username", "")
|
||||
view["project_title"] = project.get("title", "")
|
||||
view["project_slug"] = project.get("slug", "") or project.get("uid", "")
|
||||
decorated.append(view)
|
||||
return decorated
|
||||
|
||||
|
||||
def _all_workspaces() -> list[dict]:
|
||||
return list(get_table("instances").find(is_workspace=1, deleted_at=None))
|
||||
|
||||
|
||||
def _instance_or_404(uid: str) -> dict:
|
||||
instance = store.get_instance(uid)
|
||||
if not instance or not instance.get("is_workspace"):
|
||||
raise not_found("Workspace not found")
|
||||
return instance
|
||||
|
||||
|
||||
def _audit(request: Request, admin: dict, event_key: str, instance: dict, **extra):
|
||||
audit.record(
|
||||
request,
|
||||
event_key,
|
||||
user=admin,
|
||||
target_type="instance",
|
||||
target_uid=instance["uid"],
|
||||
target_label=instance.get("name"),
|
||||
summary=f"{admin['username']} {event_key} workspace {instance.get('name')}",
|
||||
links=[audit.instance(instance["uid"], instance.get("name"))],
|
||||
**extra,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/workspaces")
|
||||
async def admin_workspaces(request: Request):
|
||||
admin = require_admin(request)
|
||||
if not isinstance(admin, dict):
|
||||
return admin
|
||||
context = {
|
||||
"workspaces": _decorate(_all_workspaces()),
|
||||
"flags": flags.list_flags(),
|
||||
"admin_section": "workspaces",
|
||||
"user": admin,
|
||||
**base_seo_context(
|
||||
request,
|
||||
title="Workspaces - Admin",
|
||||
description="Administer dev workspaces.",
|
||||
robots="noindex,nofollow",
|
||||
breadcrumbs=[
|
||||
{"name": "Home", "url": "/feed"},
|
||||
{"name": "Admin", "url": "/admin"},
|
||||
{"name": "Workspaces", "url": "/admin/workspaces"},
|
||||
],
|
||||
),
|
||||
}
|
||||
return respond(request, "admin_workspaces.html", context, model=AdminWorkspacesOut)
|
||||
|
||||
|
||||
@router.get("/workspaces/data")
|
||||
async def admin_workspaces_data(request: Request):
|
||||
admin = require_admin(request)
|
||||
if not isinstance(admin, dict):
|
||||
return admin
|
||||
return JSONResponse(
|
||||
{"workspaces": _decorate(_all_workspaces()), "flags": flags.list_flags()}
|
||||
)
|
||||
|
||||
|
||||
@router.post("/workspaces/{uid}/suspend")
|
||||
async def admin_workspace_suspend(
|
||||
request: Request,
|
||||
uid: str,
|
||||
data: Annotated[WorkspaceSuspendForm, Depends(json_or_form(WorkspaceSuspendForm))],
|
||||
):
|
||||
admin = require_admin(request)
|
||||
if not isinstance(admin, dict):
|
||||
return admin
|
||||
instance = _instance_or_404(uid)
|
||||
reason = (data.reason or "").strip()
|
||||
if not reason:
|
||||
return json_error(400, "a reason is required and is shown to the owner")
|
||||
provision.suspend(instance, admin["uid"], reason)
|
||||
_audit(request, admin, "container.workspace.suspend", instance, metadata={"reason": reason})
|
||||
owner = instance.get("workspace_owner_uid", "")
|
||||
if owner:
|
||||
create_notification(
|
||||
owner,
|
||||
"workspace",
|
||||
f"Workspace {instance.get('name', '')} was suspended: {reason}",
|
||||
instance["uid"],
|
||||
"/projects",
|
||||
)
|
||||
return action_result(request, "/admin/workspaces")
|
||||
|
||||
|
||||
@router.post("/workspaces/{uid}/unsuspend")
|
||||
async def admin_workspace_unsuspend(request: Request, uid: str):
|
||||
admin = require_admin(request)
|
||||
if not isinstance(admin, dict):
|
||||
return admin
|
||||
instance = _instance_or_404(uid)
|
||||
provision.unsuspend(instance)
|
||||
_audit(request, admin, "container.workspace.unsuspend", instance)
|
||||
owner = instance.get("workspace_owner_uid", "")
|
||||
if owner:
|
||||
create_notification(
|
||||
owner,
|
||||
"workspace",
|
||||
f"Workspace {instance.get('name', '')} is available again.",
|
||||
instance["uid"],
|
||||
"/projects",
|
||||
)
|
||||
return action_result(request, "/admin/workspaces")
|
||||
|
||||
|
||||
@router.post("/workspaces/{uid}/stop")
|
||||
async def admin_workspace_stop(request: Request, uid: str):
|
||||
admin = require_admin(request)
|
||||
if not isinstance(admin, dict):
|
||||
return admin
|
||||
instance = _instance_or_404(uid)
|
||||
provision.stop(instance)
|
||||
_audit(request, admin, "container.workspace.stop", instance)
|
||||
return action_result(request, "/admin/workspaces")
|
||||
|
||||
|
||||
@router.post("/workspaces/{uid}/start")
|
||||
async def admin_workspace_start(request: Request, uid: str):
|
||||
admin = require_admin(request)
|
||||
if not isinstance(admin, dict):
|
||||
return admin
|
||||
instance = _instance_or_404(uid)
|
||||
store.update_instance(instance["uid"], {"desired_state": "running"})
|
||||
_audit(request, admin, "container.workspace.resume", instance)
|
||||
return action_result(request, "/admin/workspaces")
|
||||
|
||||
|
||||
@router.post("/workspaces/{uid}/delete")
|
||||
async def admin_workspace_delete(request: Request, uid: str):
|
||||
admin = require_admin(request)
|
||||
if not isinstance(admin, dict):
|
||||
return admin
|
||||
instance = _instance_or_404(uid)
|
||||
for row in tunnels.list_for_instance(instance["uid"]):
|
||||
tunnels.soft_delete(row["uid"], admin["uid"])
|
||||
store.delete_instance(instance["uid"], admin["uid"])
|
||||
_audit(request, admin, "container.workspace.delete", instance)
|
||||
return action_result(request, "/admin/workspaces")
|
||||
|
||||
|
||||
@router.post("/workspaces/{uid}/flag")
|
||||
async def admin_workspace_flag(
|
||||
request: Request,
|
||||
uid: str,
|
||||
data: Annotated[WorkspaceFlagForm, Depends(json_or_form(WorkspaceFlagForm))],
|
||||
):
|
||||
admin = require_admin(request)
|
||||
if not isinstance(admin, dict):
|
||||
return admin
|
||||
instance = _instance_or_404(uid)
|
||||
row = flags.raise_flag(
|
||||
instance, data.kind or flags.KIND_MANUAL, data.severity, data.detail
|
||||
)
|
||||
_audit(request, admin, "container.workspace.flag.raise", instance,
|
||||
metadata={"kind": data.kind, "severity": data.severity})
|
||||
owner = instance.get("workspace_owner_uid", "")
|
||||
if owner:
|
||||
create_notification(
|
||||
owner,
|
||||
"workspace",
|
||||
f"Workspace {instance.get('name', '')} was flagged: {data.detail or data.kind}",
|
||||
instance["uid"],
|
||||
"/projects",
|
||||
)
|
||||
return action_result(request, "/admin/workspaces", data=row)
|
||||
|
||||
|
||||
@router.post("/workspaces/flags/{flag_uid}/resolve")
|
||||
async def admin_flag_resolve(request: Request, flag_uid: str, status: str = "resolved"):
|
||||
admin = require_admin(request)
|
||||
if not isinstance(admin, dict):
|
||||
return admin
|
||||
if not flags.set_status(flag_uid, status, admin["uid"]):
|
||||
raise not_found("Flag not found")
|
||||
event = (
|
||||
"container.workspace.flag.dismiss"
|
||||
if status == "dismissed"
|
||||
else "container.workspace.flag.resolve"
|
||||
)
|
||||
audit.record(
|
||||
request,
|
||||
event,
|
||||
user=admin,
|
||||
target_type="workspace_flag",
|
||||
target_uid=flag_uid,
|
||||
summary=f"{admin['username']} set flag {flag_uid} to {status}",
|
||||
)
|
||||
return action_result(request, "/admin/workspaces")
|
||||
|
||||
|
||||
@router.post("/workspaces/quota")
|
||||
async def admin_workspace_quota(
|
||||
request: Request,
|
||||
data: Annotated[WorkspaceQuotaForm, Depends(json_or_form(WorkspaceQuotaForm))],
|
||||
):
|
||||
admin = require_admin(request)
|
||||
if not isinstance(admin, dict):
|
||||
return admin
|
||||
if not data.owner_id:
|
||||
return json_error(400, "owner_id is required")
|
||||
table = get_table(quota.RULES_TABLE)
|
||||
existing = table.find_one(
|
||||
owner_kind="user", owner_id=data.owner_id, deleted_at=None
|
||||
)
|
||||
payload = {key: getattr(data, key) for key in quota.RULE_COLUMNS}
|
||||
if existing:
|
||||
table.update({"uid": existing["uid"], "label": data.label, **payload}, ["uid"])
|
||||
uid = existing["uid"]
|
||||
else:
|
||||
uid = generate_uid()
|
||||
table.insert(
|
||||
{
|
||||
"uid": uid,
|
||||
"owner_kind": "user",
|
||||
"owner_id": data.owner_id,
|
||||
"label": data.label,
|
||||
"created_at": "",
|
||||
"updated_at": "",
|
||||
"deleted_at": None,
|
||||
"deleted_by": None,
|
||||
**payload,
|
||||
}
|
||||
)
|
||||
audit.record(
|
||||
request,
|
||||
"container.workspace.settings.update",
|
||||
user=admin,
|
||||
target_type="user",
|
||||
target_uid=data.owner_id,
|
||||
summary=f"{admin['username']} updated workspace quota",
|
||||
metadata=payload,
|
||||
)
|
||||
return action_result(request, "/admin/workspaces", data={"uid": uid, **payload})
|
||||
@ -8,7 +8,6 @@ from devplacepy.routers.auth import (
|
||||
logout,
|
||||
resetpassword,
|
||||
signup,
|
||||
terms,
|
||||
token,
|
||||
)
|
||||
|
||||
@ -19,4 +18,3 @@ router.include_router(token.router)
|
||||
router.include_router(forgotpassword.router)
|
||||
router.include_router(resetpassword.router)
|
||||
router.include_router(logout.router)
|
||||
router.include_router(terms.router)
|
||||
|
||||
@ -4,7 +4,7 @@ import logging
|
||||
from typing import Annotated
|
||||
from fastapi import Depends, APIRouter, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
from devplacepy.database import get_table, get_int_setting, is_account_active
|
||||
from devplacepy.database import get_table, get_int_setting
|
||||
from devplacepy.templating import templates
|
||||
from devplacepy.utils import (
|
||||
verify_password_async,
|
||||
@ -59,7 +59,7 @@ async def login(request: Request, data: Annotated[LoginForm, Depends(json_or_for
|
||||
|
||||
if not user or not await verify_password_async(password, user["password_hash"]):
|
||||
errors.append("Invalid email or password")
|
||||
elif not is_account_active(user):
|
||||
elif not user.get("is_active", True):
|
||||
errors.append("Account is deactivated")
|
||||
|
||||
if errors:
|
||||
|
||||
@ -90,9 +90,7 @@ async def signup(request: Request, data: Annotated[SignupForm, Depends(json_or_f
|
||||
},
|
||||
)
|
||||
|
||||
uid, role, is_first = await register_account_async(
|
||||
username, email, password, age_band=data.age_band, accepted_terms=True
|
||||
)
|
||||
uid, role, is_first = await register_account_async(username, email, password)
|
||||
|
||||
max_age = max(1, get_int_setting("session_max_age_days", 7)) * SECONDS_PER_DAY
|
||||
token = create_session(uid, max_age)
|
||||
|
||||
@ -1,84 +0,0 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
|
||||
from devplacepy.database import _now_iso, get_setting, get_table, set_consent
|
||||
from devplacepy.responses import action_result, respond
|
||||
from devplacepy.schemas import AcceptTermsOut
|
||||
from devplacepy.seo import base_seo_context
|
||||
from devplacepy.services.audit import record as audit
|
||||
from devplacepy.utils import clear_user_cache, require_user
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
TERMS_ACCEPTANCE_CODE = "terms_acceptance_required"
|
||||
|
||||
|
||||
def current_terms_version() -> str:
|
||||
return get_setting("terms_version", "1") or "1"
|
||||
|
||||
|
||||
def needs_acceptance(user: dict | None) -> bool:
|
||||
if not user:
|
||||
return False
|
||||
return (user.get("terms_version") or "") != current_terms_version()
|
||||
|
||||
|
||||
@router.get("/accept-terms", response_class=HTMLResponse)
|
||||
async def accept_terms_page(request: Request):
|
||||
user = require_user(request)
|
||||
seo_ctx = base_seo_context(
|
||||
request,
|
||||
title="Accept the updated terms",
|
||||
description="The Terms of Service changed. Accept the new version to continue.",
|
||||
robots="noindex,nofollow",
|
||||
)
|
||||
return respond(
|
||||
request,
|
||||
"accept_terms.html",
|
||||
{
|
||||
**seo_ctx,
|
||||
"request": request,
|
||||
"user": user,
|
||||
"terms_version": current_terms_version(),
|
||||
"accepted_version": user.get("terms_version") or "",
|
||||
},
|
||||
model=AcceptTermsOut,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/accept-terms")
|
||||
async def accept_terms(request: Request):
|
||||
user = require_user(request)
|
||||
version = current_terms_version()
|
||||
now = _now_iso()
|
||||
get_table("users").update(
|
||||
{"uid": user["uid"], "terms_version": version, "terms_accepted_at": now},
|
||||
["uid"],
|
||||
)
|
||||
set_consent("user", user["uid"], "terms", True, version=version)
|
||||
set_consent(
|
||||
"user",
|
||||
user["uid"],
|
||||
"privacy",
|
||||
True,
|
||||
version=get_setting("privacy_version", "1") or "1",
|
||||
)
|
||||
clear_user_cache(user["uid"])
|
||||
logger.info(f"{user['username']} accepted terms version {version}")
|
||||
audit.record(
|
||||
request,
|
||||
"terms.accept",
|
||||
user=user,
|
||||
target_type="user",
|
||||
target_uid=user["uid"],
|
||||
target_label=user.get("username"),
|
||||
new_value=version,
|
||||
summary=f"{user['username']} accepted terms version {version}",
|
||||
links=[audit.target("user", user["uid"], user.get("username"))],
|
||||
)
|
||||
return action_result(request, "/feed", data={"terms_version": version})
|
||||
@ -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, is_account_active
|
||||
from devplacepy.database import get_table
|
||||
from devplacepy.utils import verify_password_async, get_current_user
|
||||
from devplacepy.models import LoginForm
|
||||
from devplacepy.dependencies import json_or_form
|
||||
@ -59,7 +59,7 @@ async def token(
|
||||
status_code=401,
|
||||
)
|
||||
|
||||
if not is_account_active(user):
|
||||
if not user.get("is_active", True):
|
||||
audit.record(
|
||||
request,
|
||||
"auth.token.failure",
|
||||
|
||||
@ -12,7 +12,7 @@ A second REST protocol mounted at `/api` that reproduces the public devRant API
|
||||
|
||||
**ID mapping (load-bearing).** devRant integer ids ARE the auto-increment `id` PK every `dataset` table already has: `rant_id`=`posts.id`, `comment_id`=`comments.id`, `user_id`=`users.id`, `token_id`=`devrant_tokens.id`. No translation table exists - `post_by_id` is `find_one(id=...)`. Serialization converts ISO `created_at` to unix via `ids.to_unix`.
|
||||
|
||||
**Auth.** `POST /api/users/auth-token` accepts username OR email, verifies with passlib, and inserts a `devrant_tokens` row (in `SOFT_DELETE_TABLES`; born-live; `key`=`secrets.token_hex`, `expire_time` from `session_max_age_days`). Every later call re-validates `(token_id, token_key, user_id)` through **`_shared.resolve_actor(request, params)`**, which wraps `tokens.resolve_user` with `utils.guards.refuse_suspended` - because this path never touches `require_user`, a moderator's suspension would otherwise not bind here at all (the token resolver's `is_account_active` check covers a **ban** but not a time-boxed suspension). `refuse_suspended` gates mutating methods only, so read endpoints are unaffected. Read endpoints take an OPTIONAL viewer (it may return None); write endpoints return `_shared.unauthorized()` (401) when it does. **`DELETE /api/users/me` deliberately calls the bare `resolve_user`** - it is the account-deletion path and must stay reachable to a suspended user, matching the `/profile/{username}/delete` exemption on the web side.
|
||||
**Auth.** `POST /api/users/auth-token` accepts username OR email, verifies with passlib, and inserts a `devrant_tokens` row (in `SOFT_DELETE_TABLES`; born-live; `key`=`secrets.token_hex`, `expire_time` from `session_max_age_days`). Every later call re-validates `(token_id, token_key, user_id)` with `tokens.resolve_user(params)`. Read endpoints take an OPTIONAL viewer (`resolve_user` may return None); write endpoints return `_shared.unauthorized()` (401) when it does.
|
||||
|
||||
**Writes reuse the audited native cores - never duplicate.** Implementing this drove four DRY extractions in `content.py` (`apply_vote`, `create_comment_record`, `delete_comment_record`, `set_bookmark`) and one in `utils.py` (`register_account`); the native `routers/votes.py`, `routers/comments.py`, and `auth/signup.py` were refactored onto the SAME functions. So a devRant rant/comment/vote awards XP, fires notifications, writes the audit row, and soft-deletes exactly like the UI path. Rant create calls `content.create_content_item` directly; rant delete calls `content.delete_content_item` (full cascade) and returns the devRant envelope.
|
||||
|
||||
|
||||
@ -2,19 +2,10 @@
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import HTTPException, Request
|
||||
from fastapi import HTTPException
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from devplacepy.database import get_setting
|
||||
from devplacepy.services.devrant.tokens import resolve_user
|
||||
from devplacepy.utils.guards import refuse_suspended
|
||||
|
||||
|
||||
def resolve_actor(request: Request, params: dict) -> Optional[dict]:
|
||||
user = resolve_user(params)
|
||||
if user:
|
||||
refuse_suspended(request, user)
|
||||
return user
|
||||
|
||||
|
||||
def api_enabled() -> bool:
|
||||
|
||||
@ -2,13 +2,14 @@
|
||||
|
||||
import logging
|
||||
import re
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi.responses import Response
|
||||
|
||||
from devplacepy.cache import TTLCache
|
||||
from devplacepy.config import SECONDS_PER_DAY
|
||||
from devplacepy.database import get_table, get_setting, is_account_active
|
||||
from devplacepy.database import get_table, get_setting
|
||||
from devplacepy.utils import verify_password_async, register_account_async
|
||||
from devplacepy.services.audit import record as audit
|
||||
from devplacepy.services.devrant.params import merge_params
|
||||
@ -16,7 +17,7 @@ from devplacepy.services.devrant.tokens import issue_token, resolve_user, revoke
|
||||
from devplacepy.services.devrant.profile import build_profile
|
||||
from devplacepy.services.devrant.ids import user_by_id
|
||||
from devplacepy.services.devrant.avatar import render_png
|
||||
from devplacepy.routers.devrant._shared import dr_ok, dr_error, resolve_actor, unauthorized
|
||||
from devplacepy.routers.devrant._shared import dr_ok, dr_error, unauthorized
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
@ -41,7 +42,7 @@ async def auth_token(request: Request):
|
||||
)
|
||||
if (
|
||||
not user
|
||||
or not is_account_active(user)
|
||||
or not user.get("is_active", True)
|
||||
or not await verify_password_async(password, user["password_hash"])
|
||||
):
|
||||
audit.record(
|
||||
@ -130,14 +131,14 @@ async def profile(request: Request, user_id: str):
|
||||
user = user_by_id(user_id)
|
||||
if not user:
|
||||
return dr_error("User not found.")
|
||||
viewer = resolve_actor(request, params)
|
||||
viewer = resolve_user(params)
|
||||
return dr_ok(profile=build_profile(user, viewer))
|
||||
|
||||
|
||||
@router.post("/users/me/edit-profile")
|
||||
async def edit_profile(request: Request):
|
||||
params = await merge_params(request)
|
||||
user = resolve_actor(request, params)
|
||||
user = resolve_user(params)
|
||||
if not user:
|
||||
return unauthorized()
|
||||
updates = {"uid": user["uid"]}
|
||||
@ -190,29 +191,21 @@ async def delete_account(request: Request):
|
||||
user = resolve_user(params)
|
||||
if not user:
|
||||
return unauthorized()
|
||||
from devplacepy.services.moderation import deletion
|
||||
|
||||
username = user["username"]
|
||||
get_table("users").update(
|
||||
{"uid": user["uid"], "is_active": False}, ["uid"]
|
||||
)
|
||||
revoke_all(user["uid"])
|
||||
result = deletion.delete_account(user)
|
||||
if result is None:
|
||||
return dr_error("This account is already being deleted.")
|
||||
logger.info("devrant account deleted for %s", username)
|
||||
logger.info("devrant account deactivated for %s", user["username"])
|
||||
audit.record(
|
||||
request,
|
||||
"account.delete.request",
|
||||
"auth.account.disable",
|
||||
user=user,
|
||||
target_type="user",
|
||||
target_uid=user["uid"],
|
||||
target_label=username,
|
||||
target_label=user["username"],
|
||||
origin="devrant",
|
||||
metadata={
|
||||
"stamp": result["stamp"],
|
||||
"rows": result["rows"],
|
||||
"grace_hours": result["grace_hours"],
|
||||
},
|
||||
summary=f"{username} deleted account via devrant",
|
||||
links=[audit.target("user", user["uid"], username)],
|
||||
summary=f"{user['username']} deactivated account via devrant",
|
||||
links=[audit.target("user", user["uid"], user["username"])],
|
||||
)
|
||||
return dr_ok()
|
||||
|
||||
|
||||
@ -17,9 +17,10 @@ from devplacepy.services.audit import record as audit
|
||||
from devplacepy.services.correction import schedule_correction
|
||||
from devplacepy.services.ai_modifier import schedule_modification
|
||||
from devplacepy.services.devrant.params import merge_params
|
||||
from devplacepy.services.devrant.tokens import resolve_user
|
||||
from devplacepy.services.devrant.ids import as_int, comment_by_id
|
||||
from devplacepy.services.devrant.serializers import serialize_comment
|
||||
from devplacepy.routers.devrant._shared import dr_ok, dr_error, resolve_actor, unauthorized
|
||||
from devplacepy.routers.devrant._shared import dr_ok, dr_error, unauthorized
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
@ -49,7 +50,7 @@ def _serialize_single(comment: dict, viewer) -> dict:
|
||||
@router.get("/comments/{comment_id}")
|
||||
async def get_comment(request: Request, comment_id: str):
|
||||
params = await merge_params(request)
|
||||
viewer = resolve_actor(request, params)
|
||||
viewer = resolve_user(params)
|
||||
comment = comment_by_id(comment_id)
|
||||
if not comment:
|
||||
return dr_error("Invalid comment specified in path.")
|
||||
@ -59,7 +60,7 @@ async def get_comment(request: Request, comment_id: str):
|
||||
@router.post("/comments/{comment_id}")
|
||||
async def edit_comment(request: Request, comment_id: str):
|
||||
params = await merge_params(request)
|
||||
user = resolve_actor(request, params)
|
||||
user = resolve_user(params)
|
||||
if not user:
|
||||
return unauthorized()
|
||||
comment = comment_by_id(comment_id)
|
||||
@ -96,7 +97,7 @@ async def edit_comment(request: Request, comment_id: str):
|
||||
@router.delete("/comments/{comment_id}")
|
||||
async def delete_comment(request: Request, comment_id: str):
|
||||
params = await merge_params(request)
|
||||
user = resolve_actor(request, params)
|
||||
user = resolve_user(params)
|
||||
if not user:
|
||||
return unauthorized()
|
||||
comment = comment_by_id(comment_id)
|
||||
@ -111,7 +112,7 @@ async def delete_comment(request: Request, comment_id: str):
|
||||
@router.post("/comments/{comment_id}/vote")
|
||||
async def vote_comment(request: Request, comment_id: str):
|
||||
params = await merge_params(request)
|
||||
user = resolve_actor(request, params)
|
||||
user = resolve_user(params)
|
||||
if not user:
|
||||
return unauthorized()
|
||||
comment = comment_by_id(comment_id)
|
||||
|
||||
@ -5,8 +5,9 @@ import logging
|
||||
from fastapi import APIRouter, Request
|
||||
|
||||
from devplacepy.services.devrant.params import merge_params
|
||||
from devplacepy.services.devrant.tokens import resolve_user
|
||||
from devplacepy.services.devrant.notifications import build_notif_feed, clear_notifications
|
||||
from devplacepy.routers.devrant._shared import dr_ok, resolve_actor, unauthorized
|
||||
from devplacepy.routers.devrant._shared import dr_ok, unauthorized
|
||||
from devplacepy.services.audit import record as audit
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@ -16,7 +17,7 @@ router = APIRouter()
|
||||
@router.get("/users/me/notif-feed")
|
||||
async def notif_feed(request: Request):
|
||||
params = await merge_params(request)
|
||||
user = resolve_actor(request, params)
|
||||
user = resolve_user(params)
|
||||
if not user:
|
||||
return unauthorized()
|
||||
return dr_ok(data=build_notif_feed(user))
|
||||
@ -25,7 +26,7 @@ async def notif_feed(request: Request):
|
||||
@router.delete("/users/me/notif-feed")
|
||||
async def clear_notif_feed(request: Request):
|
||||
params = await merge_params(request)
|
||||
user = resolve_actor(request, params)
|
||||
user = resolve_user(params)
|
||||
if not user:
|
||||
return unauthorized()
|
||||
clear_notifications(user)
|
||||
|
||||
@ -21,10 +21,11 @@ from devplacepy.services.audit import record as audit
|
||||
from devplacepy.services.correction import schedule_correction
|
||||
from devplacepy.services.ai_modifier import schedule_modification
|
||||
from devplacepy.services.devrant.params import merge_params
|
||||
from devplacepy.services.devrant.tokens import resolve_user
|
||||
from devplacepy.services.devrant.ids import as_int, post_by_id
|
||||
from devplacepy.services.devrant.feed import list_rants, search_rants, load_rant_detail
|
||||
from devplacepy.services.devrant.serializers import encode_tags
|
||||
from devplacepy.routers.devrant._shared import dr_ok, dr_error, resolve_actor, unauthorized
|
||||
from devplacepy.routers.devrant._shared import dr_ok, dr_error, unauthorized
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
@ -44,7 +45,7 @@ def _parse_tags(raw: object) -> list:
|
||||
@router.get("/devrant/rants")
|
||||
async def rant_feed(request: Request):
|
||||
params = await merge_params(request)
|
||||
viewer = resolve_actor(request, params)
|
||||
viewer = resolve_user(params)
|
||||
sort = params.get("sort") or "recent"
|
||||
limit = min(MAX_LIMIT, max(1, as_int(params.get("limit"), DEFAULT_LIMIT)))
|
||||
skip = max(0, as_int(params.get("skip"), 0))
|
||||
@ -69,7 +70,7 @@ async def rant_feed(request: Request):
|
||||
@router.get("/devrant/search")
|
||||
async def search(request: Request):
|
||||
params = await merge_params(request)
|
||||
viewer = resolve_actor(request, params)
|
||||
viewer = resolve_user(params)
|
||||
term = (params.get("term") or "").strip()
|
||||
return dr_ok(results=search_rants(term, viewer) if term else [])
|
||||
|
||||
@ -77,7 +78,7 @@ async def search(request: Request):
|
||||
@router.post("/devrant/rants")
|
||||
async def create_rant(request: Request):
|
||||
params = await merge_params(request)
|
||||
user = resolve_actor(request, params)
|
||||
user = resolve_user(params)
|
||||
if not user:
|
||||
return unauthorized()
|
||||
text = (params.get("rant") or "").strip()
|
||||
@ -113,7 +114,7 @@ async def create_rant(request: Request):
|
||||
@router.get("/devrant/rants/{rant_id}")
|
||||
async def get_rant(request: Request, rant_id: str):
|
||||
params = await merge_params(request)
|
||||
viewer = resolve_actor(request, params)
|
||||
viewer = resolve_user(params)
|
||||
post = post_by_id(rant_id)
|
||||
if not post:
|
||||
return dr_error("This rant does not exist.")
|
||||
@ -124,7 +125,7 @@ async def get_rant(request: Request, rant_id: str):
|
||||
@router.post("/devrant/rants/{rant_id}")
|
||||
async def edit_rant(request: Request, rant_id: str):
|
||||
params = await merge_params(request)
|
||||
user = resolve_actor(request, params)
|
||||
user = resolve_user(params)
|
||||
if not user:
|
||||
return unauthorized()
|
||||
post = post_by_id(rant_id)
|
||||
@ -163,7 +164,7 @@ async def edit_rant(request: Request, rant_id: str):
|
||||
@router.delete("/devrant/rants/{rant_id}")
|
||||
async def delete_rant(request: Request, rant_id: str):
|
||||
params = await merge_params(request)
|
||||
user = resolve_actor(request, params)
|
||||
user = resolve_user(params)
|
||||
if not user:
|
||||
return unauthorized()
|
||||
post = post_by_id(rant_id)
|
||||
@ -178,7 +179,7 @@ async def delete_rant(request: Request, rant_id: str):
|
||||
@router.post("/devrant/rants/{rant_id}/vote")
|
||||
async def vote_rant(request: Request, rant_id: str):
|
||||
params = await merge_params(request)
|
||||
user = resolve_actor(request, params)
|
||||
user = resolve_user(params)
|
||||
if not user:
|
||||
return unauthorized()
|
||||
post = post_by_id(rant_id)
|
||||
@ -205,7 +206,7 @@ async def unfavorite_rant(request: Request, rant_id: str):
|
||||
|
||||
async def _set_favorite(request: Request, rant_id: str, saved: bool):
|
||||
params = await merge_params(request)
|
||||
user = resolve_actor(request, params)
|
||||
user = resolve_user(params)
|
||||
if not user:
|
||||
return unauthorized()
|
||||
post = post_by_id(rant_id)
|
||||
@ -218,7 +219,7 @@ async def _set_favorite(request: Request, rant_id: str, saved: bool):
|
||||
@router.post("/devrant/rants/{rant_id}/comments")
|
||||
async def comment_rant(request: Request, rant_id: str):
|
||||
params = await merge_params(request)
|
||||
user = resolve_actor(request, params)
|
||||
user = resolve_user(params)
|
||||
if not user:
|
||||
return unauthorized()
|
||||
post = post_by_id(rant_id)
|
||||
|
||||
@ -10,8 +10,6 @@ This file documents the documentation site (`/docs`) - prose pages, API referenc
|
||||
|
||||
## Audience tiers and navigation
|
||||
|
||||
The **Legal** section (`SECTION_LEGAL`, in the `AUDIENCE_START` tier so it is one click from `/docs`) carries the platform's policies: `terms`, `community-guidelines`, `privacy`, `content-moderation`, `intellectual-property`, `contact`, plus the admin-gated `moderation-operations`. They are ordinary prose pages, which is exactly why they were built here rather than as new routes - role gating, SEO, the search index and the docs export all come for free. The six public ones are listed in `seo.LEGAL_DOC_SLUGS` and appear in the sitemap; `_footer_links.html` links four of them from every page. Their prose reads live values through the `policy_version`, `moderation_sla_hours`, `moderation_minimum_age`, `ai_provider_name` and `contact_details` Jinja globals, so a settings change is reflected without a content edit.
|
||||
|
||||
`DOCS_PAGES` entries take optional `admin: True` (hidden + 404 for non-admins, but still indexed and surfaced only to admins by `docs_search`) and `section: "..."` (a nested sidebar group rendered by `docs_base.html`). The sidebar groups `section`s under four ordered **audience tiers** (`AUDIENCES` in `routers/docs/pages.py`): `Start here` (General), `Build with the API` (API, Components, Styles), `Contribute and internals` (Architecture, Services, Devii internals, Bots internals, Testing, Claude Code), and `Operate` (Administration, Production). `nav_groups(visible_pages)` builds the `[(audience, [(section, [pages])])]` tree from the flat visible-page list (so a section's pages collect under one heading regardless of `DOCS_PAGES` order or the API/Administration interleave from `api_doc_pages()`); `views.py` passes it as `nav`, and `docs_base.html` renders an audience super-header (`.sidebar-tier`) above each section subheading (`.sidebar-subheading`). `DOCS_PAGES` stays the canonical list for search/export/routing - the tiering is sidebar-only.
|
||||
|
||||
The public `getting-started` page (`SECTION_GENERAL`) is the new-contributor on-ramp (install/run, the four-faces workflow, validation); gate its deep-internals links with `{% if is_admin(user) %}` so guests get no 404s. Keep one canonical home per concept: the `auth` API group intro in `docs_api.py` defers method detail to the `authentication` prose page rather than re-listing the four methods. The member-facing `devii` prose page is functional; admins also get a `Devii internals` section of `devii-*` technical subpages.
|
||||
|
||||
@ -4,7 +4,6 @@ from devplacepy.docs_api import api_doc_pages
|
||||
|
||||
SECTION_GENERAL = "General"
|
||||
SECTION_TOOLS = "Tools"
|
||||
SECTION_LEGAL = "Legal"
|
||||
SECTION_COMPONENTS = "Components"
|
||||
SECTION_STYLES = "Styles"
|
||||
SECTION_API = "API"
|
||||
@ -24,7 +23,7 @@ AUDIENCE_CONTRIBUTE = "Contribute and internals"
|
||||
AUDIENCE_OPERATE = "Operate"
|
||||
|
||||
AUDIENCES = [
|
||||
(AUDIENCE_START, [SECTION_GENERAL, SECTION_LEGAL, SECTION_TOOLS]),
|
||||
(AUDIENCE_START, [SECTION_GENERAL, SECTION_TOOLS]),
|
||||
(
|
||||
AUDIENCE_BUILD,
|
||||
[SECTION_API, SECTION_DEVRANT, SECTION_COMPONENTS, SECTION_STYLES],
|
||||
@ -148,50 +147,6 @@ DOCS_PAGES = [
|
||||
"kind": "prose",
|
||||
"section": SECTION_GENERAL,
|
||||
},
|
||||
# Legal - the policies the platform is operated under (everyone)
|
||||
{
|
||||
"slug": "terms",
|
||||
"title": "Terms of Service",
|
||||
"kind": "prose",
|
||||
"section": SECTION_LEGAL,
|
||||
},
|
||||
{
|
||||
"slug": "community-guidelines",
|
||||
"title": "Community Guidelines",
|
||||
"kind": "prose",
|
||||
"section": SECTION_LEGAL,
|
||||
},
|
||||
{
|
||||
"slug": "privacy",
|
||||
"title": "Privacy Policy",
|
||||
"kind": "prose",
|
||||
"section": SECTION_LEGAL,
|
||||
},
|
||||
{
|
||||
"slug": "content-moderation",
|
||||
"title": "How moderation works",
|
||||
"kind": "prose",
|
||||
"section": SECTION_LEGAL,
|
||||
},
|
||||
{
|
||||
"slug": "intellectual-property",
|
||||
"title": "Notice and takedown",
|
||||
"kind": "prose",
|
||||
"section": SECTION_LEGAL,
|
||||
},
|
||||
{
|
||||
"slug": "contact",
|
||||
"title": "Contact",
|
||||
"kind": "prose",
|
||||
"section": SECTION_LEGAL,
|
||||
},
|
||||
{
|
||||
"slug": "moderation-operations",
|
||||
"title": "Operating the moderation queue",
|
||||
"kind": "prose",
|
||||
"section": SECTION_LEGAL,
|
||||
"admin": True,
|
||||
},
|
||||
# Tools - public developer tools (everyone)
|
||||
{
|
||||
"slug": "tools-seo",
|
||||
|
||||
@ -185,7 +185,6 @@ async def docs_page(request: Request, slug: str):
|
||||
request,
|
||||
title=f"{page['title']} - Documentation",
|
||||
description="DevPlace developer documentation.",
|
||||
robots="noindex,nofollow" if page.get("admin") else "index,follow",
|
||||
breadcrumbs=[
|
||||
{"name": "Home", "url": "/feed"},
|
||||
{"name": "Docs", "url": "/docs/index.html"},
|
||||
|
||||
@ -317,13 +317,6 @@ def _resolve_ws_user(websocket: WebSocket):
|
||||
return _user_from_api_key(key)
|
||||
return None
|
||||
|
||||
|
||||
def _ws_may_write(user: dict) -> bool:
|
||||
from devplacepy.database import suspension_active
|
||||
from devplacepy.routers.auth.terms import needs_acceptance
|
||||
|
||||
return not suspension_active(user) and not needs_acceptance(user)
|
||||
|
||||
@router.websocket("/ws")
|
||||
async def messages_ws(websocket: WebSocket):
|
||||
await websocket.accept()
|
||||
@ -331,9 +324,6 @@ async def messages_ws(websocket: WebSocket):
|
||||
if not user:
|
||||
await websocket.close(code=1008)
|
||||
return
|
||||
if not _ws_may_write(user):
|
||||
await websocket.close(code=1008)
|
||||
return
|
||||
|
||||
user_uid = user["uid"]
|
||||
message_hub.register(user_uid, websocket)
|
||||
|
||||
@ -5,7 +5,6 @@ from datetime import datetime, timedelta, timezone
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
from devplacepy.database import (
|
||||
get_maturity,
|
||||
get_table,
|
||||
db,
|
||||
load_comments,
|
||||
@ -157,7 +156,6 @@ async def news_detail_page(request: Request, news_slug: str):
|
||||
"time_ago": time_ago(article["synced_at"]),
|
||||
"comments": comments,
|
||||
"bookmarked": bookmarked,
|
||||
"maturity": get_maturity("news", article["uid"])["level"],
|
||||
},
|
||||
model=NewsDetailOut,
|
||||
)
|
||||
|
||||
@ -5,9 +5,7 @@ from devplacepy.routers.profile import (
|
||||
ai_modifier,
|
||||
avatar,
|
||||
award,
|
||||
consent,
|
||||
customization,
|
||||
delete,
|
||||
interactions,
|
||||
notifications,
|
||||
telegram,
|
||||
@ -23,7 +21,5 @@ router.include_router(ai_modifier.router)
|
||||
router.include_router(interactions.router)
|
||||
router.include_router(avatar.router)
|
||||
router.include_router(telegram.router)
|
||||
router.include_router(consent.router)
|
||||
router.include_router(delete.router)
|
||||
|
||||
__all__ = ["router", "_ai_quota"]
|
||||
|
||||
@ -1,127 +0,0 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import logging
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
|
||||
from devplacepy.database import (
|
||||
CONSENT_KINDS,
|
||||
consent_state,
|
||||
get_setting,
|
||||
get_table,
|
||||
list_consents,
|
||||
set_consent,
|
||||
)
|
||||
from devplacepy.dependencies import json_or_form
|
||||
from devplacepy.models import ConsentForm, MaturePreferenceForm
|
||||
from devplacepy.responses import action_result
|
||||
from devplacepy.routers.profile.delete import _owner_only
|
||||
from devplacepy.services.audit import record as audit
|
||||
from devplacepy.utils import clear_user_cache
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
TRUTHY = ("1", "on", "true", "yes")
|
||||
|
||||
VERSION_KEYS = {"terms": "terms_version", "privacy": "privacy_version"}
|
||||
|
||||
|
||||
def consent_view(owner_kind: str, owner_id: str) -> list[dict]:
|
||||
latest = {}
|
||||
for row in list_consents(owner_kind, owner_id):
|
||||
latest.setdefault(row["kind"], row)
|
||||
return [
|
||||
{
|
||||
"kind": kind,
|
||||
"label": label,
|
||||
"state": (latest.get(kind) or {}).get("state", "withdrawn"),
|
||||
"version": (latest.get(kind) or {}).get("version", ""),
|
||||
"granted_at": (latest.get(kind) or {}).get("granted_at", ""),
|
||||
"withdrawn_at": (latest.get(kind) or {}).get("withdrawn_at", ""),
|
||||
}
|
||||
for kind, label in CONSENT_KINDS.items()
|
||||
]
|
||||
|
||||
|
||||
def consent_version(kind: str) -> str:
|
||||
key = VERSION_KEYS.get(kind)
|
||||
return (get_setting(key, "1") or "1") if key else "1"
|
||||
|
||||
|
||||
@router.post("/{username}/consent")
|
||||
async def set_user_consent(
|
||||
request: Request,
|
||||
username: str,
|
||||
data: Annotated[ConsentForm, Depends(json_or_form(ConsentForm))],
|
||||
):
|
||||
target, denied = _owner_only(
|
||||
request, username, "Only the account holder can change a consent"
|
||||
)
|
||||
if denied is not None:
|
||||
return denied
|
||||
granted = data.granted.strip().lower() in TRUTHY
|
||||
before = consent_state("user", target["uid"], data.kind)
|
||||
set_consent(
|
||||
"user", target["uid"], data.kind, granted, version=consent_version(data.kind)
|
||||
)
|
||||
logger.info(
|
||||
f"Consent {data.kind} {'granted' if granted else 'withdrawn'} for {target['username']}"
|
||||
)
|
||||
audit.record(
|
||||
request,
|
||||
"consent.grant" if granted else "consent.withdraw",
|
||||
target_type="user",
|
||||
target_uid=target["uid"],
|
||||
target_label=target["username"],
|
||||
old_value=(before or {}).get("state"),
|
||||
new_value="granted" if granted else "withdrawn",
|
||||
metadata={"kind": data.kind},
|
||||
summary=(
|
||||
f"{'granted' if granted else 'withdrew'} {data.kind} consent "
|
||||
f"for {target['username']}"
|
||||
),
|
||||
links=[audit.target("user", target["uid"], target["username"])],
|
||||
)
|
||||
url = f"/profile/{target['username']}?tab=privacy"
|
||||
return action_result(
|
||||
request,
|
||||
url,
|
||||
data={"kind": data.kind, "state": "granted" if granted else "withdrawn"},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{username}/mature-content")
|
||||
async def set_mature_preference(
|
||||
request: Request,
|
||||
username: str,
|
||||
data: Annotated[MaturePreferenceForm, Depends(json_or_form(MaturePreferenceForm))],
|
||||
):
|
||||
target, denied = _owner_only(
|
||||
request,
|
||||
username,
|
||||
"Only the account holder can change the mature-content preference",
|
||||
)
|
||||
if denied is not None:
|
||||
return denied
|
||||
opted_in = data.mature_opt_in.strip().lower() in TRUTHY
|
||||
get_table("users").update(
|
||||
{"uid": target["uid"], "mature_opt_in": 1 if opted_in else 0}, ["uid"]
|
||||
)
|
||||
clear_user_cache(target["uid"])
|
||||
audit.record(
|
||||
request,
|
||||
"profile.mature_content",
|
||||
target_type="user",
|
||||
target_uid=target["uid"],
|
||||
target_label=target["username"],
|
||||
new_value=1 if opted_in else 0,
|
||||
summary=(
|
||||
f"{'enabled' if opted_in else 'disabled'} mature content "
|
||||
f"for {target['username']}"
|
||||
),
|
||||
links=[audit.target("user", target["uid"], target["username"])],
|
||||
)
|
||||
url = f"/profile/{target['username']}?tab=privacy"
|
||||
return action_result(request, url, data={"mature_opt_in": opted_in})
|
||||
@ -1,150 +0,0 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import logging
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
|
||||
from devplacepy.dependencies import json_or_form
|
||||
from devplacepy.models import AccountDeleteForm
|
||||
from devplacepy.responses import action_result, json_error, respond
|
||||
from devplacepy.routers.profile._shared import resolve_customization_target
|
||||
from devplacepy.schemas import AccountDeletionOut
|
||||
from devplacepy.seo import base_seo_context
|
||||
from devplacepy.services.audit import record as audit
|
||||
from devplacepy.services.moderation import deletion
|
||||
from devplacepy.utils import get_current_user, verify_password_async
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
REMOVED = [
|
||||
"Your account record, username, email address and password",
|
||||
"Your profile: bio, location, links and avatar",
|
||||
"Your posts, comments, gists, projects, project files and quizzes",
|
||||
"Your uploads and media gallery",
|
||||
"Your direct-message history, votes, reactions, bookmarks and polls",
|
||||
"Your API key, access tokens and every signed-in session",
|
||||
"Your assistant conversations, tasks, lessons and custom tools",
|
||||
]
|
||||
|
||||
RETAINED = [
|
||||
"Append-only audit and moderation records, which hold identifiers rather than "
|
||||
"your profile, so the platform can show it enforced its own rules",
|
||||
"Backup archives, until they rotate out on their normal schedule",
|
||||
]
|
||||
|
||||
|
||||
def _owner_only(
|
||||
request: Request,
|
||||
username: str,
|
||||
message: str = "Only the account holder can delete this account",
|
||||
):
|
||||
target, denied = resolve_customization_target(request, username)
|
||||
if denied is not None:
|
||||
return None, denied
|
||||
viewer = get_current_user(request)
|
||||
if not viewer or viewer["uid"] != target["uid"]:
|
||||
audit.record(
|
||||
request,
|
||||
"security.authz.denied",
|
||||
user=viewer,
|
||||
result="denied",
|
||||
target_type="user",
|
||||
target_uid=target["uid"],
|
||||
target_label=target["username"],
|
||||
metadata={"reason": message},
|
||||
summary=f"non-owner denied {request.method} {request.url.path}",
|
||||
links=[audit.target("user", target["uid"], target["username"])],
|
||||
)
|
||||
return None, json_error(403, message)
|
||||
return target, None
|
||||
|
||||
|
||||
async def _password_matches(password: str, hashed: str) -> bool:
|
||||
if not hashed:
|
||||
return False
|
||||
try:
|
||||
return await verify_password_async(password, hashed)
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
|
||||
@router.get("/{username}/delete", response_class=HTMLResponse)
|
||||
async def delete_account_page(request: Request, username: str):
|
||||
target, denied = _owner_only(request, username)
|
||||
if denied is not None:
|
||||
return denied
|
||||
seo_ctx = base_seo_context(
|
||||
request,
|
||||
title="Delete your account",
|
||||
description="Permanently remove your DevPlace account and personal data.",
|
||||
robots="noindex,nofollow",
|
||||
breadcrumbs=[
|
||||
{"name": "Home", "url": "/feed"},
|
||||
{"name": target["username"], "url": f"/profile/{target['username']}"},
|
||||
{
|
||||
"name": "Delete account",
|
||||
"url": f"/profile/{target['username']}/delete",
|
||||
},
|
||||
],
|
||||
)
|
||||
return respond(
|
||||
request,
|
||||
"account_delete.html",
|
||||
{
|
||||
**seo_ctx,
|
||||
"request": request,
|
||||
"user": target,
|
||||
"username": target["username"],
|
||||
"grace_hours": deletion.grace_hours(),
|
||||
"removed": REMOVED,
|
||||
"retained": RETAINED,
|
||||
},
|
||||
model=AccountDeletionOut,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{username}/delete")
|
||||
async def delete_account(
|
||||
request: Request,
|
||||
username: str,
|
||||
data: Annotated[AccountDeleteForm, Depends(json_or_form(AccountDeleteForm))],
|
||||
):
|
||||
target, denied = _owner_only(request, username)
|
||||
if denied is not None:
|
||||
return denied
|
||||
if not await _password_matches(data.password, target.get("password_hash", "")):
|
||||
audit.record(
|
||||
request,
|
||||
"account.delete.request",
|
||||
result="denied",
|
||||
target_type="user",
|
||||
target_uid=target["uid"],
|
||||
target_label=target["username"],
|
||||
summary=f"account deletion for {target['username']} refused: wrong password",
|
||||
links=[audit.target("user", target["uid"], target["username"])],
|
||||
)
|
||||
return json_error(403, "That password is not correct")
|
||||
result = deletion.delete_account(target)
|
||||
if result is None:
|
||||
return json_error(409, "This account is already being deleted")
|
||||
logger.info(f"Account {target['username']} deleted by request")
|
||||
audit.record(
|
||||
request,
|
||||
"account.delete.request",
|
||||
target_type="user",
|
||||
target_uid=target["uid"],
|
||||
target_label=target["username"],
|
||||
metadata={
|
||||
"stamp": result["stamp"],
|
||||
"rows": result["rows"],
|
||||
"grace_hours": result["grace_hours"],
|
||||
},
|
||||
summary=f"account {target['username']} deleted",
|
||||
links=[audit.target("user", target["uid"], target["username"])],
|
||||
)
|
||||
response = action_result(request, "/", data=result)
|
||||
response.delete_cookie("session")
|
||||
return response
|
||||
@ -6,7 +6,6 @@ from fastapi import Depends, APIRouter, Request
|
||||
from devplacepy.models import ProfileForm
|
||||
from fastapi.responses import HTMLResponse, JSONResponse
|
||||
from devplacepy.database import (
|
||||
get_setting,
|
||||
get_table,
|
||||
get_customization_prefs,
|
||||
get_notification_prefs,
|
||||
@ -35,13 +34,6 @@ from devplacepy.database.awards import (
|
||||
get_user_awards,
|
||||
)
|
||||
from devplacepy.content import can_view_project, enrich_items
|
||||
from devplacepy.routers.profile.consent import consent_view
|
||||
from devplacepy.services.moderation.deletion import grace_hours
|
||||
from devplacepy.services.moderation.screening import (
|
||||
record as record_screening,
|
||||
refuse_if_blocked,
|
||||
screen_fields,
|
||||
)
|
||||
from devplacepy.utils import (
|
||||
get_current_user,
|
||||
get_badge,
|
||||
@ -371,30 +363,6 @@ async def profile_page(
|
||||
if (tab == "notifications" and can_manage_customization)
|
||||
else False
|
||||
)
|
||||
consents = (
|
||||
consent_view("user", profile_user["uid"])
|
||||
if (tab == "privacy" and can_manage_customization)
|
||||
else []
|
||||
)
|
||||
privacy_fields = (
|
||||
{
|
||||
"mature_opt_in": bool(profile_user.get("mature_opt_in")),
|
||||
"age_band": profile_user.get("age_band", ""),
|
||||
"terms_version": profile_user.get("terms_version", ""),
|
||||
"terms_accepted_at": profile_user.get("terms_accepted_at", ""),
|
||||
"suspended_until": profile_user.get("suspended_until", ""),
|
||||
"suspension_reason": profile_user.get("suspension_reason", ""),
|
||||
}
|
||||
if can_manage_customization
|
||||
else {
|
||||
"mature_opt_in": False,
|
||||
"age_band": "",
|
||||
"terms_version": "",
|
||||
"terms_accepted_at": "",
|
||||
"suspended_until": "",
|
||||
"suspension_reason": "",
|
||||
}
|
||||
)
|
||||
|
||||
base = site_url(request)
|
||||
robots = "noindex,follow" if posts_count < 2 else "index,follow"
|
||||
@ -466,10 +434,6 @@ async def profile_page(
|
||||
"cust_disable_global": customization_prefs["disable_global"],
|
||||
"cust_disable_pagetype": customization_prefs["disable_pagetype"],
|
||||
"notification_prefs": notification_prefs,
|
||||
"consents": consents,
|
||||
**privacy_fields,
|
||||
"current_terms_version": get_setting("terms_version", "1") or "1",
|
||||
"deletion_grace_hours": grace_hours(),
|
||||
"ai_quota": ai_quota,
|
||||
"correction_usage": correction_usage,
|
||||
"modifier_usage": modifier_usage,
|
||||
@ -497,10 +461,6 @@ async def profile_page(
|
||||
async def update_profile(request: Request, data: Annotated[ProfileForm, Depends(json_or_form(ProfileForm))]):
|
||||
user = require_user(request)
|
||||
users = get_table("users")
|
||||
screening = screen_fields(
|
||||
"users", {"bio": data.bio, "location": data.location}
|
||||
)
|
||||
refuse_if_blocked(screening)
|
||||
users.update(
|
||||
{
|
||||
"uid": user["uid"],
|
||||
@ -512,13 +472,6 @@ async def update_profile(request: Request, data: Annotated[ProfileForm, Depends(
|
||||
["uid"],
|
||||
)
|
||||
clear_user_cache(user["uid"])
|
||||
record_screening(
|
||||
screening,
|
||||
target_type="user",
|
||||
target_uid=user["uid"],
|
||||
actor_uid=user["uid"],
|
||||
request=request,
|
||||
)
|
||||
schedule_correction(user, "users", user["uid"], request)
|
||||
schedule_modification(user, "users", user["uid"], request)
|
||||
|
||||
|
||||
@ -9,7 +9,7 @@ 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`.
|
||||
|
||||
**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.
|
||||
**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 (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`).
|
||||
|
||||
|
||||
@ -2,9 +2,8 @@
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from devplacepy.routers.projects.containers import instances, schedules, workspace
|
||||
from devplacepy.routers.projects.containers import instances, schedules
|
||||
|
||||
router = APIRouter()
|
||||
router.include_router(instances.router)
|
||||
router.include_router(schedules.router)
|
||||
router.include_router(workspace.router)
|
||||
|
||||
@ -1,285 +0,0 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Form, Request, WebSocket
|
||||
from starlette.responses import Response
|
||||
|
||||
from devplacepy.content import (
|
||||
can_manage_workspace,
|
||||
can_open_workspace,
|
||||
)
|
||||
from devplacepy.database import get_table, resolve_by_slug
|
||||
from devplacepy.models import 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.provision import WorkspaceError
|
||||
from devplacepy.utils import not_found, require_user
|
||||
|
||||
from ._shared import audit_instance, fail
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _project_or_404(slug: str) -> dict:
|
||||
project = resolve_by_slug(get_table("projects"), slug)
|
||||
if not project:
|
||||
raise not_found("Project not found")
|
||||
return project
|
||||
|
||||
|
||||
def _workspace_or_404(project: dict, user: dict) -> dict:
|
||||
instance = provision.find_for_project(project["uid"], user["uid"])
|
||||
if not instance:
|
||||
raise not_found("No workspace for this project")
|
||||
return instance
|
||||
|
||||
|
||||
def _guard(request: Request, project: dict, user: dict, event_key: str) -> None:
|
||||
if can_open_workspace(project, user):
|
||||
return
|
||||
audit.record(
|
||||
request,
|
||||
event_key,
|
||||
user=user,
|
||||
target_type="project",
|
||||
target_uid=project["uid"],
|
||||
target_label=project.get("title"),
|
||||
summary=f"{user['username']} denied workspace access",
|
||||
result="denied",
|
||||
)
|
||||
raise not_found("Workspaces are not available for this project")
|
||||
|
||||
|
||||
@router.get("/{slug}/workspace")
|
||||
async def workspace_page(request: Request, slug: str):
|
||||
user = require_user(request)
|
||||
if isinstance(user, Response):
|
||||
return user
|
||||
project = _project_or_404(slug)
|
||||
_guard(request, project, user, "container.workspace.open")
|
||||
instance = provision.find_for_project(project["uid"], user["uid"])
|
||||
limits = quota.resolve(user["uid"])
|
||||
context = {
|
||||
"project": project,
|
||||
"workspace": 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,
|
||||
"editor_url": (
|
||||
f"/projects/{slug}/containers/instances/{instance['uid']}/code/"
|
||||
if instance
|
||||
else ""
|
||||
),
|
||||
"editor_password": (
|
||||
api.ensure_editor_password(instance) if instance else ""
|
||||
),
|
||||
"user": user,
|
||||
}
|
||||
return respond(request, "workspace.html", context, model=WorkspaceOut)
|
||||
|
||||
|
||||
@router.post("/{slug}/workspace")
|
||||
async def workspace_open(request: Request, slug: str):
|
||||
user = require_user(request)
|
||||
if isinstance(user, Response):
|
||||
return user
|
||||
project = _project_or_404(slug)
|
||||
_guard(request, project, user, "container.workspace.quota.block")
|
||||
try:
|
||||
instance = await provision.ensure(project, user)
|
||||
instance = provision.resume(instance)
|
||||
except WorkspaceError as error:
|
||||
audit.record(
|
||||
request,
|
||||
"container.workspace.quota.block",
|
||||
user=user,
|
||||
target_type="project",
|
||||
target_uid=project["uid"],
|
||||
summary=str(error),
|
||||
result="denied",
|
||||
)
|
||||
return json_error(400, str(error))
|
||||
except ContainerError as error:
|
||||
return fail(error)
|
||||
audit_instance(
|
||||
request,
|
||||
user,
|
||||
"container.workspace.create",
|
||||
instance,
|
||||
project,
|
||||
summary=f"{user['username']} opened workspace for {project.get('title')}",
|
||||
)
|
||||
provision.write_manifest(instance)
|
||||
return action_result(
|
||||
request, f"/projects/{slug}/workspace", data=provision.view(instance)
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{slug}/workspace/stop")
|
||||
async def workspace_stop(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")
|
||||
provision.stop(instance)
|
||||
audit_instance(request, user, "container.workspace.stop", instance, project)
|
||||
return action_result(request, f"/projects/{slug}/workspace")
|
||||
|
||||
|
||||
@router.post("/{slug}/workspace/delete")
|
||||
async def workspace_delete(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")
|
||||
for row in tunnels.list_for_instance(instance["uid"]):
|
||||
tunnels.soft_delete(row["uid"], user["uid"])
|
||||
store.delete_instance(instance["uid"], user["uid"])
|
||||
audit_instance(request, user, "container.workspace.delete", instance, project)
|
||||
return action_result(request, f"/projects/{slug}/workspace")
|
||||
|
||||
|
||||
@router.get("/{slug}/workspace/tunnels")
|
||||
async def tunnel_list(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")
|
||||
return {"tunnels": tunnels.list_for_instance(instance["uid"])}
|
||||
|
||||
|
||||
@router.post("/{slug}/workspace/tunnels")
|
||||
async def tunnel_create(
|
||||
request: Request, slug: str, data: Annotated[TunnelForm, Form()]
|
||||
):
|
||||
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")
|
||||
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")
|
||||
audit_instance(
|
||||
request,
|
||||
user,
|
||||
"container.tunnel.create",
|
||||
instance,
|
||||
project,
|
||||
metadata={"hostname": row["hostname"], "port": data.container_port},
|
||||
)
|
||||
provision.write_manifest(instance)
|
||||
return action_result(request, f"/projects/{slug}/workspace", data=row)
|
||||
|
||||
|
||||
@router.delete("/{slug}/workspace/tunnels/{uid}")
|
||||
@router.post("/{slug}/workspace/tunnels/{uid}/delete")
|
||||
async def tunnel_delete(request: Request, slug: str, uid: 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")
|
||||
row = tunnels.get(uid)
|
||||
if not row or row.get("instance_uid") != instance["uid"]:
|
||||
raise not_found("Tunnel not found")
|
||||
tunnels.soft_delete(uid, user["uid"])
|
||||
audit_instance(
|
||||
request,
|
||||
user,
|
||||
"container.tunnel.delete",
|
||||
instance,
|
||||
project,
|
||||
metadata={"hostname": row.get("hostname", "")},
|
||||
)
|
||||
provision.write_manifest(instance)
|
||||
return action_result(request, f"/projects/{slug}/workspace")
|
||||
|
||||
|
||||
def _editor_guard(request: Request, slug: str, uid: str):
|
||||
user = require_user(request)
|
||||
if isinstance(user, Response):
|
||||
return None, None, user
|
||||
project = _project_or_404(slug)
|
||||
instance = store.get_instance(uid)
|
||||
if not instance or not instance.get("is_workspace"):
|
||||
raise not_found("Workspace not found")
|
||||
if not can_manage_workspace(instance, project, user):
|
||||
return None, None, json_error(403, "Not allowed to open this workspace")
|
||||
return project, instance, None
|
||||
|
||||
|
||||
@router.api_route(
|
||||
"/{slug}/containers/instances/{uid}/code", methods=forward.METHODS
|
||||
)
|
||||
@router.api_route(
|
||||
"/{slug}/containers/instances/{uid}/code/{path:path}", methods=forward.METHODS
|
||||
)
|
||||
async def editor_proxy(request: Request, slug: str, uid: str, path: str = ""):
|
||||
project, instance, denial = _editor_guard(request, slug, uid)
|
||||
if denial is not None:
|
||||
return denial
|
||||
if instance.get("suspended_at"):
|
||||
return Response("this workspace is suspended", status_code=403)
|
||||
if instance.get("status") != store.ST_RUNNING:
|
||||
return Response("this workspace is not running", status_code=409)
|
||||
host, port = provision.editor_target(instance)
|
||||
if not host or not port:
|
||||
return Response("the editor has no reachable port", status_code=502)
|
||||
activity.touch(instance["uid"])
|
||||
prefix = f"/projects/{slug}/containers/instances/{uid}/code"
|
||||
return await forward.proxy_http(request, host, port, path, prefix=prefix)
|
||||
|
||||
|
||||
@router.websocket("/{slug}/containers/instances/{uid}/code")
|
||||
@router.websocket("/{slug}/containers/instances/{uid}/code/{path:path}")
|
||||
async def editor_proxy_ws(
|
||||
websocket: WebSocket, slug: str, uid: str, path: str = ""
|
||||
):
|
||||
from devplacepy.utils import get_current_user
|
||||
|
||||
user = get_current_user(websocket)
|
||||
project = resolve_by_slug(get_table("projects"), slug)
|
||||
instance = store.get_instance(uid)
|
||||
if not user or not project or not instance or not instance.get("is_workspace"):
|
||||
await websocket.close(code=1008)
|
||||
return
|
||||
if not can_manage_workspace(instance, project, user):
|
||||
await websocket.close(code=1008)
|
||||
return
|
||||
if instance.get("suspended_at") or instance.get("status") != store.ST_RUNNING:
|
||||
await websocket.close(code=1011)
|
||||
return
|
||||
host, port = provision.editor_target(instance)
|
||||
if not host or not port:
|
||||
await websocket.close(code=1011)
|
||||
return
|
||||
activity.touch(instance["uid"])
|
||||
prefix = f"/projects/{slug}/containers/instances/{uid}/code"
|
||||
await forward.proxy_ws(websocket, host, port, path, prefix=prefix)
|
||||
@ -39,7 +39,6 @@ from devplacepy.content import (
|
||||
is_owner,
|
||||
can_view_project,
|
||||
can_view_project_containers,
|
||||
can_open_workspace,
|
||||
get_project_devlog,
|
||||
)
|
||||
from devplacepy.utils import (
|
||||
@ -176,19 +175,6 @@ 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
|
||||
|
||||
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 ""
|
||||
slug = project["slug"] or project["uid"]
|
||||
return f"/projects/{slug}/containers/instances/{instance['uid']}/code/"
|
||||
|
||||
|
||||
@router.get("/{project_slug}", response_class=HTMLResponse)
|
||||
async def project_detail(request: Request, project_slug: str, before: str = None):
|
||||
user = get_current_user(request)
|
||||
@ -225,10 +211,6 @@ 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 ""
|
||||
)
|
||||
parent = get_fork_parent(project["uid"])
|
||||
forked_from = (
|
||||
{
|
||||
@ -274,8 +256,6 @@ async def project_detail(request: Request, project_slug: str, before: str = None
|
||||
"is_private": bool(project.get("is_private")),
|
||||
"read_only": bool(project.get("read_only")),
|
||||
"viewer_can_containers": can_view_project_containers(project, user),
|
||||
"viewer_can_workspace": viewer_can_workspace,
|
||||
"workspace_editor_url": workspace_editor_url,
|
||||
"forked_from": forked_from,
|
||||
"fork_count": count_forks(project["uid"]),
|
||||
"file_count": count_files(project["uid"]),
|
||||
|
||||
@ -1,18 +1,70 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
import httpx
|
||||
import websockets
|
||||
from fastapi import APIRouter, Request, WebSocket
|
||||
from starlette.responses import Response
|
||||
|
||||
from devplacepy.services.audit import record as audit
|
||||
from devplacepy.services.containers import api, forward, store
|
||||
from devplacepy.services.containers import api, store
|
||||
from devplacepy.utils import not_found
|
||||
from devplacepy.services.audit import record as audit
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
METHODS = forward.METHODS
|
||||
HOP_HEADERS = {
|
||||
"connection",
|
||||
"keep-alive",
|
||||
"proxy-authenticate",
|
||||
"proxy-authorization",
|
||||
"te",
|
||||
"trailers",
|
||||
"transfer-encoding",
|
||||
"upgrade",
|
||||
"host",
|
||||
"content-length",
|
||||
"content-encoding",
|
||||
}
|
||||
METHODS = ["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "HEAD"]
|
||||
|
||||
|
||||
def _forward_headers(request: Request, prefix: str) -> dict:
|
||||
headers = {k: v for k, v in request.headers.items() if k.lower() not in HOP_HEADERS}
|
||||
headers["X-Forwarded-Prefix"] = prefix
|
||||
headers["X-Script-Name"] = prefix
|
||||
headers["X-Forwarded-Host"] = request.headers.get(
|
||||
"host", request.url.hostname or ""
|
||||
)
|
||||
headers["X-Forwarded-Proto"] = request.headers.get(
|
||||
"x-forwarded-proto", request.url.scheme
|
||||
)
|
||||
headers["Accept-Encoding"] = "identity"
|
||||
return headers
|
||||
|
||||
|
||||
def _inject_base(body: bytes, prefix: str) -> bytes:
|
||||
lowered = body.lower()
|
||||
if b"<base" in lowered:
|
||||
return body
|
||||
tag = f'<base href="{prefix}/">'.encode()
|
||||
head = lowered.find(b"<head")
|
||||
anchor = (
|
||||
lowered.find(b">", head)
|
||||
if head != -1
|
||||
else lowered.find(b">", lowered.find(b"<html"))
|
||||
)
|
||||
if anchor == -1:
|
||||
return tag + body
|
||||
return body[: anchor + 1] + tag + body[anchor + 1 :]
|
||||
|
||||
|
||||
def _rewrite_location(value: str, prefix: str) -> str:
|
||||
if value.startswith("/") and not value.startswith("//"):
|
||||
return prefix + value
|
||||
return value
|
||||
|
||||
|
||||
def _resolve(slug: str):
|
||||
@ -43,9 +95,41 @@ async def proxy_http(request: Request, slug: str, path: str = ""):
|
||||
summary=f"request proxied to instance {instance.get('name')} via ingress {slug}",
|
||||
links=[audit.instance(instance["uid"], instance.get("name"))],
|
||||
)
|
||||
return await forward.proxy_http(
|
||||
request, host, port, path, prefix=f"/p/{slug}", timeout=60.0
|
||||
prefix = f"/p/{slug}"
|
||||
url = f"http://{host}:{port}/{path}"
|
||||
headers = _forward_headers(request, prefix)
|
||||
body = await request.body()
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=60.0, follow_redirects=False) as client:
|
||||
upstream = await client.request(
|
||||
request.method,
|
||||
url,
|
||||
params=request.query_params,
|
||||
headers=headers,
|
||||
content=body,
|
||||
)
|
||||
except httpx.HTTPError as exc:
|
||||
return Response(f"upstream error: {exc}", status_code=502)
|
||||
out_headers = {
|
||||
k: v
|
||||
for k, v in upstream.headers.items()
|
||||
if k.lower() not in HOP_HEADERS and k.lower() != "set-cookie"
|
||||
}
|
||||
if "location" in out_headers:
|
||||
out_headers["location"] = _rewrite_location(out_headers["location"], prefix)
|
||||
content_type = upstream.headers.get("content-type", "")
|
||||
content = upstream.content
|
||||
if "text/html" in content_type.lower():
|
||||
content = _inject_base(content, prefix)
|
||||
response = Response(
|
||||
content=content,
|
||||
status_code=upstream.status_code,
|
||||
headers=out_headers,
|
||||
media_type=content_type or None,
|
||||
)
|
||||
for cookie in upstream.headers.get_list("set-cookie"):
|
||||
response.headers.append("set-cookie", cookie)
|
||||
return response
|
||||
|
||||
|
||||
@router.websocket("/{slug}")
|
||||
@ -55,6 +139,10 @@ async def proxy_ws(websocket: WebSocket, slug: str, path: str = ""):
|
||||
if instance is None or not host or not port:
|
||||
await websocket.close(code=1011)
|
||||
return
|
||||
upstream_url = f"ws://{host}:{port}/{path}"
|
||||
if websocket.url.query:
|
||||
upstream_url += f"?{websocket.url.query}"
|
||||
await websocket.accept()
|
||||
audit.record(
|
||||
websocket,
|
||||
"proxy.access",
|
||||
@ -66,4 +154,48 @@ async def proxy_ws(websocket: WebSocket, slug: str, path: str = ""):
|
||||
summary=f"websocket proxied to instance {instance.get('name')} via ingress {slug}",
|
||||
links=[audit.instance(instance["uid"], instance.get("name"))],
|
||||
)
|
||||
await forward.proxy_ws(websocket, host, port, path, prefix=f"/p/{slug}")
|
||||
try:
|
||||
async with websockets.connect(
|
||||
upstream_url, open_timeout=10, max_size=None
|
||||
) as upstream:
|
||||
await _pump(websocket, upstream)
|
||||
except Exception as exc:
|
||||
logger.debug("ws proxy %s failed: %s", slug, exc)
|
||||
try:
|
||||
await websocket.close(code=1011)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
async def _pump(client_ws: WebSocket, upstream) -> None:
|
||||
async def client_to_upstream():
|
||||
try:
|
||||
while True:
|
||||
message = await client_ws.receive()
|
||||
if message["type"] == "websocket.disconnect":
|
||||
break
|
||||
if message.get("text") is not None:
|
||||
await upstream.send(message["text"])
|
||||
elif message.get("bytes") is not None:
|
||||
await upstream.send(message["bytes"])
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
await upstream.close()
|
||||
|
||||
async def upstream_to_client():
|
||||
try:
|
||||
async for message in upstream:
|
||||
if isinstance(message, (bytes, bytearray)):
|
||||
await client_ws.send_bytes(bytes(message))
|
||||
else:
|
||||
await client_ws.send_text(message)
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
try:
|
||||
await client_ws.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
await asyncio.gather(client_to_upstream(), upstream_to_client())
|
||||
|
||||
@ -1,143 +0,0 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import logging
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
|
||||
from devplacepy.database import (
|
||||
REPORTABLE_TARGETS,
|
||||
REPORT_SEVERITIES,
|
||||
REPORT_STATUSES,
|
||||
report_reason_options,
|
||||
)
|
||||
from devplacepy.dependencies import json_or_form
|
||||
from devplacepy.models import ReportForm
|
||||
from devplacepy.responses import action_result, json_error, respond
|
||||
from devplacepy.schemas import ReportListOut, ReportReasonsOut
|
||||
from devplacepy.seo import base_seo_context, site_url, website_schema
|
||||
from devplacepy.services.audit import record as audit
|
||||
from devplacepy.services.moderation import queue, sla
|
||||
from devplacepy.utils import create_notification, get_current_user, require_user
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/reasons")
|
||||
async def report_reasons(request: Request):
|
||||
base = site_url(request)
|
||||
return respond(
|
||||
request,
|
||||
"report_reasons.html",
|
||||
{
|
||||
**base_seo_context(
|
||||
request,
|
||||
title="Report reasons",
|
||||
description="The categories DevPlace accepts content reports under.",
|
||||
breadcrumbs=[
|
||||
{"name": "Home", "url": "/feed"},
|
||||
{"name": "Report reasons", "url": "/reports/reasons"},
|
||||
],
|
||||
schemas=[website_schema(base)],
|
||||
),
|
||||
"request": request,
|
||||
"user": get_current_user(request),
|
||||
"reasons": report_reason_options(),
|
||||
"severities": list(REPORT_SEVERITIES),
|
||||
},
|
||||
model=ReportReasonsOut,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/mine", response_class=HTMLResponse)
|
||||
async def my_reports(request: Request, status: str = "", page: int = 1):
|
||||
user = require_user(request)
|
||||
if status not in REPORT_STATUSES:
|
||||
status = ""
|
||||
reports, pagination = queue.list_reports(
|
||||
status=status, reporter_uid=user["uid"], page=page
|
||||
)
|
||||
base = site_url(request)
|
||||
seo_ctx = base_seo_context(
|
||||
request,
|
||||
title="Your reports",
|
||||
description="The reports you filed and the outcome of each.",
|
||||
robots="noindex,nofollow",
|
||||
breadcrumbs=[
|
||||
{"name": "Home", "url": "/feed"},
|
||||
{"name": "Your reports", "url": "/reports/mine"},
|
||||
],
|
||||
schemas=[website_schema(base)],
|
||||
)
|
||||
return respond(
|
||||
request,
|
||||
"reports_mine.html",
|
||||
{
|
||||
**seo_ctx,
|
||||
"request": request,
|
||||
"user": user,
|
||||
"reports": reports,
|
||||
"pagination": pagination,
|
||||
"status": status,
|
||||
"reasons": report_reason_options(),
|
||||
},
|
||||
model=ReportListOut,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{target_type}/{target_uid}")
|
||||
async def submit_report(
|
||||
request: Request,
|
||||
target_type: str,
|
||||
target_uid: str,
|
||||
data: Annotated[ReportForm, Depends(json_or_form(ReportForm))],
|
||||
):
|
||||
user = require_user(request)
|
||||
if target_type not in REPORTABLE_TARGETS:
|
||||
return json_error(400, "Unknown report target")
|
||||
owner_uid = queue.owner_uid_for(target_type, target_uid)
|
||||
if owner_uid and owner_uid == user["uid"]:
|
||||
return json_error(400, "You cannot report your own content")
|
||||
report = queue.raise_report(
|
||||
target_type=target_type,
|
||||
target_uid=target_uid,
|
||||
reporter_uid=user["uid"],
|
||||
reason=data.reason,
|
||||
detail=data.detail,
|
||||
origin="member",
|
||||
)
|
||||
if not report:
|
||||
return json_error(400, "Report could not be filed")
|
||||
hours = sla.sla_hours()
|
||||
logger.info(
|
||||
f"{user['username']} reported {target_type} {target_uid} as {data.reason}"
|
||||
)
|
||||
audit.record(
|
||||
request,
|
||||
"report.create",
|
||||
user=user,
|
||||
target_type=target_type,
|
||||
target_uid=target_uid,
|
||||
metadata={"reason": data.reason, "severity": report["severity"]},
|
||||
summary=f"{user['username']} reported {target_type} {target_uid} as {data.reason}",
|
||||
links=[audit.target(target_type, target_uid)],
|
||||
)
|
||||
create_notification(
|
||||
user["uid"],
|
||||
"moderation",
|
||||
f"Report received. A moderator reviews it within {hours} hours.",
|
||||
user["uid"],
|
||||
"/reports/mine",
|
||||
)
|
||||
return action_result(
|
||||
request,
|
||||
"/reports/mine",
|
||||
data={
|
||||
"uid": report["uid"],
|
||||
"status": report["status"],
|
||||
"severity": report["severity"],
|
||||
"sla_hours": hours,
|
||||
},
|
||||
)
|
||||
@ -22,8 +22,6 @@ Disallow: /avatar/
|
||||
Disallow: /follow/
|
||||
Disallow: /admin/
|
||||
Disallow: /uploads/
|
||||
Disallow: /reports/mine
|
||||
Disallow: /profile/*/delete
|
||||
Disallow: /*?tab=
|
||||
Disallow: /*?sort=
|
||||
Allow: /static/
|
||||
|
||||
@ -1,74 +0,0 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Request, WebSocket
|
||||
from starlette.responses import Response
|
||||
|
||||
from devplacepy.services.containers import activity, api, forward, store
|
||||
from devplacepy.services.containers.workspace import naming, tunnels
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
METHODS = forward.METHODS
|
||||
|
||||
|
||||
def resolve(host: str):
|
||||
if not naming.is_tunnel_host(host):
|
||||
return None, None, None, None
|
||||
row = tunnels.by_hostname(host)
|
||||
if not row or row.get("status") not in tunnels.SERVING_STATUSES:
|
||||
return None, None, None, None
|
||||
instance = store.get_instance(row.get("instance_uid", ""))
|
||||
if not instance or instance.get("deleted_at"):
|
||||
return None, None, None, None
|
||||
if instance.get("suspended_at"):
|
||||
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
|
||||
|
||||
|
||||
async def handle_http(request: Request, path: str) -> Response:
|
||||
host = request.headers.get("host", "")
|
||||
row, instance, gateway, port = resolve(host)
|
||||
if row is None:
|
||||
return Response("no tunnel is published at this address", status_code=404)
|
||||
if instance is not None and instance.get("suspended_at"):
|
||||
return Response("this workspace is suspended", status_code=403)
|
||||
if not gateway or not port:
|
||||
return Response("the tunnel has no reachable port", status_code=502)
|
||||
return await forward.proxy_http(
|
||||
request,
|
||||
gateway,
|
||||
port,
|
||||
path,
|
||||
on_complete=lambda sent: record_traffic(instance["uid"], row["uid"], sent),
|
||||
)
|
||||
|
||||
|
||||
def record_traffic(instance_uid: str, tunnel_uid: str, sent: int) -> None:
|
||||
activity.touch(instance_uid, egress_bytes=sent)
|
||||
tunnels.record_hit(tunnel_uid, sent)
|
||||
|
||||
|
||||
async def handle_ws(websocket: WebSocket, path: str) -> None:
|
||||
host = websocket.headers.get("host", "")
|
||||
row, instance, gateway, port = resolve(host)
|
||||
if row is None or instance is None or not gateway or not port:
|
||||
await websocket.close(code=1011)
|
||||
return
|
||||
activity.touch(instance["uid"])
|
||||
await forward.proxy_ws(websocket, gateway, port, path)
|
||||
@ -1,116 +0,0 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
|
||||
from devplacepy.database import (
|
||||
build_pagination,
|
||||
db,
|
||||
get_maturity_by_targets,
|
||||
get_table,
|
||||
get_users_by_uids,
|
||||
)
|
||||
from devplacepy.content import can_view_project
|
||||
from devplacepy.responses import respond
|
||||
from devplacepy.schemas import WorkspaceIndexOut
|
||||
from devplacepy.seo import base_seo_context, public_base_url, site_url, website_schema
|
||||
from devplacepy.utils import get_current_user
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
PER_PAGE = 50
|
||||
|
||||
|
||||
def published_instances() -> list[dict]:
|
||||
if "instances" not in db.tables:
|
||||
return []
|
||||
table = get_table("instances")
|
||||
if not table.has_column("ingress_slug"):
|
||||
return []
|
||||
rows = [
|
||||
row
|
||||
for row in table.find(deleted_at=None, order_by=["-created_at"])
|
||||
if (row.get("ingress_slug") or "").strip()
|
||||
]
|
||||
return rows
|
||||
|
||||
|
||||
def projects_by_uids(uids: list[str]) -> dict[str, dict]:
|
||||
unique = [uid for uid in set(uids) if uid]
|
||||
if not unique or "projects" not in db.tables:
|
||||
return {}
|
||||
table = get_table("projects")
|
||||
return {
|
||||
row["uid"]: row for row in table.find(table.table.columns.uid.in_(unique))
|
||||
}
|
||||
|
||||
|
||||
def index_entries(rows: list[dict], user: dict | None) -> list[dict]:
|
||||
projects = projects_by_uids([row.get("project_uid", "") for row in rows])
|
||||
owners = get_users_by_uids(
|
||||
[row.get("owner_uid") or row.get("created_by") for row in rows]
|
||||
)
|
||||
maturity = get_maturity_by_targets("workspace", [row["uid"] for row in rows])
|
||||
base = public_base_url()
|
||||
entries = []
|
||||
for row in rows:
|
||||
project = projects.get(row.get("project_uid", ""))
|
||||
if not can_view_project(project, user):
|
||||
project = None
|
||||
owner_uid = row.get("owner_uid") or row.get("created_by") or ""
|
||||
owner = owners.get(owner_uid)
|
||||
slug = row["ingress_slug"]
|
||||
project_slug = (project or {}).get("slug") or (project or {}).get("uid") or ""
|
||||
entries.append(
|
||||
{
|
||||
"uid": row["uid"],
|
||||
"name": row.get("name") or slug,
|
||||
"slug": slug,
|
||||
"owner_uid": owner_uid,
|
||||
"url": f"{base}/p/{slug}" if base else f"/p/{slug}",
|
||||
"description": (project or {}).get("description", "") or "",
|
||||
"owner": owner["username"] if owner else "",
|
||||
"maturity": maturity.get(row["uid"], {}).get("level", "general"),
|
||||
"project_url": f"/projects/{project_slug}" if project_slug else "",
|
||||
}
|
||||
)
|
||||
return entries
|
||||
|
||||
|
||||
@router.get("/index", response_class=HTMLResponse)
|
||||
async def workspace_index(request: Request, page: int = 1):
|
||||
user = get_current_user(request)
|
||||
rows = published_instances()
|
||||
pagination = build_pagination(page, len(rows), PER_PAGE)
|
||||
offset = (pagination["page"] - 1) * pagination["per_page"]
|
||||
window = rows[offset : offset + pagination["per_page"]]
|
||||
base = site_url(request)
|
||||
seo_ctx = base_seo_context(
|
||||
request,
|
||||
title="Published workspaces",
|
||||
description=(
|
||||
"Every workspace DevPlace members have published to the public ingress, "
|
||||
"with its owner, project and direct link."
|
||||
),
|
||||
breadcrumbs=[
|
||||
{"name": "Home", "url": "/feed"},
|
||||
{"name": "Published workspaces", "url": "/workspaces/index"},
|
||||
],
|
||||
schemas=[website_schema(base)],
|
||||
)
|
||||
return respond(
|
||||
request,
|
||||
"workspace_index.html",
|
||||
{
|
||||
**seo_ctx,
|
||||
"request": request,
|
||||
"user": user,
|
||||
"workspaces": index_entries(window, user),
|
||||
"pagination": pagination,
|
||||
"total": len(rows),
|
||||
},
|
||||
model=WorkspaceIndexOut,
|
||||
)
|
||||
@ -71,15 +71,10 @@ from devplacepy.schemas.containers import (
|
||||
AdminContainerEditOut,
|
||||
AdminContainerInstanceOut,
|
||||
AdminContainersOut,
|
||||
AdminWorkspacesOut,
|
||||
BotFrameOut,
|
||||
ContainersOut,
|
||||
InstanceOut,
|
||||
ScheduleOut,
|
||||
TunnelOut,
|
||||
WorkspaceFlagOut,
|
||||
WorkspaceOut,
|
||||
WorkspaceViewOut,
|
||||
)
|
||||
from devplacepy.schemas.jobs import (
|
||||
DbQueryJobOut,
|
||||
@ -183,20 +178,3 @@ from devplacepy.schemas.game import (
|
||||
GameQuestOut,
|
||||
GameStateOut,
|
||||
)
|
||||
from devplacepy.schemas.moderation import (
|
||||
AcceptTermsOut,
|
||||
AccountDeletionOut,
|
||||
AdminModerationOut,
|
||||
AdminReportOut,
|
||||
ConsentListOut,
|
||||
ConsentOut,
|
||||
MaturityOut,
|
||||
ModerationActionOut,
|
||||
ReportCreatedOut,
|
||||
ReportListOut,
|
||||
ReportOut,
|
||||
ReportReasonsOut,
|
||||
SlaOut,
|
||||
WorkspaceIndexItemOut,
|
||||
WorkspaceIndexOut,
|
||||
)
|
||||
|
||||
@ -99,76 +99,3 @@ class AdminBotsOut(_Out):
|
||||
service_status: str = ""
|
||||
admin_section: Optional[str] = None
|
||||
user: Optional[Any] = None
|
||||
|
||||
|
||||
class TunnelOut(_Out):
|
||||
uid: str = ""
|
||||
instance_uid: str = ""
|
||||
project_uid: str = ""
|
||||
user_uid: str = ""
|
||||
hostname: str = ""
|
||||
label: str = ""
|
||||
container_port: int = 0
|
||||
desired_state: str = ""
|
||||
status: str = ""
|
||||
cert_status: str = ""
|
||||
request_count: int = 0
|
||||
bytes_out: int = 0
|
||||
last_request_at: str = ""
|
||||
last_error: str = ""
|
||||
created_at: str = ""
|
||||
|
||||
|
||||
class WorkspaceFlagOut(_Out):
|
||||
uid: str = ""
|
||||
instance_uid: str = ""
|
||||
user_uid: str = ""
|
||||
kind: str = ""
|
||||
severity: str = ""
|
||||
detail: str = ""
|
||||
metric_value: float = 0.0
|
||||
threshold: float = 0.0
|
||||
status: str = ""
|
||||
created_at: str = ""
|
||||
|
||||
|
||||
class WorkspaceViewOut(_Out):
|
||||
uid: str = ""
|
||||
name: str = ""
|
||||
status: str = ""
|
||||
desired_state: str = ""
|
||||
suspended: bool = False
|
||||
flag_reason: str = ""
|
||||
tunnel_name: str = ""
|
||||
primary_url: str = ""
|
||||
last_active_at: str = ""
|
||||
disk_bytes: int = 0
|
||||
disk_quota_mb: int = 0
|
||||
disk_percent: int = 0
|
||||
egress_bytes: int = 0
|
||||
egress_quota_mb: int = 0
|
||||
egress_percent: int = 0
|
||||
idle_stop_minutes: int = 0
|
||||
retention_days: int = 0
|
||||
max_tunnels: int = 0
|
||||
tunnels: list[TunnelOut] = []
|
||||
flags: list[WorkspaceFlagOut] = []
|
||||
|
||||
|
||||
class WorkspaceOut(_Out):
|
||||
project: Optional[Any] = None
|
||||
workspace: Optional[WorkspaceViewOut] = None
|
||||
has_workspace: bool = False
|
||||
viewer_can_workspace: bool = False
|
||||
workspace_count: int = 0
|
||||
max_workspaces: int = 0
|
||||
editor_url: str = ""
|
||||
editor_password: str = ""
|
||||
user: Optional[Any] = None
|
||||
|
||||
|
||||
class AdminWorkspacesOut(_Out):
|
||||
workspaces: list[WorkspaceViewOut] = []
|
||||
flags: list[WorkspaceFlagOut] = []
|
||||
admin_section: Optional[str] = None
|
||||
user: Optional[Any] = None
|
||||
|
||||
@ -23,7 +23,6 @@ from devplacepy.schemas.content import (
|
||||
|
||||
class FeedItemOut(_Out):
|
||||
post: PostOut
|
||||
maturity: Optional[str] = None
|
||||
author: Optional[UserOut] = None
|
||||
time_ago: Optional[str] = None
|
||||
my_vote: int = 0
|
||||
@ -38,7 +37,6 @@ class FeedItemOut(_Out):
|
||||
|
||||
class GistItemOut(_Out):
|
||||
gist: GistOut
|
||||
maturity: Optional[str] = None
|
||||
author: Optional[UserOut] = None
|
||||
time_ago: Optional[str] = None
|
||||
my_vote: int = 0
|
||||
@ -122,7 +120,6 @@ class FeedOut(_Out):
|
||||
|
||||
|
||||
class PostDetailOut(_Out):
|
||||
maturity: Optional[str] = None
|
||||
post: PostOut
|
||||
author: Optional[UserOut] = None
|
||||
is_owner: bool = False
|
||||
@ -152,7 +149,6 @@ class ProjectsOut(_Out):
|
||||
|
||||
|
||||
class ProjectDetailOut(_Out):
|
||||
maturity: Optional[str] = None
|
||||
project: ProjectOut
|
||||
author: Optional[UserOut] = None
|
||||
is_owner: bool = False
|
||||
@ -167,8 +163,6 @@ class ProjectDetailOut(_Out):
|
||||
is_private: bool = False
|
||||
read_only: bool = False
|
||||
viewer_can_containers: bool = False
|
||||
viewer_can_workspace: bool = False
|
||||
workspace_editor_url: Optional[str] = None
|
||||
forked_from: Optional[dict] = None
|
||||
fork_count: int = 0
|
||||
file_count: int = 0
|
||||
@ -187,7 +181,6 @@ class GistsOut(_Out):
|
||||
|
||||
|
||||
class GistDetailOut(_Out):
|
||||
maturity: Optional[str] = None
|
||||
gist: GistOut
|
||||
author: Optional[UserOut] = None
|
||||
is_owner: bool = False
|
||||
@ -206,7 +199,6 @@ class NewsListOut(_Out):
|
||||
|
||||
|
||||
class NewsDetailOut(_Out):
|
||||
maturity: Optional[str] = None
|
||||
article: NewsOut
|
||||
canonical_slug: Optional[str] = None
|
||||
image_url: Optional[str] = None
|
||||
|
||||
@ -1,136 +0,0 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Optional
|
||||
|
||||
from devplacepy.schemas.admin import AdminUserOut
|
||||
from devplacepy.schemas.base import _Out
|
||||
|
||||
|
||||
class ReportOut(_Out):
|
||||
uid: Optional[str] = None
|
||||
target_type: Optional[str] = None
|
||||
target_uid: Optional[str] = None
|
||||
target_url: Optional[str] = None
|
||||
reason: Optional[str] = None
|
||||
reason_label: Optional[str] = None
|
||||
detail: Optional[str] = None
|
||||
severity: Optional[str] = None
|
||||
status: Optional[str] = None
|
||||
origin: Optional[str] = None
|
||||
categories: list[str] = []
|
||||
created_at: Optional[str] = None
|
||||
updated_at: Optional[str] = None
|
||||
resolved_at: Optional[str] = None
|
||||
reporter_name: Optional[str] = None
|
||||
owner_name: Optional[str] = None
|
||||
report_count: Optional[int] = None
|
||||
|
||||
|
||||
class ReportCreatedOut(_Out):
|
||||
report: Optional[ReportOut] = None
|
||||
sla_hours: Optional[int] = None
|
||||
message: Optional[str] = None
|
||||
|
||||
|
||||
class ReportListOut(_Out):
|
||||
reports: list[ReportOut] = []
|
||||
pagination: Optional[Any] = None
|
||||
status: Optional[str] = None
|
||||
reasons: list[Any] = []
|
||||
|
||||
|
||||
class ReportReasonsOut(_Out):
|
||||
reasons: list[Any] = []
|
||||
severities: list[str] = []
|
||||
|
||||
|
||||
class ModerationActionOut(_Out):
|
||||
uid: Optional[str] = None
|
||||
report_uid: Optional[str] = None
|
||||
action: Optional[str] = None
|
||||
actor_name: Optional[str] = None
|
||||
reason: Optional[str] = None
|
||||
notes: Optional[str] = None
|
||||
expires_at: Optional[str] = None
|
||||
created_at: Optional[str] = None
|
||||
|
||||
|
||||
class SlaOut(_Out):
|
||||
sla_hours: Optional[int] = None
|
||||
oldest_open_hours: Optional[float] = None
|
||||
oldest_open_uid: Optional[str] = None
|
||||
breached: Optional[int] = None
|
||||
within_sla: Optional[bool] = None
|
||||
|
||||
|
||||
class AdminModerationOut(_Out):
|
||||
reports: list[ReportOut] = []
|
||||
pagination: Optional[Any] = None
|
||||
status: Optional[str] = None
|
||||
statuses: list[Any] = []
|
||||
counts: dict = {}
|
||||
sla: Optional[SlaOut] = None
|
||||
admin_section: Optional[str] = None
|
||||
|
||||
|
||||
class AdminReportOut(_Out):
|
||||
report: Optional[ReportOut] = None
|
||||
actions: list[ModerationActionOut] = []
|
||||
history: list[ModerationActionOut] = []
|
||||
available_actions: list[str] = []
|
||||
can_remove: Optional[bool] = None
|
||||
subject: Optional[AdminUserOut] = None
|
||||
sla: Optional[SlaOut] = None
|
||||
admin_section: Optional[str] = None
|
||||
|
||||
|
||||
class MaturityOut(_Out):
|
||||
target_type: Optional[str] = None
|
||||
target_uid: Optional[str] = None
|
||||
level: Optional[str] = None
|
||||
source: Optional[str] = None
|
||||
|
||||
|
||||
class ConsentOut(_Out):
|
||||
kind: Optional[str] = None
|
||||
label: Optional[str] = None
|
||||
state: Optional[str] = None
|
||||
version: Optional[str] = None
|
||||
granted_at: Optional[str] = None
|
||||
withdrawn_at: Optional[str] = None
|
||||
|
||||
|
||||
class ConsentListOut(_Out):
|
||||
consents: list[ConsentOut] = []
|
||||
|
||||
|
||||
class AcceptTermsOut(_Out):
|
||||
terms_version: Optional[str] = None
|
||||
accepted_version: Optional[str] = None
|
||||
|
||||
|
||||
class AccountDeletionOut(_Out):
|
||||
username: Optional[str] = None
|
||||
grace_hours: Optional[int] = None
|
||||
retained: list[str] = []
|
||||
removed: list[str] = []
|
||||
|
||||
|
||||
class WorkspaceIndexItemOut(_Out):
|
||||
uid: Optional[str] = None
|
||||
name: Optional[str] = None
|
||||
slug: Optional[str] = None
|
||||
owner_uid: Optional[str] = None
|
||||
url: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
owner: Optional[str] = None
|
||||
maturity: Optional[str] = None
|
||||
project_url: Optional[str] = None
|
||||
|
||||
|
||||
class WorkspaceIndexOut(_Out):
|
||||
workspaces: list[WorkspaceIndexItemOut] = []
|
||||
pagination: Optional[Any] = None
|
||||
total: Optional[int] = None
|
||||
@ -82,15 +82,6 @@ class ProfileOut(_Out):
|
||||
awards_count: int = 0
|
||||
prominent_award: Optional[AwardOut] = None
|
||||
can_give_award: bool = False
|
||||
consents: list[Any] = []
|
||||
mature_opt_in: bool = False
|
||||
age_band: Optional[str] = None
|
||||
terms_version: Optional[str] = None
|
||||
terms_accepted_at: Optional[str] = None
|
||||
current_terms_version: Optional[str] = None
|
||||
suspended_until: Optional[str] = None
|
||||
suspension_reason: Optional[str] = None
|
||||
deletion_grace_hours: Optional[int] = None
|
||||
|
||||
|
||||
class TelegramPairOut(_Out):
|
||||
|
||||
@ -82,7 +82,6 @@ class QuizOut(_Out):
|
||||
|
||||
|
||||
class QuizDetailOut(_Out):
|
||||
maturity: Optional[str] = None
|
||||
quiz: QuizOut = QuizOut()
|
||||
questions: list[QuizQuestionOut] = []
|
||||
comments: list[CommentItemOut] = []
|
||||
|
||||
@ -15,15 +15,6 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
SITE_NAME = "DevPlace"
|
||||
SITEMAP_URL_LIMIT = 5000
|
||||
|
||||
LEGAL_DOC_SLUGS = (
|
||||
"terms",
|
||||
"community-guidelines",
|
||||
"privacy",
|
||||
"content-moderation",
|
||||
"intellectual-property",
|
||||
"contact",
|
||||
)
|
||||
SITEMAP_TTL = int(os.environ.get("DEVPLACE_SITEMAP_TTL", "3600"))
|
||||
_sitemap_cache = {}
|
||||
|
||||
@ -433,18 +424,6 @@ 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}/workspaces/index", changefreq="daily", priority="0.6")
|
||||
)
|
||||
urlset.append(
|
||||
url_element(f"{base_url}/reports/reasons", changefreq="monthly", priority="0.4")
|
||||
)
|
||||
for slug in LEGAL_DOC_SLUGS:
|
||||
urlset.append(
|
||||
url_element(
|
||||
f"{base_url}/docs/{slug}.html", changefreq="monthly", priority="0.5"
|
||||
)
|
||||
)
|
||||
|
||||
if "posts" in db.tables:
|
||||
posts = _collect(
|
||||
@ -554,13 +533,9 @@ def _build_sitemap(base_url):
|
||||
try:
|
||||
from devplacepy.routers.docs.pages import DOCS_PAGES
|
||||
|
||||
listed = set(LEGAL_DOC_SLUGS)
|
||||
for page in DOCS_PAGES:
|
||||
if page.get("admin") or page.get("kind") == "live":
|
||||
continue
|
||||
if page["slug"] in listed:
|
||||
continue
|
||||
listed.add(page["slug"])
|
||||
urlset.append(
|
||||
url_element(
|
||||
f"{base_url}/docs/{page['slug']}.html",
|
||||
|
||||
@ -114,10 +114,6 @@ devplace devii reset-quota --guests # Reset every guest quota
|
||||
devplace devii reset-quota --all # Reset every quota (users and guests)
|
||||
```
|
||||
|
||||
## Moderation housekeeping (`services/moderation/service.py`)
|
||||
|
||||
`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`.
|
||||
|
||||
## 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:
|
||||
@ -181,16 +177,14 @@ 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.
|
||||
|
||||
**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.
|
||||
|
||||
**Live path (lock owner only), ONE set on ONE topic, change-only + hysteresis:** `PresenceRelayService` (`services/presence_relay.py`, `BaseService`, default-enabled, 2s tick, registered in `main.py`) is a sibling of `NotificationRelayService`/`LiveViewRelayService` and is **the single source of truth for live online status**. Each tick, only while the roster topic has subscribers, it reads the online population in ONE indexed query (`presence.online_candidates()`, capped at `config.PRESENCE_TRACK_LIMIT`, env `DEVPLACE_PRESENCE_TRACK_LIMIT`, default 500) and recomputes ONE set `self._online` with **hysteresis** via `presence.stays_online(elapsed, was_online)`: a user becomes online at `PRESENCE_TIMEOUT_SECONDS` but only drops after `+ PRESENCE_ONLINE_MARGIN_SECONDS` (env `DEVPLACE_PRESENCE_ONLINE_MARGIN_SECONDS`, default 20) - **quick to go online, slow (grace margin) to go offline** - which kills boundary flicker. It publishes that set on the ONE shared topic `public.presence.roster` (`roster_payload`: `{count, online: [uid...], users: [display rows]}`) **only when the set of online uids changes** (a `frozenset` compare, so reordering never republishes), never on a fixed interval, so an idle site emits nothing. `online` is the authority for EVERY avatar dot; `users` is the same set trimmed to `PRESENCE_ONLINE_LIMIT` for the feed's avatar panel, and `count` matches it. **There are no per-user `public.presence.{uid}` topics** - they were removed precisely because their candidate population differed from the roster's, so dots and roster could disagree (the /messages-vs-feed bug). One set, one topic, one frame. `public.presence.roster` is subscribable by any logged-in user (`pubsub/policy.py` allows `public.*`); guests keep the server-rendered initial state.
|
||||
**Live path (lock owner only), change-only + hysteresis:** `PresenceRelayService` (`services/presence_relay.py`, `BaseService`, default-enabled, 2s tick, registered in `main.py`) is a sibling of `NotificationRelayService`/`LiveViewRelayService`. Each tick it recomputes ONE global online set `self._online` (dot-subscribed uids batch-read via `get_users_by_uids` + roster candidates) and drives BOTH the per-user dots and the feed roster from that one set, so they can never disagree. The set uses **hysteresis** via `presence.stays_online(elapsed, was_online)`: a user becomes online at `PRESENCE_TIMEOUT_SECONDS` but only drops after `+ PRESENCE_ONLINE_MARGIN_SECONDS` (env `DEVPLACE_PRESENCE_ONLINE_MARGIN_SECONDS`, default 20) - **quick to go online, slow (grace margin) to go offline** - which kills boundary flicker for a user hovering near the timeout. It publishes `{online, last_seen}` to `public.presence.{uid}` **only when a topic's `online` bool changed OR the topic is newly subscribed (first-seen)** - never on a fixed interval, so a steady page emits nothing after the initial frame (`self._published[topic] -> bool`, pruned to active topics). `public.presence.{uid}` is subscribable by any logged-in user (`pubsub/policy.py` allows `public.*`); guests fall back to the server-rendered initial state. The one-directional grace also means dots and roster stay consistent across viewers (the shared `self._online` is the single authority). To keep it lightweight the relay reads all due users in **one batched `get_users_by_uids`** per tick.
|
||||
|
||||
**Frontend:** `static/js/PresenceManager.js` (`app.presence`, constructed with `this.pubsub` in `Application.js`, mirroring `LocalTime`/`CounterManager`) makes ONE subscription to `public.presence.roster` and keeps the pushed `online` uid set. It scans `[data-presence-uid]` elements (on load and via a `MutationObserver`, so dynamically inserted markup is covered) and renders each one purely as membership of that set - toggling the `online` class and, for `data-presence-label` elements, the "online / last seen X / offline" text, where the relative time is a `<time data-dt data-dt-mode="ago">` formatted by the shared `LocalTime` (presence never formats a date itself). **There is NO client-side clock, no `data-presence-timeout`, and no expiry timer** - the old `isOnline(lastSeen)` fallback was a second decider that silently drifted a dot to grey after the timeout whenever a frame was missed, which is exactly how /messages diverged from the feed. Before the first frame arrives the server-rendered state simply stands. The constructor takes an optional `root` (`new PresenceManager(pubsub, root)`) so a detached widget can scope it; `dp-chat mode="embed"` uses that only when no page-global `app.presence` exists. Both the profile `.profile-presence` dot and the messages `#messages-presence` span carry `data-presence-uid`/`data-presence-last-seen`.
|
||||
**Frontend:** `static/js/PresenceManager.js` (`app.presence`, constructed with `this.pubsub` in `Application.js`, mirroring `LocalTime`/`CounterManager`) scans `[data-presence-uid]` elements, subscribes each uid to `public.presence.{uid}` (deduped per uid, so a repeated author is one subscription), and treats each frame's `online` flag as **authoritative** (`entry.online`), toggling the `online` class + (for `data-presence-label` elements) the "online / last seen X / offline" text. Because the relay is change-only and authoritative, a live-subscribed dot is **not** expired by the client clock (no false-offline flicker for an active user whose `last_seen` the client cannot see advancing); the 20s `last_seen` staleness timer only applies to entries that never received a frame (guests / degraded). The window is read from `<body data-presence-timeout>`. Both the profile `.profile-presence` dot and the messages `#messages-presence` span carry `data-presence-uid`/`data-presence-last-seen`.
|
||||
|
||||
**Online-now roster (feed):** the feed panel is just the display face of the same frame. `services/presence.py` `online_users(limit=PRESENCE_ONLINE_LIMIT)` (strict cutoff, feed initial render) and `online_candidates(limit=PRESENCE_TRACK_LIMIT)` (grace window, the relay's authority population) both go through `database.get_online_users(cutoff_iso, limit)`, which reads users with `last_seen >= cutoff` via the `idx_users_last_seen` index (the one place presence is queried by `last_seen`). **The list is ordered ALPHABETICALLY by username** (`get_online_users` `order_by=["username"]` + case-insensitive `presence.sort_by_username`), NOT by recency, so avatars keep a stable position. `routers/feed.py` puts `online_users` on the context (`FeedOut.online_users`) and `feed.html` renders the initial **Online now** panel as a `.sidebar-section` at the bottom of the left feed sidebar; `static/js/OnlineUsers.js` (`app.onlineUsers`) subscribes to `public.presence.roster` and re-renders the avatar list + count from `users`/`count`. **A roster avatar is NOT a special case** - it renders the same `_presence_dot.html` (server) / `Avatar.badgeElement` (client) as every other avatar, subscribed like every other dot, so it cannot disagree with the rest of the page.
|
||||
**Online-now roster (feed):** the same relay maintains ONE shared topic `public.presence.roster`, republished **only when the SET of online uids changes** (a `frozenset` compare, so pure reordering never republishes). `services/presence.py` `online_users(limit)` (strict, feed initial render) and `online_candidates(limit)` (grace window, relay hysteresis) both go through `database.get_online_users(cutoff_iso, limit)`, which reads users with `last_seen >= cutoff` via the `idx_users_last_seen` index (the one place presence is queried by `last_seen`; `config.PRESENCE_ONLINE_LIMIT`, env `DEVPLACE_PRESENCE_ONLINE_LIMIT`, default 30). **The list is ordered ALPHABETICALLY by username** (`get_online_users` `order_by=["username"]` + case-insensitive `presence.sort_by_username`), NOT by recency, so avatars keep a stable position and do not needlessly reshuffle as people's `last_seen` ticks. `routers/feed.py` puts `online_users` on the context (`FeedOut.online_users`) and `feed.html` renders the initial **Online now** panel as a `.sidebar-section` at the bottom of the left feed sidebar (`aside.sidebar-card`); `static/js/OnlineUsers.js` (`app.onlineUsers`) subscribes to `public.presence.roster` and re-renders the avatar list + count live. Roster avatars use a plain green `.presence-dot` with NO `data-presence-uid` (list membership IS the presence, so no per-user subscription - the relay drops a user from the roster when they go offline).
|
||||
|
||||
**Avatar presence dot (sitewide, DRY):** a small corner dot on **every** user avatar (green online, muted grey offline) comes from ONE reusable partial `templates/_presence_dot.html` - `<span class="presence-dot" data-presence-uid data-presence-last-seen>` guarded on `_user.get('uid')` (a partial-dict author, e.g. the issues includes, renders no dot). It carries **no** `data-presence-label`, so `PresenceManager` colours it with zero extra JS. It is included by the shared avatar partial `templates/_avatar_link.html` (its `.user-avatar-link` anchor is the positioning host, covering ~19 sites) and by the handful of raw-`<img class="avatar-img">` sites wrapped in a positioned `<span class="avatar-badge">` (the two `base.html` nav avatars, the `profile.html` hero + followers list, the `messages.html` conversation list). CSS in `static/css/base.css` (`.user-avatar-link`/`.avatar-badge` `position:relative;display:inline-flex`, `.presence-dot` sized `30%` of the avatar clamped 8-14px with a `--bg-card` ring, `.online` -> `--success`), so it is proportional and responsive at every avatar size with no per-size class. `database/follows.py` `get_follow_list` now carries `last_seen` in its trimmed dict so the followers/following dots resolve (all other author dicts are full `get_users_by_uids` rows). `dp-avatar` (`AppAvatar.js`) is docs-demo only (no real user avatars) and is intentionally out of scope. In JavaScript the matching builder is `Avatar.badgeElement(user)` (`static/js/Avatar.js`), which emits the `.avatar-badge` + `.presence-dot` + award-badge trio and is used by `OnlineUsers` and `AppChat._buildConversationItem`, so dot markup lives in exactly two places: the partial and that helper. Reuse `_presence_dot.html` (server) or `Avatar.badgeElement` (client) for any new avatar surface - never hand-roll a presence dot.
|
||||
**Avatar presence dot (sitewide, DRY):** a small corner dot on **every** user avatar (green online, muted grey offline) comes from ONE reusable partial `templates/_presence_dot.html` - `<span class="presence-dot" data-presence-uid data-presence-last-seen>` guarded on `_user.get('uid')` (a partial-dict author, e.g. the issues includes, renders no dot). It carries **no** `data-presence-label`, so `PresenceManager` colours it with zero extra JS. It is included by the shared avatar partial `templates/_avatar_link.html` (its `.user-avatar-link` anchor is the positioning host, covering ~19 sites) and by the handful of raw-`<img class="avatar-img">` sites wrapped in a positioned `<span class="avatar-badge">` (the two `base.html` nav avatars, the `profile.html` hero + followers list, the `messages.html` conversation list). CSS in `static/css/base.css` (`.user-avatar-link`/`.avatar-badge` `position:relative;display:inline-flex`, `.presence-dot` sized `30%` of the avatar clamped 8-14px with a `--bg-card` ring, `.online` -> `--success`), so it is proportional and responsive at every avatar size with no per-size class. `database/follows.py` `get_follow_list` now carries `last_seen` in its trimmed dict so the followers/following dots resolve (all other author dicts are full `get_users_by_uids` rows). `dp-avatar` (`AppAvatar.js`) is docs-demo only (no real user avatars) and is intentionally out of scope. Reuse `_presence_dot.html` + the `.avatar-badge` wrapper for any new avatar surface - never hand-roll a presence dot.
|
||||
|
||||
**Messaging refactor:** the old presence was per-worker and WS-connect-based (`message_hub.is_online`/`last_seen`, `_announce_presence`, the WS `presence` frame) and broke with >1 worker. That display path was **removed**; `message_hub` keeps only its socket connection tracking for message delivery. The messages header presence is now the shared `PresenceManager`, so chat presence is finally cross-worker correct. **Never re-implement WS-connect presence** - reuse `presence.is_online`, the `public.presence.roster` topic, and `PresenceManager`. `dp-chat` likewise no longer carries its own presence renderer or `PubSubClient`: that private duplicate only ever showed online/offline (never `last seen`) and was a third source of truth.
|
||||
**Messaging refactor:** the old presence was per-worker and WS-connect-based (`message_hub.is_online`/`last_seen`, `_announce_presence`, the WS `presence` frame) and broke with >1 worker. That display path was **removed**; `message_hub` keeps only its socket connection tracking for message delivery. The messages header presence is now the shared `PresenceManager`, so chat presence is finally cross-worker correct. **Never re-implement WS-connect presence** - reuse `presence.is_online`, the `public.presence.{uid}` topic, and `PresenceManager`.
|
||||
|
||||
@ -4,7 +4,7 @@ import secrets
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Optional
|
||||
|
||||
from devplacepy.database import get_table, get_int_setting, is_account_active
|
||||
from devplacepy.database import get_table, get_int_setting
|
||||
from devplacepy.config import SECONDS_PER_DAY
|
||||
from devplacepy.utils import generate_uid
|
||||
|
||||
@ -77,7 +77,7 @@ def resolve_token(token: str) -> Optional[dict]:
|
||||
return None
|
||||
|
||||
user = get_table("users").find_one(uid=row.get("user_uid"))
|
||||
if not user or not is_account_active(user):
|
||||
if not user or not user.get("is_active", True):
|
||||
return None
|
||||
return user
|
||||
|
||||
|
||||
@ -2,7 +2,7 @@ This file documents the audit log subsystem. Claude Code auto-loads it when a fi
|
||||
|
||||
## Audit log (`services/audit/`)
|
||||
|
||||
**Admin-only, append-only** record of every state-changing action. The authoritative event catalogue is `events.md` (its storage model, relation vocabulary, and per-event specs are authoritative); the catalogue currently spans 288 keys across 42 domains.
|
||||
**Admin-only, append-only** record of every state-changing action. The authoritative event catalogue is `events.md` (its storage model, relation vocabulary, and per-event specs are authoritative); the catalogue currently spans 223 keys across 38 domains.
|
||||
|
||||
### Package layout
|
||||
|
||||
|
||||
@ -3,12 +3,6 @@
|
||||
CATEGORY_BY_PREFIX: dict[str, str] = {
|
||||
"auth": "auth",
|
||||
"profile": "account",
|
||||
"account": "account",
|
||||
"consent": "account",
|
||||
"terms": "account",
|
||||
"report": "moderation",
|
||||
"moderation": "moderation",
|
||||
"filter": "moderation",
|
||||
"follow": "social",
|
||||
"award": "social",
|
||||
"relation": "social",
|
||||
|
||||
@ -10,8 +10,6 @@ from devplacepy.services.bot.handles import make_handle
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
SIGNUP_BIRTH_DATE = "1995-01-01"
|
||||
|
||||
|
||||
class BotAuthMixin:
|
||||
def _generate_handle(self) -> str:
|
||||
@ -72,10 +70,6 @@ class BotAuthMixin:
|
||||
await b.fill("#password", self.state.password)
|
||||
await b._idle(0.2, 0.5)
|
||||
await b.fill("#confirm_password", self.state.password)
|
||||
await b._idle(0.2, 0.5)
|
||||
await b.fill("#birth_date", SIGNUP_BIRTH_DATE)
|
||||
await b._idle(0.2, 0.5)
|
||||
await b.click("#accept_terms")
|
||||
await b._idle(0.5, 1.0)
|
||||
await b.click(".auth-submit")
|
||||
await asyncio.sleep(2)
|
||||
|
||||
@ -120,7 +120,6 @@ The security hotpatch that used to run per build is now baked into `ppy.Dockerfi
|
||||
- `COPY`s the **sudo superclone** (`files/sudo`) over `/usr/local/bin/sudo` (+ symlink `/usr/bin/sudo`; the real `sudo` package is not installed).
|
||||
- `COPY`s the **`aptroot` fakeroot wrapper** (`files/aptroot`, symlinked over `apt`/`apt-get`/`dpkg` in `/usr/local/bin` so pravda installs system packages without root).
|
||||
- `COPY`s **`pagent`** (`files/pagent`, the stdlib AI agent; reads `DEVPLACE_OPENAI_URL`+`DEVPLACE_API_KEY`, falling back to its public endpoint + `DEEPSEEK_API_KEY`) to `/usr/bin/pagent.py`, plus `files/.vimrc` to `/home/pravda/.vimrc` (whose AI helper - `AiEditSelection` - targets the same gateway as pagent via `DEVPLACE_OPENAI_URL`/`DEVPLACE_API_KEY`, with a public fallback, never `api.openai.com`).
|
||||
- `COPY`s **`dpc`** (`files/dpc`, DevPlace Code, the Claude-Code-class coding agent) to `/usr/bin/dpc`, and **`bot.py`** to `/usr/bin/botje.py`. **`dpc` is the one prebuilt binary in this repository**: an ELF 64-bit x86-64 position-independent executable, 10850272 bytes, sha256 `0c0f980717deebed285dcde7968c249f77638a65d76af8ae5deeda3ac2b0f042`. Its source is NOT in this repository and there is no build recipe here, so unlike every other file in `files/` it cannot be reviewed before it is installed root-owned onto `PATH` in every user container. Record a new size and checksum here whenever it is replaced - this record is the only integrity check that exists on it, so a stale entry is worse than none. (The previous entry, 3578664 bytes / sha256 `24f7fbb0...`, described a build that is no longer the file on disk.)
|
||||
- Evicts any pre-existing uid-1000 user, creates user **`pravda` at `1000:1000`**.
|
||||
- Hands pravda ownership of the toolchain AND the OS package trees (`chown -R pravda` over `/usr/local/lib`, `/usr/local/bin`, `/usr/lib/python3`, `/opt`, `/app`, `/home/pravda`, plus `/usr/lib`, `/usr/bin`, `/usr/sbin`, `/usr/share`, `/usr/include`, `/etc`, `/var/lib`, `/var/cache`, `/var/log`, `/srv` so `apt`/`dpkg` can write; `~/.local/bin` on `PATH`).
|
||||
- Ends on `USER pravda`.
|
||||
@ -172,8 +171,6 @@ Both `api.sync_workspace` (HTTP/Devii sync action, returns `{exported, imported}
|
||||
|
||||
An instance's `run_as_uid` column selects WHICH DevPlace user's identity and `api_key` are injected (`DEVPLACE_API_KEY`, `DEVPLACE_USER_UID`), resolved in `api.pravda_env` ahead of the `created_by`/`owner_uid` fallback chain. It does **NOT** change the container OS user, which is ALWAYS `pravda` (uid 1000) - required for the bind-mounted `/app` (DooD uid maps 1:1 to host). Validate it against an existing user via `api.validate_run_as`.
|
||||
|
||||
**Running as someone else requires their consent (load-bearing).** `validate_run_as(run_as_uid, actor_uid)` refuses when the run-as user is not the actor and has not granted the `container_credentials` consent. This is not a policy nicety: `pravda_env` injects that user's real `DEVPLACE_API_KEY` into a container someone else operates, so without the gate an administrator could hand any member's platform credential to software that member never saw. `create_instance` and `update_instance_config` both pass `_actor_uid(actor)`, so the gate covers the admin UI, the per-project manager and the Devii container tools at once. Running as **yourself** needs no consent - you are the one handing over your own credential. The consent is granted and withdrawn from the member's own profile privacy tab like every other consent; see `devplacepy/services/moderation/CLAUDE.md`.
|
||||
|
||||
## Boot source precedence (load-bearing)
|
||||
|
||||
Columns `boot_language` (`none`|`python`|`bash`) + `boot_script` (multiline source) sit alongside the legacy `boot_command`. Precedence in `api.run_spec_for`: `boot_script` (by language) > `boot_command` > image CMD (`sleep infinity`). When a boot script is set, the reconciler writes it into the workspace (`api.materialize_boot_script`, `.devplace_boot.py`/`.devplace_boot.sh`, excluded from sync) before launch and runs `python|bash /app/.devplace_boot.<ext>`. `api.validate_boot` enforces the language set and a 100k char cap.
|
||||
@ -201,251 +198,3 @@ The container runtime is also the basis of "vibe coding": the public prose page
|
||||
When the runtime, the agent binaries, or the `PRAVDA_*`/ingress contract change, update this page alongside the source.
|
||||
|
||||
**The agents are gateway-only:** `dpc`/`d.py` and `botje.py`/`bot.py` use a single `molodetz` backend pointed at `DEVPLACE_OPENAI_URL` (the gateway); the former direct `api.deepseek.com` fallback backend was removed so every in-container AI call is ledgered under the run-as user and nothing bypasses `gateway_usage_ledger`. `pagent`/`.vimrc` already posted to the gateway URL (using `DEEPSEEK_API_KEY` only as a key fallback, never the DeepSeek endpoint). Rebuild the image (`make ppy`) for the change to reach running containers.
|
||||
|
||||
## Dev Workspaces (`workspace/`, `workspace_service.py`, `activity.py`, `forward.py`)
|
||||
|
||||
A **workspace** is a member-facing container running `code-server`, layered on this same runtime. The
|
||||
admin container manager's authorization is unchanged; workspaces add their own narrower predicates.
|
||||
|
||||
**Two planes, deliberately distinct.**
|
||||
|
||||
| | Plane A: editor | Plane B: user's servers |
|
||||
|---|---|---|
|
||||
| Entry | `/projects/{slug}/containers/instances/{uid}/code/...` | `{port}-{name}.tunnel.pravda.education` |
|
||||
| Auth | session + `can_manage_workspace` | none, public by design |
|
||||
| Backend | code-server, `--auth password`, bound in-container | whatever the user runs |
|
||||
|
||||
Plane B routing is **one static molohttp site** `*.tunnel.pravda.education -> 127.0.0.1:10500`;
|
||||
DevPlace resolves the instance from the `Host` header. There is no molohttp object per tunnel.
|
||||
`TunnelDispatchMiddleware` (`main.py`) is ASGI-level and pre-empts the router for both HTTP and
|
||||
WebSocket, so a tunnel host can never render the application. molohttp's `HostIndex` glob matches
|
||||
**exactly one label**, which is why the pattern is `{port}-{name}`, never `{port}.{name}`.
|
||||
|
||||
**One forwarding core.** `forward.py` owns header filtering, prefix stripping, `Location` rewriting,
|
||||
`<base>` injection, response streaming and the bidirectional WS pump. `/p/{slug}`, the editor route
|
||||
and the tunnel route all call it. Never write a second proxy, and never let the two planes drift:
|
||||
**both build their headers from the single `base_headers` core**, so anything a tunnelled app sees
|
||||
over HTTP (`X-Real-IP`, `X-Forwarded-For`, `Accept-Language`, custom auth headers) it also sees over
|
||||
a websocket. `forward_headers` adds only what is HTTP-specific (`Host`, `Accept-Encoding: identity`
|
||||
so the internal hop is never compressed); `ws_headers` drops only `WS_HANDSHAKE_HEADERS`, which the
|
||||
`websockets` client regenerates itself. Both planes therefore send the same `Host` (the public one),
|
||||
the same `X-Forwarded-Host`/`-Proto`/`-Prefix`, and the same `X-Script-Name`.
|
||||
|
||||
**The websocket handshake carries exactly ONE `Host`, and it is the public one (load-bearing).**
|
||||
Never put `Host` in `additional_headers`: the `websockets` client already writes its own for the real
|
||||
TCP target and `Headers.update` *appends*, so a second one produces a handshake with two `Host`
|
||||
lines. Node keeps the FIRST (the internal `gateway:port`) and Go's `net/http` rejects the request
|
||||
outright. The fix is `websockets.connect(uri, host=..., port=...)` - the public host goes in the URI
|
||||
(so it becomes the single `Host` header) while the connection still dials the container. code-server
|
||||
resolves the request host as `Forwarded` -> `X-Forwarded-Host` -> `Host` and runs
|
||||
`authenticateOrigin` on EVERY websocket (`wsRouter.ws(/.*/, ensureOrigin, ...)`), so with only the
|
||||
losing duplicate it compared the browser's `Origin` (`devplace.net`) against the internal host and
|
||||
answered **403**, and the editor died on *"The workbench failed to connect to the server (Error: Time
|
||||
limit reached)"* - a blank page under the whole
|
||||
`/projects/{slug}/containers/instances/{uid}/code/` sub-path while its HTTP assets loaded fine
|
||||
(`forward_headers` already sent `X-Forwarded-Host`, which is why only the websocket plane broke).
|
||||
This hit the tunnel plane identically. Reproduce with the real image, not a mock: `docker run ppy
|
||||
code-server --auth none` and replay the header set - the origin check runs before authentication.
|
||||
|
||||
**Dial upstream BEFORE accepting the browser socket.** `proxy_ws` connects first and only then calls
|
||||
`websocket.accept(subprotocol=upstream.subprotocol)`. Accepting first turns every upstream refusal
|
||||
into a phantom `101` followed by an immediate `1011`, which is exactly what made the 403 above
|
||||
present as an opaque client-side timeout instead of a handshake failure; it also makes it impossible
|
||||
to echo the negotiated subprotocol, because the answer is not known yet. Subprotocols travel through
|
||||
`subprotocols=` (never as a forwarded header) and come back on the `accept`.
|
||||
|
||||
**Responses stream; only the `<base>`-injected HTML is buffered.** `proxy_http` sends with
|
||||
`stream=True` and returns a `StreamingResponse` over `aiter_raw()`, passing `content-length` and
|
||||
`content-encoding` through untouched (`RESPONSE_HOP_HEADERS` strips hop-by-hop only), so a large
|
||||
download through a tunnel costs a constant few hundred KiB of worker memory instead of its full size,
|
||||
and SSE works. The HTML branch must buffer because injection needs the whole body, and it therefore
|
||||
drops `content-length`/`content-encoding` and lets Starlette recompute. Byte accounting rides the
|
||||
`on_complete(sent)` callback fired when the stream ends - `routers/tunnel.py` uses it for
|
||||
`activity.touch` + `tunnels.record_hit`, which is ONE `touch` per request (`activity` accumulates
|
||||
while throttled, so a second call would double the request counter). **Request bodies stay
|
||||
buffered on purpose**: they are bounded by nginx `client_max_body_size`, and streaming them would
|
||||
force chunked encoding onto arbitrary upstream apps.
|
||||
|
||||
**One keep-alive client, closed on shutdown.** `forward.client()` is a lazily-created module-level
|
||||
`httpx.AsyncClient` with `httpx.Limits` (the `ChromeStealthClient` pattern), closed by
|
||||
`forward.close_client()` in the `main.py` lifespan. A client per request meant a fresh pool and TCP
|
||||
handshake for every one of the workbench's hundreds of assets.
|
||||
|
||||
**Path and query are forwarded byte-exactly.** The route-matched path is `quote()`d before it is
|
||||
interpolated (a filename containing `#` or a space otherwise truncates or corrupts the upstream URL),
|
||||
and the query comes from `raw_query(connection)` = the raw ASGI `scope["query_string"]`, NEVER
|
||||
`request.url.query`/`request.query_params`. Starlette rebuilds `.url` by string-joining path and
|
||||
query, so a literal `#` in the path makes the reconstructed URL treat the query as a fragment and
|
||||
`.url.query` silently returns empty - which would drop code-server's `?reconnectionToken=`.
|
||||
|
||||
**Editor persistence.** code-server's user-data and extensions live in
|
||||
`config.WORKSPACE_STATE_DIR/<instance uid>`, bind-mounted at `WORKSPACE_STATE_MOUNT`, so extensions
|
||||
survive container recreation. `api.editor_command` builds the argv; `run_spec_for` prefers it over
|
||||
the boot-script/boot-command chain when `is_workspace` and `editor_port` are set.
|
||||
|
||||
**Activity and egress are the presence pattern.** `activity.py` keeps a per-worker monotonic dict and
|
||||
writes at most once per `WORKSPACE_ACTIVITY_WRITE_SECONDS`, accumulating egress and request counts
|
||||
into one atomic `COALESCE` UPDATE (`store.record_activity`). Both proxy planes call `touch`; because
|
||||
plane B traverses DevPlace, public traffic is observed directly rather than inferred.
|
||||
|
||||
**`workspace/` package.** `quota.py` resolves limits instance -> user rule -> setting -> default
|
||||
through ONE resolver (never read a workspace setting at a call site). `flags.py` is the abuse ledger
|
||||
and is **idempotent per `(instance_uid, kind)` while a flag is open**, so a sustained condition is one
|
||||
row, not one per tick. `naming.py` generates faker labels with collision retry and owns the hostname
|
||||
patterns plus `is_tunnel_host`. `tunnels.py` is CRUD with revive-not-duplicate. `provision.py` is the
|
||||
create/resume/stop/suspend/view surface and writes `/app/.devplace/tunnels.json`.
|
||||
|
||||
**`WorkspaceService`** is the only new service: lock-owner, `default_enabled=False`, four wrapped
|
||||
synchronous phases (disk sample on its own slow cadence, flag evaluation, lifecycle, purge sweep)
|
||||
plus the async `_issue_tunnel_certificates`. It is a reconciler, not a `JobService`.
|
||||
|
||||
**Tunnel certificates are per-host, and that is forced by molohttp, not a preference.** molohttp's
|
||||
ACME client implements **`http-01` only** (`Sources/MoloHTTP/ACME/AcmeClient.swift`,
|
||||
`AcmeRenewalService.swift` - there is no `dns-01` anywhere), and Let's Encrypt will not issue a
|
||||
wildcard over `http-01`. So a `*.tunnel.pravda.education` **certificate** is impossible today and
|
||||
`workspace_cert_mode=per_host` is the only working mode; the prose above about one static wildcard
|
||||
site describes **routing**, which is correct and already in place (one enabled molohttp site
|
||||
`*.tunnel.pravda.education -> http://127.0.0.1:10500`, no per-tunnel site object). Only the cert is
|
||||
per-host. `tunnels.create` writes a row at `STATUS_PENDING` and nothing else; the row is inert until
|
||||
something certifies it - `pending` is not in `SERVING_STATUSES`, so an un-provisioned tunnel 404s.
|
||||
|
||||
**One state machine, two callers.** `certs.certify(row, log=None)` owns the whole transition:
|
||||
`provisioning` -> `certs.issue(hostname)` (`POST {workspace_molohttp_base_url}/api/v1/certs/issue`
|
||||
with the `x-api-key` header, Basic as fallback) -> `active` or `failed` with `last_error`, then
|
||||
`certs.announce(row, hostname)` sends the owner the live URL (plus the password for the editor
|
||||
tunnel). Never re-implement those transitions at a call site - both paths call `certify`:
|
||||
|
||||
- **Eagerly, at workspace creation.** `provision.ensure` ends with
|
||||
`schedule_certificate(publish_editor_tunnel(...))`, which claims the row as `provisioning`
|
||||
**synchronously** and then spawns `certify` as a loop task, so the editor certificate is ordered
|
||||
the instant the workspace exists rather than up to one service tick later. It is fire-and-forget
|
||||
by necessity: an ACME `http-01` order takes ~10s and `certs.issue` allows up to 180s, so awaiting
|
||||
it would hang the `POST /projects/{slug}/workspace` response. The task is held in
|
||||
`provision._pending_certificates` and discarded on completion - a bare `create_task` reference can
|
||||
be garbage collected mid-flight. The synchronous claim is what closes the race with the tick: the
|
||||
service selects only `pending`, so a row already claimed can never be issued twice and burn a
|
||||
Let's Encrypt duplicate-certificate slot. `schedule_certificate` no-ops (leaving the row `pending`
|
||||
for the tick) when molohttp is unconfigured or there is no running loop.
|
||||
- **On the tick, as the safety net.** `WorkspaceService._issue_tunnel_certificates` reads
|
||||
`tunnels.awaiting_certificate()` (`status=pending` + `desired_state=present` + not deleted) and
|
||||
calls `certify` per row. This covers user-created tunnels, rows predating the eager path, and any
|
||||
creation that happened with molohttp unconfigured.
|
||||
|
||||
The residual cost is the ACME round trip itself, which is irreducible under `http-01`. The tick
|
||||
interval (`service_workspace_interval`, default 30s, floor `min_interval = 5`) no longer sits in
|
||||
front of the editor tunnel, but still bounds how long a user-created tunnel waits.
|
||||
|
||||
**A tunnel serves before its certificate exists.** `SERVING_STATUSES = (provisioning, active)`, so
|
||||
`routers/tunnel.py` routes the host the moment the row is claimed, while the order is still in
|
||||
flight. The wildcard DNS already resolves, so molohttp terminates TLS with its default certificate
|
||||
and the browser shows `ERR_CERT_COMMON_NAME_INVALID` for that window rather than failing to connect.
|
||||
That is deliberate (a reachable host beats a dead one) and is the reason eager issuance matters: the
|
||||
window is only as long as issuance takes. **Only `pending` is retried** - a `failed` row stays failed until the user recreates
|
||||
the tunnel (`create` revives it to `pending`), which keeps a broken host from burning the Let's
|
||||
Encrypt failure rate limit every 30s. **Renewal is molohttp's job, not DevPlace's**: once a host is
|
||||
issued, `AcmeRenewalService` re-issues it against its own expiry threshold forever, so DevPlace never
|
||||
schedules or tracks renewals. This whole phase did not exist - `tunnels` had no reader at all, and
|
||||
the six `workspace_molohttp_*`/`workspace_cert_mode`/`workspace_acme_email` settings were admin
|
||||
fields wired to nothing, which is why every tunnel sat at `pending` with no certificate.
|
||||
|
||||
**Two contracts that bite:**
|
||||
- A `ConfigField` with `type="select"` needs `options=[{"value": ..., "label": ...}]`. Plain strings
|
||||
crash `docs_api.build_services_group`, which `docs_search` indexes, so the whole docs search page
|
||||
500s. No other service used `select` before this one.
|
||||
- A route taking `Annotated[Form, Form()]` validates **before** the handler's auth guard, so a
|
||||
required field makes an anonymous request 422 instead of 401 and `tests/api/auth/matrix.py` fails.
|
||||
Give the field a default and validate it inside the handler after `require_user`.
|
||||
|
||||
**The editor opens in a new tab, directly.** The project detail page renders an inline **VS Code**
|
||||
button in `.project-detail-actions` (`target="_blank"`) straight to the code-server proxy
|
||||
`/projects/{slug}/containers/instances/{uid}/code/`, built by `_editor_url` in
|
||||
`routers/projects/index.py` and carried as `workspace_editor_url` on the context and
|
||||
`ProjectDetailOut`. It is emitted ONLY when the viewer passes `can_open_workspace` AND their
|
||||
workspace for that project exists, is not suspended, and is `store.ST_RUNNING` - the three states the
|
||||
`editor_proxy` route itself refuses (403 suspended, 409 not running, 502 no port), so the button can
|
||||
never open a dead editor. When there is no running workspace the button is absent and the Workspace
|
||||
menu item below is the way in (create/start it there). The workspace page's own **Open editor** link
|
||||
opens in a new tab too; keep both in step.
|
||||
|
||||
**Member entry point** is the project detail page's overflow menu (`project_detail.html`), gated by
|
||||
the `viewer_can_workspace` context flag (`can_open_workspace(project, user)`, set in
|
||||
`routers/projects/index.py` and declared on `ProjectDetailOut`) - exactly the pattern the admin-only
|
||||
**Containers** item uses with `viewer_can_containers`. `can_open_workspace` folds in the
|
||||
`workspace_enabled` master switch, so the item disappears for everyone while the feature is off and
|
||||
the route's own `_guard` stays the authority. **A workspace surface with no context flag is
|
||||
unreachable**: the whole feature shipped once with routes, Devii tools and docs but no link into
|
||||
`/projects/{slug}/workspace`, so it was reachable only by typing the URL - and then 404'd anyway
|
||||
because `workspace_enabled` defaults to `"0"`. Any new workspace surface needs both the flag on the
|
||||
page that links to it and the setting turned on.
|
||||
|
||||
**Admin console** is `/admin/workspaces` (`routers/admin/workspaces.py`, `admin_workspaces.html`):
|
||||
list, start/stop, suspend/unsuspend with a required reason, raise/resolve/dismiss flags, per-user
|
||||
quota rules. `base_seo_context` takes `breadcrumbs`/`schemas`, not `canonical`/`schema`.
|
||||
|
||||
**Devii** has 15 tools under `handler="workspace"`; five are in `CONFIRM_REQUIRED` and every one of
|
||||
them declares a `confirm` param (schemas are `additionalProperties: false`, so a gated tool without it
|
||||
loops forever).
|
||||
|
||||
**Opening a workspace publishes its editor on a public tunnel, automatically.** `provision.ensure`
|
||||
does three things after creating the instance: `api.ensure_editor_password`, then
|
||||
`publish_editor_tunnel` (a `tunnels.create` for the editor port, so the hostname is
|
||||
`{editor_port}-{name}.{domain}`), and the row lands at `pending`. The `WorkspaceService` cert phase
|
||||
then issues the certificate and calls `_announce_tunnel`, which sends the owner a `workspace`
|
||||
notification carrying the live `https://` URL **and the password**. The notification fires on
|
||||
`pending -> active`, not at creation, so the user is only told about a URL that already serves TLS.
|
||||
`provision.is_editor_tunnel(instance, tunnel)` distinguishes the auto-published editor tunnel (its
|
||||
`container_port` equals `editor_port`) from user-created ones, which get a plainer message with no
|
||||
password. This is a deliberate departure from the plane-A/plane-B split described above: the editor
|
||||
is now reachable publicly, which is only acceptable **because** `--auth password` is on - never
|
||||
reintroduce `--auth none` while the editor tunnel is auto-published.
|
||||
|
||||
**The editor is password-protected, per workspace.** `api.editor_command` runs code-server with
|
||||
`--auth password`, and `api.pravda_env` injects the secret as `PASSWORD` (the variable code-server
|
||||
reads). The secret is an 8-character **pronounceable** token from `api.generate_editor_password()`,
|
||||
built as four consonant-vowel pairs (`ronebamu`, `zipesodu`) so a user can read it once and retype it
|
||||
from memory. That is ~24 bits of entropy, which is deliberately weak for convenience: it is
|
||||
acceptable only because the alternative in practice was `--auth none`. If the editor tunnel is ever
|
||||
exposed to untrusted traffic at scale, lengthen the pair count rather than switching alphabet, and
|
||||
keep it pronounceable. `api.ensure_editor_password(instance)` is the single generator/persister: it returns the
|
||||
stored value or mints and saves one, so the password is **stable** across restarts and recreations.
|
||||
It is called from `api.run_spec_for` whenever `is_workspace` - the one point every workspace launch
|
||||
passes through - so a legacy instance created before this column existed gets one on its next boot
|
||||
rather than letting code-server invent an unknowable password of its own. The owner reads it off the
|
||||
workspace page (`editor_password` on the context and on `WorkspaceOut`). **It is deliberately NOT on
|
||||
`WorkspaceViewOut`**: that model is shared with `AdminWorkspacesOut`, which lists every workspace on
|
||||
the instance, so putting it there would hand every user's editor password to any admin loading
|
||||
`/admin/workspaces`. Keep per-workspace secrets on the owner-scoped model only. The column is
|
||||
`instances.editor_password`, ensured in `init_db`. This closes the hole that made a tunnelled editor
|
||||
an unauthenticated public shell: `--auth none` was safe only while the editor was reachable solely
|
||||
through the session-authenticated proxy route, and a user can publish a tunnel to the editor port.
|
||||
|
||||
**code-server lives in the `ppy` image, and nothing else supplies it.** `api.editor_command` makes
|
||||
`code-server` the container's argv, so an image without it fails `docker run` with exit **127**
|
||||
(`executable file not found in $PATH`) and the reconciler records `crashed` / `launch_failed` with an
|
||||
empty `container_id` - the container process never existed. It is installed in `ppy.Dockerfile` in
|
||||
the runtime stage **before `USER pravda`** and before the `chown -R pravda` block (so uid 1000 owns
|
||||
it with no elevation): version pinned in the single `ARG CODE_SERVER_VERSION`, arch resolved from
|
||||
`dpkg --print-architecture`, release tarball unpacked to `/usr/local/lib/code-server` with a symlink
|
||||
at `/usr/local/bin/code-server`. `code-server --version` is in the build smoke-test loop and the
|
||||
symlink is in the executable-check list, so an image that cannot run the editor can never build
|
||||
green. Bumping the version is a one-line `ARG` change plus `make ppy`. **`workspace_editor_version`
|
||||
(a `ConfigField` on `WorkspaceService`) is currently read by nothing** - the version is the image's,
|
||||
not a runtime setting; wire it up or drop it before relying on it.
|
||||
|
||||
**A container stuck in `created` is recreated, never retried forever.** The reconciler's
|
||||
`desired=running` branch reaches `backend.start()` for `ps.state == "created"`. That call is wrapped:
|
||||
on failure it logs, records an instance event `recreated`, `rm -f`s the container and calls
|
||||
`_launch`, so the instance is rebuilt from the CURRENT image. Without this, a container created
|
||||
against an older image that can no longer start is a permanent wedge - `docker start` re-resolves
|
||||
nothing, so the reconciler loops on the same error every tick until someone removes it by hand (the
|
||||
real failure: after code-server was added to `ppy`, the already-created workspace container kept
|
||||
failing `docker start` with the old 127 even though the new image was correct). Recreation is not a
|
||||
new retry class: a launch that fails already leaves `ps is None`, which `_launch`es again next tick.
|
||||
|
||||
**Toolchains in `ppy`.** Rust (rustup), Nim (choosenim), Swift (swiftly, then the toolchain is moved
|
||||
to a fixed `/opt/swift/toolchain` because swiftly's proxy resolves against `$HOME` and breaks for
|
||||
`pravda` at runtime). The choosenim installer **exits 1 even on success**, so its `RUN` ends with
|
||||
`|| true` plus a real version check. The build smoke test must not pipe (`cmd | head -1` returns
|
||||
`head`'s status and masks a broken toolchain - this hid a non-working Swift through a full build).
|
||||
`/etc/profile.d/devplace-toolchains.sh` re-exports the PATH because a login shell resets it, which is
|
||||
what the container terminal uses.
|
||||
|
||||
@ -1,56 +0,0 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from devplacepy.config import WORKSPACE_ACTIVITY_WRITE_SECONDS
|
||||
from devplacepy.services.containers import store
|
||||
|
||||
_last_write: dict[str, float] = {}
|
||||
_pending_egress: dict[str, int] = {}
|
||||
_pending_requests: dict[str, int] = {}
|
||||
|
||||
|
||||
def touch(instance_uid: str, egress_bytes: int = 0) -> None:
|
||||
if not instance_uid:
|
||||
return
|
||||
if egress_bytes > 0:
|
||||
_pending_egress[instance_uid] = _pending_egress.get(instance_uid, 0) + egress_bytes
|
||||
_pending_requests[instance_uid] = _pending_requests.get(instance_uid, 0) + 1
|
||||
now = time.monotonic()
|
||||
if now - _last_write.get(instance_uid, 0.0) < WORKSPACE_ACTIVITY_WRITE_SECONDS:
|
||||
return
|
||||
_last_write[instance_uid] = now
|
||||
flush(instance_uid)
|
||||
|
||||
|
||||
def flush(instance_uid: str) -> None:
|
||||
egress = _pending_egress.pop(instance_uid, 0)
|
||||
requests = _pending_requests.pop(instance_uid, 0)
|
||||
store.record_activity(
|
||||
instance_uid,
|
||||
datetime.now(timezone.utc).isoformat(),
|
||||
egress,
|
||||
requests,
|
||||
)
|
||||
|
||||
|
||||
def flush_all() -> None:
|
||||
for instance_uid in list(_pending_requests) + list(_pending_egress):
|
||||
if instance_uid in _pending_requests or instance_uid in _pending_egress:
|
||||
flush(instance_uid)
|
||||
|
||||
|
||||
def pending(instance_uid: str) -> tuple[int, int]:
|
||||
return (
|
||||
_pending_egress.get(instance_uid, 0),
|
||||
_pending_requests.get(instance_uid, 0),
|
||||
)
|
||||
|
||||
|
||||
def forget(instance_uid: str) -> None:
|
||||
_last_write.pop(instance_uid, None)
|
||||
_pending_egress.pop(instance_uid, None)
|
||||
_pending_requests.pop(instance_uid, None)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user