Add the trust and safety subsystem and the App Store compliance work

Implements the moderation and consent obligations a social platform carries,
so the web version and any client that speaks to it enforce the same rules.

Moderation core (services/moderation/, database/moderation.py): a reportable
target registry, the content filter and its choke points, the report queue with
atomic resolution, enforcement actions, consent tracking, maturity gating, and
account deletion with a grace window.

Surfaces: POST /reports plus the member report list, /admin/moderation and the
per-report admin view, /workspaces, terms acceptance at /auth/terms, consent and
account deletion under /profile, the report button and dialog partials, the
maturity gate, and the moderation stylesheet and ReportDialog client.

Every user-generated surface stays reportable by construction: new content tables
are registered in REPORTABLE_TARGETS or listed in UNREPORTABLE_TABLES with a
reason, and the registry test fails the suite on anything left unclassified.

Docs: community guidelines, content moderation, intellectual property, privacy,
terms, contact, and the admin-only moderation operations page, plus the
moderation API group and the Devii moderation actions.

Compliance record: applecomp.md is the requirement register, applechanges.md the
gap analysis against this codebase, and appleimpl.md the implementation design
they resolve to.

Tests cover the report flow, admin moderation, consent, account deletion, terms
acceptance, workspaces, and the registry invariant across the unit, api, and e2e
tiers.
This commit is contained in:
2026-08-09 00:18:20 +02:00
parent 68c2bbe387
commit 8e9d3fad98
348 changed files with 10633 additions and 238 deletions
+3
View File
@@ -2,6 +2,7 @@
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 (
@@ -49,6 +50,8 @@ __all__ = [
"main",
"build_parser",
"_audit_cli",
"cmd_accounts_pending",
"cmd_accounts_prune",
"cmd_role_get",
"cmd_role_set",
"cmd_apikey_get",
+46
View File
@@ -0,0 +1,46 @@
# 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
View File
@@ -2,6 +2,7 @@
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
@@ -36,6 +37,7 @@ def build_parser():
register_quiz(sub)
register_gateway(sub)
register_messaging(sub)
register_accounts(sub)
return parser
+74
View File
@@ -31,6 +31,11 @@ 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,
)
@@ -51,6 +56,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.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")
@@ -79,6 +89,30 @@ 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
@@ -206,6 +240,8 @@ 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(
@@ -251,6 +287,13 @@ 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)
@@ -367,6 +410,8 @@ 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 = {
@@ -417,6 +462,13 @@ 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}")
@@ -441,10 +493,19 @@ 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']}")
@@ -567,6 +628,7 @@ 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)
@@ -601,11 +663,20 @@ 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)
@@ -747,6 +818,7 @@ 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"],
}
@@ -762,6 +834,7 @@ 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 = {
@@ -769,6 +842,7 @@ 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] = (
+26
View File
@@ -146,6 +146,23 @@ 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:
@@ -192,6 +209,15 @@ 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).
+71
View File
@@ -36,6 +36,43 @@ 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
@@ -217,6 +254,40 @@ __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",
+38
View File
@@ -56,6 +56,44 @@ 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"
+328
View File
@@ -0,0 +1,328 @@
# 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)
+1
View File
@@ -20,6 +20,7 @@ NOTIFICATION_TYPES = [
{"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)"},
]
+113 -1
View File
@@ -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, uncensored environment.",
"site_tagline": "Track industry shifts. Discover bold releases. Share what you are building in an open environment built by developers, for developers.",
}
for key, value in defaults.items():
existing = db["site_settings"].find_one(key=key)
@@ -558,6 +558,9 @@ def init_db():
("slug", ""),
("name", ""),
("status", ""),
("created_at", ""),
("owner_uid", ""),
("created_by", ""),
("desired_state", ""),
("container_id", ""),
("ingress_slug", ""),
@@ -1550,6 +1553,8 @@ def init_db():
["user_uid", "created_at"],
)
_ensure_moderation_tables()
for table in db.tables:
_uid_index(db, table)
@@ -1688,6 +1693,19 @@ 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)
@@ -1768,6 +1786,83 @@ 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(
@@ -1857,6 +1952,22 @@ 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"
@@ -1871,6 +1982,7 @@ 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
+4
View File
@@ -49,6 +49,10 @@ SOFT_DELETE_TABLES = [
"quiz_options",
"quiz_attempts",
"quiz_answers",
"content_reports",
"moderation_actions",
"content_maturity",
"user_consents",
]
+2
View File
@@ -8,6 +8,7 @@ from . import (
content,
profiles,
messaging,
moderation,
notifications,
uploads,
project_files,
@@ -31,6 +32,7 @@ ORDERED_GROUPS = [
content.GROUP,
profiles.GROUP,
messaging.GROUP,
moderation.GROUP,
notifications.GROUP,
uploads.GROUP,
project_files.GROUP,
+7
View File
@@ -50,6 +50,13 @@ 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(
+489
View File
@@ -0,0 +1,489 @@
# 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,
},
),
],
}
+69 -1
View File
@@ -72,6 +72,7 @@ from devplacepy.routers import (
push,
leaderboard,
reactions,
reports,
bookmarks,
polls,
docs,
@@ -86,6 +87,7 @@ from devplacepy.routers import (
dbapi,
pubsub,
game,
workspaces,
)
from devplacepy.services.manager import service_manager
from devplacepy.services.background import background
@@ -115,6 +117,8 @@ 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
@@ -278,6 +282,7 @@ async def lifespan(app: FastAPI):
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())
@@ -371,6 +376,30 @@ 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"),
@@ -441,6 +470,7 @@ 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")
@@ -469,6 +499,7 @@ app.include_router(dbapi.router, prefix="/dbapi")
app.include_router(pubsub.router, prefix="/pubsub")
app.include_router(game.router, prefix="/game")
app.include_router(quizzes.router, prefix="/quizzes")
app.include_router(workspaces.router, prefix="/workspaces")
@app.middleware("http")
@@ -589,6 +620,43 @@ 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 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, redirect="/auth/accept-terms")
return RedirectResponse(url="/auth/accept-terms", status_code=303)
@app.middleware("http")
async def track_presence(request: Request, call_next):
path = request.url.path
@@ -741,7 +809,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, uncensored environment.",
description="Track industry shifts. Discover bold releases. Share what you're building in an open environment built by developers, for developers.",
breadcrumbs=[],
schemas=[website_schema(base)],
)
+155
View File
@@ -48,11 +48,53 @@ 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
@@ -63,6 +105,10 @@ 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")
@@ -587,8 +633,29 @@ 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):
@@ -911,3 +978,91 @@ class WorkspaceFlagForm(BaseModel):
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)
+11 -1
View File
@@ -23,9 +23,11 @@ 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 `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 `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/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 |
@@ -315,6 +317,14 @@ 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.
+2
View File
@@ -14,6 +14,7 @@ from devplacepy.routers.admin import (
gateway_configs,
issues,
media,
moderation,
news,
notifications,
services,
@@ -30,6 +31,7 @@ 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)
+39
View File
@@ -2,6 +2,45 @@
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:
+323
View File
@@ -0,0 +1,323 @@
# 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",
)
+120 -32
View File
@@ -4,7 +4,7 @@ import logging
from typing import Annotated
from fastapi import Depends, APIRouter, Request
from fastapi.responses import HTMLResponse, JSONResponse
from devplacepy.models import AdminRoleForm, AdminPasswordForm
from devplacepy.models import AdminRoleForm, AdminPasswordForm, BanForm, SuspensionForm
from devplacepy.database import (
get_table,
build_pagination,
@@ -21,37 +21,16 @@ 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):
@@ -124,8 +103,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)
@@ -156,8 +135,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(
@@ -194,8 +173,8 @@ 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)
users.update({"uid": uid, "is_active": new_state}, ["uid"])
@@ -216,12 +195,121 @@ 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(
+2
View File
@@ -8,6 +8,7 @@ from devplacepy.routers.auth import (
logout,
resetpassword,
signup,
terms,
token,
)
@@ -18,3 +19,4 @@ 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)
+3 -1
View File
@@ -90,7 +90,9 @@ async def signup(request: Request, data: Annotated[SignupForm, Depends(json_or_f
},
)
uid, role, is_first = await register_account_async(username, email, password)
uid, role, is_first = await register_account_async(
username, email, password, age_band=data.age_band, accepted_terms=True
)
max_age = max(1, get_int_setting("session_max_age_days", 7)) * SECONDS_PER_DAY
token = create_session(uid, max_age)
+82
View File
@@ -0,0 +1,82 @@
# 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()
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})
+1 -1
View File
@@ -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)` 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.
**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.
**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.
+10 -1
View File
@@ -2,10 +2,19 @@
from typing import Optional
from fastapi import HTTPException
from fastapi import HTTPException, Request
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:
+19 -12
View File
@@ -2,7 +2,6 @@
import logging
import re
from datetime import datetime, timezone
from fastapi import APIRouter, Request
from fastapi.responses import Response
@@ -17,7 +16,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, unauthorized
from devplacepy.routers.devrant._shared import dr_ok, dr_error, resolve_actor, unauthorized
logger = logging.getLogger(__name__)
router = APIRouter()
@@ -131,14 +130,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_user(params)
viewer = resolve_actor(request, 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_user(params)
user = resolve_actor(request, params)
if not user:
return unauthorized()
updates = {"uid": user["uid"]}
@@ -191,21 +190,29 @@ async def delete_account(request: Request):
user = resolve_user(params)
if not user:
return unauthorized()
get_table("users").update(
{"uid": user["uid"], "is_active": False}, ["uid"]
)
from devplacepy.services.moderation import deletion
username = user["username"]
revoke_all(user["uid"])
logger.info("devrant account deactivated for %s", user["username"])
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)
audit.record(
request,
"auth.account.disable",
"account.delete.request",
user=user,
target_type="user",
target_uid=user["uid"],
target_label=user["username"],
target_label=username,
origin="devrant",
summary=f"{user['username']} deactivated account via devrant",
links=[audit.target("user", user["uid"], user["username"])],
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)],
)
return dr_ok()
+5 -6
View File
@@ -17,10 +17,9 @@ 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, unauthorized
from devplacepy.routers.devrant._shared import dr_ok, dr_error, resolve_actor, unauthorized
logger = logging.getLogger(__name__)
router = APIRouter()
@@ -50,7 +49,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_user(params)
viewer = resolve_actor(request, params)
comment = comment_by_id(comment_id)
if not comment:
return dr_error("Invalid comment specified in path.")
@@ -60,7 +59,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_user(params)
user = resolve_actor(request, params)
if not user:
return unauthorized()
comment = comment_by_id(comment_id)
@@ -97,7 +96,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_user(params)
user = resolve_actor(request, params)
if not user:
return unauthorized()
comment = comment_by_id(comment_id)
@@ -112,7 +111,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_user(params)
user = resolve_actor(request, params)
if not user:
return unauthorized()
comment = comment_by_id(comment_id)
+3 -4
View File
@@ -5,9 +5,8 @@ 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, unauthorized
from devplacepy.routers.devrant._shared import dr_ok, resolve_actor, unauthorized
from devplacepy.services.audit import record as audit
logger = logging.getLogger(__name__)
@@ -17,7 +16,7 @@ router = APIRouter()
@router.get("/users/me/notif-feed")
async def notif_feed(request: Request):
params = await merge_params(request)
user = resolve_user(params)
user = resolve_actor(request, params)
if not user:
return unauthorized()
return dr_ok(data=build_notif_feed(user))
@@ -26,7 +25,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_user(params)
user = resolve_actor(request, params)
if not user:
return unauthorized()
clear_notifications(user)
+10 -11
View File
@@ -21,11 +21,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, 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, unauthorized
from devplacepy.routers.devrant._shared import dr_ok, dr_error, resolve_actor, unauthorized
logger = logging.getLogger(__name__)
router = APIRouter()
@@ -45,7 +44,7 @@ def _parse_tags(raw: object) -> list:
@router.get("/devrant/rants")
async def rant_feed(request: Request):
params = await merge_params(request)
viewer = resolve_user(params)
viewer = resolve_actor(request, 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))
@@ -70,7 +69,7 @@ async def rant_feed(request: Request):
@router.get("/devrant/search")
async def search(request: Request):
params = await merge_params(request)
viewer = resolve_user(params)
viewer = resolve_actor(request, params)
term = (params.get("term") or "").strip()
return dr_ok(results=search_rants(term, viewer) if term else [])
@@ -78,7 +77,7 @@ async def search(request: Request):
@router.post("/devrant/rants")
async def create_rant(request: Request):
params = await merge_params(request)
user = resolve_user(params)
user = resolve_actor(request, params)
if not user:
return unauthorized()
text = (params.get("rant") or "").strip()
@@ -114,7 +113,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_user(params)
viewer = resolve_actor(request, params)
post = post_by_id(rant_id)
if not post:
return dr_error("This rant does not exist.")
@@ -125,7 +124,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_user(params)
user = resolve_actor(request, params)
if not user:
return unauthorized()
post = post_by_id(rant_id)
@@ -164,7 +163,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_user(params)
user = resolve_actor(request, params)
if not user:
return unauthorized()
post = post_by_id(rant_id)
@@ -179,7 +178,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_user(params)
user = resolve_actor(request, params)
if not user:
return unauthorized()
post = post_by_id(rant_id)
@@ -206,7 +205,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_user(params)
user = resolve_actor(request, params)
if not user:
return unauthorized()
post = post_by_id(rant_id)
@@ -219,7 +218,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_user(params)
user = resolve_actor(request, params)
if not user:
return unauthorized()
post = post_by_id(rant_id)
+2
View File
@@ -10,6 +10,8 @@ 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.
+46 -1
View File
@@ -4,6 +4,7 @@ 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"
@@ -23,7 +24,7 @@ AUDIENCE_CONTRIBUTE = "Contribute and internals"
AUDIENCE_OPERATE = "Operate"
AUDIENCES = [
(AUDIENCE_START, [SECTION_GENERAL, SECTION_TOOLS]),
(AUDIENCE_START, [SECTION_GENERAL, SECTION_LEGAL, SECTION_TOOLS]),
(
AUDIENCE_BUILD,
[SECTION_API, SECTION_DEVRANT, SECTION_COMPONENTS, SECTION_STYLES],
@@ -147,6 +148,50 @@ 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",
+1
View File
@@ -185,6 +185,7 @@ 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"},
+10
View File
@@ -317,6 +317,13 @@ 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()
@@ -324,6 +331,9 @@ 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)
+2
View File
@@ -5,6 +5,7 @@ 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,
@@ -156,6 +157,7 @@ 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,
)
+4
View File
@@ -5,7 +5,9 @@ from devplacepy.routers.profile import (
ai_modifier,
avatar,
award,
consent,
customization,
delete,
interactions,
notifications,
telegram,
@@ -21,5 +23,7 @@ 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"]
+127
View File
@@ -0,0 +1,127 @@
# 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})
+150
View File
@@ -0,0 +1,150 @@
# 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
+47
View File
@@ -6,6 +6,7 @@ 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,
@@ -34,6 +35,13 @@ 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,
@@ -363,6 +371,30 @@ 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"
@@ -434,6 +466,10 @@ 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,
@@ -461,6 +497,10 @@ 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"],
@@ -472,6 +512,13 @@ 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)
+143
View File
@@ -0,0 +1,143 @@
# 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,
},
)
+2
View File
@@ -22,6 +22,8 @@ Disallow: /avatar/
Disallow: /follow/
Disallow: /admin/
Disallow: /uploads/
Disallow: /reports/mine
Disallow: /profile/*/delete
Disallow: /*?tab=
Disallow: /*?sort=
Allow: /static/
+116
View File
@@ -0,0 +1,116 @@
# 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,
)
+17
View File
@@ -183,3 +183,20 @@ 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,
)
+6
View File
@@ -23,6 +23,7 @@ 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
@@ -37,6 +38,7 @@ 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
@@ -120,6 +122,7 @@ class FeedOut(_Out):
class PostDetailOut(_Out):
maturity: Optional[str] = None
post: PostOut
author: Optional[UserOut] = None
is_owner: bool = False
@@ -149,6 +152,7 @@ class ProjectsOut(_Out):
class ProjectDetailOut(_Out):
maturity: Optional[str] = None
project: ProjectOut
author: Optional[UserOut] = None
is_owner: bool = False
@@ -183,6 +187,7 @@ class GistsOut(_Out):
class GistDetailOut(_Out):
maturity: Optional[str] = None
gist: GistOut
author: Optional[UserOut] = None
is_owner: bool = False
@@ -201,6 +206,7 @@ class NewsListOut(_Out):
class NewsDetailOut(_Out):
maturity: Optional[str] = None
article: NewsOut
canonical_slug: Optional[str] = None
image_url: Optional[str] = None
+136
View File
@@ -0,0 +1,136 @@
# 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
+9
View File
@@ -82,6 +82,15 @@ 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):
+1
View File
@@ -82,6 +82,7 @@ class QuizOut(_Out):
class QuizDetailOut(_Out):
maturity: Optional[str] = None
quiz: QuizOut = QuizOut()
questions: list[QuizQuestionOut] = []
comments: list[CommentItemOut] = []
+25
View File
@@ -15,6 +15,15 @@ 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 = {}
@@ -424,6 +433,18 @@ 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(
@@ -533,9 +554,13 @@ 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",
+6
View File
@@ -114,6 +114,10 @@ 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:
@@ -177,6 +181,8 @@ 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.
+6
View File
@@ -3,6 +3,12 @@
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",
+6
View File
@@ -10,6 +10,8 @@ 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:
@@ -70,6 +72,10 @@ 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)
+2
View File
@@ -172,6 +172,8 @@ 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.
+14 -3
View File
@@ -49,7 +49,11 @@ def _validate_limits(cpu_limit: str, mem_limit: str) -> None:
raise ContainerError("memory limit must look like 512m, 1g, or a byte count")
def validate_run_as(run_as_uid) -> str:
def _actor_uid(actor) -> str:
return actor[1] if actor and actor[0] == "user" else ""
def validate_run_as(run_as_uid, actor_uid: str = "") -> str:
uid = str(run_as_uid or "").strip()
if not uid:
return ""
@@ -58,6 +62,13 @@ def validate_run_as(run_as_uid) -> str:
user = database.get_users_by_uids([uid]).get(uid)
if not user:
raise ContainerError(f"run-as user not found: {uid}")
if actor_uid and actor_uid != uid:
if not database.consent_granted("user", uid, "container_credentials"):
raise ContainerError(
f"{user['username']} has not consented to their DevPlace credentials "
f"being shared with software run by someone else; they grant it under "
f"Privacy on their profile"
)
return uid
@@ -224,7 +235,7 @@ async def create_instance(
f"restart policy must be one of {', '.join(store.RESTART_POLICIES)}"
)
_validate_limits(cpu_limit, mem_limit)
run_as_uid = validate_run_as(run_as_uid)
run_as_uid = validate_run_as(run_as_uid, _actor_uid(actor))
boot_language, boot_script = validate_boot(boot_language, boot_script)
port_list = assign_host_ports(parse_ports(ports))
env_map = parse_env(env)
@@ -324,7 +335,7 @@ def update_instance_config(
) -> dict:
changes: dict = {}
if run_as_uid is not None:
changes["run_as_uid"] = validate_run_as(run_as_uid)
changes["run_as_uid"] = validate_run_as(run_as_uid, _actor_uid(actor))
if boot_language is not None or boot_script is not None:
language = (
boot_language
@@ -14,6 +14,7 @@ from .gists import GIST_ACTIONS
from .issues import ISSUE_ACTIONS
from .jobs import JOB_ACTIONS
from .messages import MESSAGE_ACTIONS
from .moderation import MODERATION_ACTIONS
from .news import NEWS_ACTIONS
from .notifications import NOTIFICATION_ACTIONS
from .posts import POSTS_ACTIONS
@@ -47,6 +48,7 @@ ACTIONS: tuple[Action, ...] = (
+ GATEWAY_ACTIONS
+ GAME_ACTIONS
+ QUIZ_ACTIONS
+ MODERATION_ACTIONS
)
PLATFORM_CATALOG = Catalog(actions=ACTIONS)
@@ -15,8 +15,16 @@ def query(name: str, description: str, required: bool = False) -> Param:
)
def body(name: str, description: str, required: bool = False) -> Param:
return Param(name=name, location="body", description=description, required=required)
def body(
name: str, description: str, required: bool = False, type: str = "string"
) -> Param:
return Param(
name=name,
location="body",
description=description,
required=required,
type=type,
)
def upload(name: str, description: str) -> Param:
@@ -0,0 +1,242 @@
# retoor <retoor@molodetz.nl>
from __future__ import annotations
from devplacepy.database.moderation import (
CONSENT_KINDS,
MODERATION_ACTIONS as DECISION_KINDS,
REPORT_REASONS,
REPORT_STATUSES,
REPORTABLE_TARGETS,
)
from ..spec import Action
from ._shared import body, confirm, path, query
TARGET_KEYS = ", ".join(REPORTABLE_TARGETS)
REASON_KEYS = ", ".join(REPORT_REASONS)
STATUS_KEYS = ", ".join(REPORT_STATUSES)
DECISION_KEYS = ", ".join(DECISION_KINDS)
CONSENT_KEYS = ", ".join(CONSENT_KINDS)
CONSENT_CHOICES = "; ".join(
f"{kind} ({label})" for kind, label in CONSENT_KINDS.items()
)
MODERATION_ACTIONS: tuple[Action, ...] = (
Action(
name="report_reasons",
method="GET",
path="/reports/reasons",
summary="List the reasons a piece of content can be reported under",
requires_auth=False,
),
Action(
name="report_content",
method="POST",
path="/reports/{target_type}/{target_uid}",
summary="Report content that breaks the community guidelines",
description=(
"Files a report a moderator reviews within the published response window. "
"Use report_reasons to pick a valid reason key first."
),
params=(
path("target_type", f"Type of content being reported: {TARGET_KEYS}."),
path("target_uid", "Uid of the reported item."),
body("reason", f"Reason key: {REASON_KEYS}.", required=True),
body("detail", "What the moderator should know, up to 2000 characters."),
),
),
Action(
name="list_my_reports",
method="GET",
path="/reports/mine",
summary="List the reports you filed and their outcome",
params=(
query("status", f"Filter by status: {STATUS_KEYS}."),
query("page", "Page number, 25 per page."),
),
),
Action(
name="list_reports",
method="GET",
path="/admin/moderation",
summary="List the moderation queue, oldest open report first",
requires_admin=True,
params=(
query("status", f"Filter by status: {STATUS_KEYS}."),
query("page", "Page number, 25 per page."),
),
),
Action(
name="get_report",
method="GET",
path="/admin/moderation/{uid}",
summary="Read one report, its decisions and the author's moderation history",
requires_admin=True,
params=(path("uid", "Report uid."),),
),
Action(
name="set_report_status",
method="POST",
path="/admin/moderation/{uid}/status",
summary="Move a report through the triage state machine",
requires_admin=True,
params=(
path("uid", "Report uid."),
body("status", f"New status: {STATUS_KEYS}.", required=True),
),
),
Action(
name="decide_report",
method="POST",
path="/admin/moderation/{uid}/decide",
summary="Apply a moderation decision to a report",
description=(
"Removes or restores content, warns, suspends, bans, lifts, dismisses or "
"escalates. The decision is recorded and the affected user is told why. "
"Show the moderator the exact report and decision, get explicit "
"confirmation, then call again with confirm=true."
),
requires_admin=True,
params=(
path("uid", "Report uid."),
body("action", f"Decision to apply: {DECISION_KEYS}.", required=True),
body("reason", "Reason shown to the affected user."),
body("notes", "Internal notes, never shown to the user."),
body("duration_hours", "Suspension length in hours.", type="integer"),
confirm(),
),
),
Action(
name="suspend_user",
method="POST",
path="/admin/users/{uid}/suspend",
summary="Suspend an account for a fixed period with a stated reason",
description=(
"A suspended account can still read, see why, and delete itself, but "
"cannot post. Show the moderator the exact account, get explicit "
"confirmation, then call again with confirm=true."
),
requires_admin=True,
params=(
path("uid", "User uid to suspend."),
body("reason", "Reason shown to the user."),
body("duration_hours", "Suspension length in hours.", type="integer"),
confirm(),
),
),
Action(
name="lift_suspension",
method="POST",
path="/admin/users/{uid}/lift",
summary="Lift a suspension or ban and restore the account",
requires_admin=True,
params=(path("uid", "User uid to restore."),),
),
Action(
name="accept_terms",
method="POST",
path="/auth/accept-terms",
summary="Accept the current version of the Terms of Service",
description=(
"Records acceptance of the version in force and of the privacy policy. "
"Show the user the terms first."
),
),
Action(
name="list_consents",
method="GET",
path="/profile/{username}",
summary="List the consents on an account and whether each is granted",
description=(
"Only the account holder and an administrator see the consents; for anyone "
"else the list comes back empty."
),
params=(
path("username", "Your own username, or an account you administer."),
query("tab", "Profile tab (use privacy for this action)."),
),
),
Action(
name="set_consent",
method="POST",
path="/profile/{username}/consent",
summary="Grant or withdraw one consent on your own account",
description=(
f"Kinds: {CONSENT_CHOICES}. Withdrawal takes effect immediately. Only the "
"account holder can change a consent, administrators included."
),
params=(
path("username", "Your own username."),
body("kind", f"Consent kind to change: {CONSENT_KEYS}.", required=True),
body("granted", "1 to grant, 0 to withdraw.", required=True),
),
),
Action(
name="set_mature_content",
method="POST",
path="/profile/{username}/mature-content",
summary="Turn the mature-content reveal on or off for your own account",
params=(
path("username", "Your own username."),
body("mature_opt_in", "1 to show mature content, 0 to hide it.", required=True),
),
),
Action(
name="view_account_deletion",
method="GET",
path="/profile/{username}/delete",
summary="Read what account deletion removes, what it retains and the grace window",
description=(
"Returns the removed list, the retained list and the grace window in hours. "
"Read it and show it to the user before calling delete_my_account. Only the "
"account holder may read it."
),
params=(path("username", "Your own username."),),
),
Action(
name="delete_my_account",
method="POST",
path="/profile/{username}/delete",
summary="Permanently delete your own account and personal data",
description=(
"Only the account holder can do this, and only with their password. "
"Sessions are revoked, the profile is anonymised at once, and the content "
"is purged after the published grace window. Show the user exactly what "
"will be removed, get explicit confirmation, then call again with "
"confirm=true."
),
params=(
path("username", "Your own username."),
body("password", "Your account password.", required=True),
confirm(),
),
),
Action(
name="list_published_workspaces",
method="GET",
path="/workspaces/index",
summary="List every workspace published to the public ingress, with its link",
requires_auth=False,
params=(query("page", "Page number, 50 per page."),),
),
Action(
name="ban_user",
method="POST",
path="/admin/users/{uid}/ban",
summary="Permanently close an account with a stated reason",
description=(
"Disables the account, revokes every session and token, and tells the "
"user why. Show the moderator the exact account, get explicit "
"confirmation, then call again with confirm=true."
),
requires_admin=True,
params=(
path("uid", "User uid to ban."),
body("reason", "Reason shown to the user."),
confirm(),
),
),
)
@@ -32,6 +32,10 @@ from .spec import Action, Catalog
MUTATING_METHODS = ("POST", "DELETE", "PUT", "PATCH")
CONFIRM_REQUIRED = {
"delete_my_account",
"decide_report",
"suspend_user",
"ban_user",
"workspace_stop",
"workspace_delete",
"tunnel_delete",
@@ -247,6 +251,32 @@ def confirmation_error(name: str, arguments: dict[str, Any]) -> ToolInputError |
f"such as rm, dd, truncate, or drop): {command!r}. Show the user the exact command, get "
"explicit confirmation, then call again with confirm=true."
)
if name == "delete_my_account":
return ToolInputError(
"Deleting the account revokes every session, anonymises the profile at once and "
"purges the content once the grace window closes. Call view_account_deletion, show "
"the user exactly what goes and what is kept, get explicit confirmation, then call "
"again with confirm=true."
)
if name == "suspend_user":
return ToolInputError(
"Suspending an account blocks the member from posting for the whole period and "
"notifies them. Show the moderator the exact account and reason, get explicit "
"confirmation, then call again with confirm=true."
)
if name == "ban_user":
return ToolInputError(
"Banning closes the account, revokes every session and token, and notifies the user. "
"Show the moderator the exact account and reason, get explicit confirmation, then "
"call again with confirm=true."
)
if name == "decide_report":
decision = str(arguments.get("action", "")).strip() or "(unspecified)"
return ToolInputError(
f"Applying '{decision}' resolves the report, records the decision and tells the "
"affected user why. Show the moderator the exact report and decision, get explicit "
"confirmation, then call again with confirm=true."
)
if name == "email_account_delete":
label = str(arguments.get("account", "")).strip() or "(unspecified)"
return ToolInputError(
+15
View File
@@ -17,6 +17,11 @@ from devplacepy.utils import (
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.moderation.screening import (
record as record_screening,
refuse_if_blocked,
screen_fields,
)
logger = logging.getLogger("messaging.persist")
@@ -79,6 +84,9 @@ def persist_message(
if sender["uid"] in get_blocked_uids(receiver_uid):
return None
screening = screen_fields("messages", {"content": content})
refuse_if_blocked(screening)
sender_uid = sender["uid"]
sender_username = sender.get("username", "")
messages_table = get_table("messages")
@@ -96,6 +104,13 @@ def persist_message(
)
link_attachments(attachment_uids, "message", msg_uid)
record_screening(
screening,
target_type="message",
target_uid=msg_uid,
actor_uid=sender_uid,
request=request,
)
schedule_correction(sender, "messages", msg_uid, request)
schedule_modification(sender, "messages", msg_uid, request)
+126
View File
@@ -0,0 +1,126 @@
This file documents the moderation subsystem (`devplacepy/services/moderation/`, the `/reports` and `/admin/moderation` routers, the report/maturity/consent data layer in `database/moderation.py`, and account deletion). Claude Code auto-loads it whenever a file under this directory is read or edited.
## Why this subsystem exists
It is the safety layer an app store requires of a social platform: a filter at post time, a report control on every surface, a queue with a published response window, real enforcement, statements of reasons, consent, age gating, and self-service account deletion. The design and the requirement-to-artifact map live in `appleimpl.md` at the repository root.
## The two load-bearing ideas
**One registry, one queue.** `database/moderation.py` `REPORTABLE_TARGETS` maps every externally visible surface to its table; `content_reports` is the single queue with two producers (members and the filter) and one state machine. Every consumer - the report route, the report partial, the Devii action, the API docs enum, the admin filter - derives from the registry, so adding a surface is one line rather than twenty.
**Coverage is enforced, not remembered.** `tests/unit/database/moderation.py` asserts that every table in `SOFT_DELETE_TABLES` is either in `REPORTABLE_TARGETS` or in the explicit, reviewed `UNREPORTABLE_TABLES` exclusion list (with its reason). Adding a user-generated table without classifying it **fails the suite**. Never widen the exclusion list to silence that test without a real reason for the entry.
## Module map
| File | Owns |
|---|---|
| `rules.py` | The category rule set (`RULES`), `FILTER_MODES`, `ALWAYS_BLOCK_CATEGORIES`, the technical-context discount |
| `filter.py` | `classify(text, mode) -> Classification`, `screen(values) -> Screening`, `resolve_verdict` |
| `screening.py` | The choke-point API: `screen_fields`, `refuse_if_blocked`, `record`, and the `ContentRefused` exception |
| `queue.py` | `raise_report`, `claim_open`, `set_status`, `escalate`, `record_action`, `list_reports`, `status_counts` |
| `enforcement.py` | `remove_content`, `restore_content`, `suspend_user`, `lift_suspension`, `ban_user`, `revoke_sessions`, `notify_subject` (the one statement-of-reasons delivery: type `moderation`, target `NOTICE_URL`) |
| `deletion.py` | `claim_deletion`, `delete_account`, `cascade`, `anonymise`, `due_purges`, `purge_due` |
| `sla.py` | `sla_hours`, `oldest_open`, `breach_count`, `snapshot` |
| `service.py` | `ModerationService` - lock-owner housekeeping: purges due deletions, reports the SLA, exposes queue metrics |
## The filter: five choke points, and why it defaults to review
`classify` is never called from a router. It runs through `screening.screen_fields` at exactly five places, because content creation on DevPlace already funnels through them:
1. `content.create_content_item` - posts, projects, gists, news, quizzes
2. `content.create_comment_record`
3. `content.edit_content_item` and `content.edit_comment_record`
4. `services/messaging/persist.persist_message` - both the HTTP and WebSocket DM paths
5. `routers/profile/index.update_profile` and `models.SignupForm` (username)
The pattern at each is the same and the order is load-bearing: **screen and refuse before the write, record after it** (the report needs the target uid).
```python
screening = screen_fields(table_name, fields)
refuse_if_blocked(screening)
...the insert...
record(screening, target_type=..., target_uid=uid, actor_uid=user["uid"], request=request)
```
`refuse_if_blocked` raises `ContentRefused`, handled once by the `@app.exception_handler(ContentRefused)` in `main.py` (400 + the category list for JSON, the error page for a browser). **No caller catches it** - that is what keeps the five choke points free of per-route error handling.
Three properties must not regress:
- **The default mode is `review`, not `block`.** A match publishes and raises a system report. This is not timidity: this is a developer platform whose members discuss exploits, malware analysis and violent subject matter as their work, and a machine that suppressed them would destroy the product. Only `ALWAYS_BLOCK_CATEGORIES` (sexual, exploitative) refuse outright.
- **The technical-context discount only ever lowers a score.** `rules.TECHNICAL_CONTEXT` matches security-research vocabulary and subtracts from `weapons`/`violence`/`illegal` weights. Never make it raise a score; a property test asserts the direction.
- **The filter fails to `review`, never to `allow`.** `classify` wraps `_classify` in a try/except that returns `verdict="review", failed=True` with the error in `detail`, and `screening.record` escalates a failed classification to a `critical` report. A safety control that fails silently is worse than none.
`moderation_filter_mode` (`off`/`label`/`review`/`block`) and `moderation_filter_review_score` are live `site_settings`. `resolve_verdict` is monotone in the mode: strengthening the mode can never weaken a verdict, and a property test iterates the whole mode x verdict matrix.
## The queue: one atomic resolution, never a check-then-act
`queue.claim_open(uid, status, actor)` is the only way a report becomes `actioned`/`dismissed`. It is a single conditional `UPDATE ... WHERE status IN (open, acknowledged)` through `database.conditional_update_row`, decided on the driver's real `rowcount`. Two administrators deciding the same report simultaneously produce **exactly one** `moderation_actions` row; the loser gets a 409. This is proven with 16 real OS processes, not threads. Never replace it with a read-then-write.
`escalate` is deliberately not a resolution: it raises severity to `critical` and returns the report to `acknowledged` for a second opinion.
Duplicate handling mirrors `workspace_flags.raise_flag`: an open report by the same reporter on the same target is **updated**, never duplicated; a different reporter creates a second row and the queue shows the count.
## Enforcement, and what removal cannot cover
`enforcement.can_remove(target_type)` is the honest boundary. Content removal exists for posts, gists, projects, quizzes, news, comments, attachments and project files. It does **not** exist for direct messages, accounts, workspaces, polls or assistant output - those have no removal path in the data model, so the remedy is the account-level action (warn/suspend/ban). The admin UI filters the action list on this predicate and says so; do not paper over it with a silently-failing "remove".
`remove_content` reuses `content.delete_content_item` / `delete_comment_record` rather than re-implementing the cascade, so a moderator removal is byte-identical to an owner removal (same soft delete, same stamp, same audit).
Suspension is enforced by ONE predicate, `utils.guards.refuse_suspended`. It gates **mutating methods only** and exempts `/auth`, `/reports`, `/block`, `/mute`, account deletion and consent changes, so a suspended user can always read, always see why, always report, always withdraw a consent, and always delete their account. A user is never trapped.
**Every auth resolver must reach that predicate.** `require_user` / `require_user_api` cover the whole HTTP surface, but two paths authenticate on their own and would otherwise be silent bypasses - both now call the same predicate rather than re-implementing it:
- **devRant** (`/api`) resolves in-band `token_id`/`token_key`, never `require_user`. `routers/devrant/_shared.resolve_actor(request, params)` wraps `tokens.resolve_user` with `refuse_suspended` and is what every devRant handler calls. The one deliberate exception is `DELETE /api/users/me`, which calls `resolve_user` directly because it is the account-deletion path and must stay reachable. `is_account_active` (which the token resolver already checks) covers a **ban** but not a time-boxed suspension, which is why the extra call is needed.
- **The messages WebSocket.** `@app.middleware("http")` never runs for a WebSocket scope, so neither the terms gate nor the suspension gate applied to `WS /messages/ws`. `_ws_may_write(user)` checks `suspension_active` and `needs_acceptance` at connect and closes `1008`, mirroring the guest branch. `refuse_suspended` itself cannot be reused there - it reads `request.method`, which a WebSocket has no equivalent of.
Adding a new auth resolver means adding it to this list, not adding a second suspension rule.
Every per-user enforcement passes `routers/admin/_shared.is_senior_admin` / `deny_senior` (moved there from `admin/users.py` so the moderation router shares it), so a junior administrator can never action a senior one - server-side, therefore also binding on Devii.
## Account deletion: claim, cascade under one stamp, anonymise, purge
`deletion.delete_account(user)` returns `None` when it loses the race and a result dict when it wins:
1. `claim_deletion` - one atomic `UPDATE users SET deletion_requested_at = :stamp WHERE uid = :uid AND COALESCE(deletion_requested_at, '') = ''`, decided on `rowcount`. **The `COALESCE` is load-bearing**: the column is NULL on rows that predate it, and `NULL = ''` is NULL, not true. Sixteen concurrent processes produce exactly one cascade.
2. `revoke_sessions` - sessions, access tokens and devRant tokens.
3. `cascade` - `OWNED_TABLES` (an explicit, reviewed registry of table+column pairs) plus `CHILD_TABLES` (rows owned through a parent), all under the **one shared stamp**, so `/admin/trash` restores or purges the whole event atomically.
4. `anonymise` - tombstones the username and clears every field in `ANONYMISED_FIELDS`. From the user's and everyone else's point of view the account is gone the moment they confirm.
5. `purge_due` - after `account_deletion_grace_hours`, `purge_event(stamp)` plus a hard delete of the tombstone row. Run by `ModerationService` and by `devplace accounts prune`.
`user_consents`, `content_reports` and `moderation_actions` are deliberately **not** in the cascade: they key on `owner_id`/`reporter_uid`, not `user_uid`, and the privacy policy states that the moderation and consent record is retained. Adding them to `OWNED_TABLES` would destroy the proof that the platform enforced its own rules.
Deletion is **owner-only** (`_owner_only` in `routers/profile/delete.py`) and needs the account password. An administrator removing someone uses a ban, not a deletion - they cannot know the password, and a ban is the auditable act. The devRant `DELETE /api/users/me` routes into this same cascade; it is no longer a deactivation.
## Consent, and the one AI gate
Five consents live in `user_consents` (`CONSENT_KINDS`), append-only in effect: a withdrawal stamps the current row and writes a new one, so the history is provable. Signup grants `terms`, `privacy` and `activity_recording`; **`ai_third_party` and `container_credentials` are never granted by default.**
**Changing a consent is owner-only, exactly like account deletion** (`_owner_only` in `routers/profile/delete.py`, reused by `routers/profile/consent.py` for both `POST /profile/{username}/consent` and `POST /profile/{username}/mature-content`). An administrator reads the privacy tab of an account they moderate, but may never grant or withdraw on someone else's behalf - a consent an administrator could grant would not be a consent, and it would let an admin unlock third-party AI processing of a member's content or hand a member the mature-content reveal. The privacy tab renders the toggles only for the owner and the profile route withholds `age_band`/`terms_*`/`suspended_until`/`suspension_reason`/`mature_opt_in` from any other viewer in BOTH the HTML and the `ProfileOut` JSON (the same rule as the `_ai_quota` dollar fields).
The gate is at exactly one place: `GatewayService.consent_denied` in `services/openai_gateway/service.py`, checked in `handle()` right after `resolve_owner`. The split is the whole point:
- owner kind `user`/`admin` = the call carries **that user's own content** -> requires `ai_third_party` consent, 403 otherwise;
- owner kind `internal`/`key`/`anonymous` = **platform processing** (news import, bots, SEO metadata) -> ungated.
`ai_correction_enabled` / `ai_modifier_enabled` survive unchanged as *preferences* subordinate to consent. No consumer changed and no existing preference was flipped: withdrawing consent simply makes the gateway refuse.
`container_credentials` gates `containers/api.validate_run_as`: a container configured to run as **someone else** would inject that person's real `DEVPLACE_API_KEY` into software they do not operate, so it is refused unless they granted the consent. Running a container as yourself never asks - you are the one handing over your own credential.
`activity_recording` gates `presence.touch`, checked **after** the per-worker throttle so the consent read costs at most one query per half-window per user. Withdraw it and you simply appear offline. The indicator is the `.recording-indicator` in `base.html`, rendered from the `activity_recording_on(user)` Jinja global.
## Terms re-acceptance
`terms_acceptance_gate` in `main.py` sits beside the maintenance gate. It gates **mutating methods only** and exempts `/static`, `/avatar`, `/auth`, `/docs`, `/reports`, `/block`, `/mute`, `/openai` and account deletion. Reading, accepting and leaving are never blocked.
Every reader of a policy version uses `get_setting(key, "1") or "1"`. **This is not cosmetic**: on a fresh database `init_db` skips the settings seed (its `tables` snapshot predates `site_settings`), so an admin settings save can insert `terms_version = ""`, and a bare `get_setting` would then compare every user's `"1"` against `""` and 403 every write on the platform. That was a real failure; keep the `or "1"`.
## Maturity
`content_maturity` is polymorphic (`target_type`, `target_uid`), read through the batch helper `get_maturity_by_targets` - never per row. Absence of a row means `general`, so nothing needed backfilling. `content.maturity_hidden(level, user)` is the single predicate (also the `maturity_hidden` Jinja global) and `_maturity_gate.html` is the single interstitial; `enrich_items` and `load_detail` attach `maturity` so listings and detail pages both have it with one query.
## Rules for extending this
- A new user-generated surface: add it to `REPORTABLE_TARGETS`, make it resolve in `resolve_object_url`, and include `_report_button.html` in its action bar. The registry test enforces the first two, the e2e coverage test the third.
- A new filter category: add rules to `rules.py` and the reason key to `REPORT_REASONS`; a unit test asserts every rule's category is a real reason.
- A new consent: add it to `CONSENT_KINDS` and enforce it at one choke point, never at N call sites.
- Never add a second removal path, a second suspension check, or a second consent gate.
@@ -0,0 +1,21 @@
# retoor <retoor@molodetz.nl>
from devplacepy.services.moderation import (
deletion,
enforcement,
filter,
queue,
rules,
sla,
)
from devplacepy.services.moderation.service import ModerationService
__all__ = [
"ModerationService",
"deletion",
"enforcement",
"filter",
"queue",
"rules",
"sla",
]
+211
View File
@@ -0,0 +1,211 @@
# retoor <retoor@molodetz.nl>
from __future__ import annotations
import logging
from datetime import datetime, timedelta, timezone
from sqlalchemy import text
from devplacepy.database import (
_now_iso,
db,
get_int_setting,
get_table,
purge_event,
soft_delete,
soft_delete_in,
)
from devplacepy.utils import clear_user_cache
logger = logging.getLogger(__name__)
DEFAULT_GRACE_HOURS = 24
TOMBSTONE_UID_CHARS = 12
OWNED_TABLES: tuple[tuple[str, str], ...] = (
("posts", "user_uid"),
("comments", "user_uid"),
("gists", "user_uid"),
("projects", "user_uid"),
("quizzes", "user_uid"),
("quiz_attempts", "user_uid"),
("quiz_answers", "user_uid"),
("attachments", "user_uid"),
("votes", "user_uid"),
("reactions", "user_uid"),
("bookmarks", "user_uid"),
("poll_votes", "user_uid"),
("follows", "follower_uid"),
("follows", "following_uid"),
("awards", "receiver_uid"),
("awards", "giver_uid"),
("user_relations", "user_uid"),
("user_relations", "target_uid"),
("notification_preferences", "user_uid"),
("issue_tickets", "user_uid"),
("issue_comment_authors", "user_uid"),
("sessions", "user_uid"),
("access_tokens", "user_uid"),
("devrant_tokens", "user_uid"),
("email_accounts", "user_uid"),
("devii_conversations", "owner_id"),
("devii_tasks", "owner_id"),
("devii_lessons", "owner_id"),
("devii_virtual_tools", "owner_id"),
("user_customizations", "owner_id"),
("deepsearch_sessions", "owner_id"),
("isslop_analyses", "owner_id"),
)
CHILD_TABLES: tuple[tuple[str, str, str, str], ...] = (
("project_files", "project_uid", "projects", "user_uid"),
("project_forks", "project_uid", "projects", "user_uid"),
("polls", "post_uid", "posts", "user_uid"),
("quiz_questions", "quiz_uid", "quizzes", "user_uid"),
("deepsearch_messages", "session_uid", "deepsearch_sessions", "owner_id"),
)
ANONYMISED_FIELDS: tuple[str, ...] = (
"email",
"bio",
"location",
"git_link",
"website",
"avatar_seed",
"api_key",
"password_hash",
"timezone",
"last_seen",
"suspension_reason",
"suspended_until",
)
def grace_hours() -> int:
return max(0, get_int_setting("account_deletion_grace_hours", DEFAULT_GRACE_HOURS))
def tombstone_username(uid: str) -> str:
return f"deleted_{uid.replace('-', '')[:TOMBSTONE_UID_CHARS]}"
def _child_uids(parent_table: str, owner_column: str, user_uid: str) -> list[str]:
if parent_table not in db.tables:
return []
table = get_table(parent_table)
if not table.has_column(owner_column) or not table.has_column("uid"):
return []
return [row["uid"] for row in table.find(**{owner_column: user_uid}) if row.get("uid")]
def cascade(user_uid: str, stamp: str) -> int:
removed = 0
for table_name, child_column, parent_table, owner_column in CHILD_TABLES:
if table_name not in db.tables:
continue
if not get_table(table_name).has_column(child_column):
continue
parents = _child_uids(parent_table, owner_column, user_uid)
if parents:
removed += soft_delete_in(
table_name, child_column, parents, user_uid, stamp=stamp
)
for table_name, column in OWNED_TABLES:
if table_name not in db.tables:
continue
if not get_table(table_name).has_column(column):
continue
removed += soft_delete(table_name, user_uid, stamp=stamp, **{column: user_uid})
return removed
def anonymise(user: dict, stamp: str) -> None:
changes = {
"uid": user["uid"],
"username": tombstone_username(user["uid"]),
"is_active": False,
"deletion_requested_at": stamp,
"role": "Member",
}
for column in ANONYMISED_FIELDS:
changes[column] = ""
get_table("users").update(changes, ["uid"])
clear_user_cache(user["uid"])
def claim_deletion(user_uid: str, stamp: str) -> bool:
with db:
result = db.executable.execute(
text(
"UPDATE users SET deletion_requested_at = :stamp "
"WHERE uid = :uid AND COALESCE(deletion_requested_at, '') = ''"
),
{"stamp": stamp, "uid": user_uid},
)
return result.rowcount == 1
def delete_account(user: dict) -> dict | None:
from devplacepy.database import invalidate_admins_cache, invalidate_user_relations
from devplacepy.services.moderation.enforcement import revoke_sessions
stamp = _now_iso()
if not claim_deletion(user["uid"], stamp):
logger.info(f"Account {user['uid']} was already being deleted")
return None
revoke_sessions(user["uid"])
removed = cascade(user["uid"], stamp)
anonymise(user, stamp)
invalidate_admins_cache()
invalidate_user_relations(user["uid"])
logger.info(f"Account {user['uid']} deleted, {removed} rows removed under {stamp}")
return {"stamp": stamp, "rows": removed, "grace_hours": grace_hours()}
def due_purges(now: datetime | None = None) -> list[dict]:
if "users" not in db.tables:
return []
table = get_table("users")
if not table.has_column("deletion_requested_at"):
return []
moment = now or datetime.now(timezone.utc)
cutoff = (moment - timedelta(hours=grace_hours())).isoformat()
rows = db.query(
"SELECT uid, deletion_requested_at FROM users "
"WHERE deletion_requested_at IS NOT NULL AND deletion_requested_at != '' "
"AND deletion_requested_at <= :cutoff",
cutoff=cutoff,
)
return [dict(row) for row in rows]
def purge_due(now: datetime | None = None) -> int:
from devplacepy.services.audit import record as audit
purged = 0
for row in due_purges(now):
tables = purge_event(row["deletion_requested_at"])
_purge_user_row(row["uid"])
purged += 1
audit.record_system(
"account.delete.purge",
target_type="user",
target_uid=row["uid"],
summary=f"purged deleted account {row['uid']} after the grace window",
metadata={
"stamp": row["deletion_requested_at"],
"tables": [name for name, _ in tables],
},
links=[audit.target("user", row["uid"])],
)
return purged
def _purge_user_row(user_uid: str) -> None:
with db:
db.query("DELETE FROM users WHERE uid = :uid", uid=user_uid)
clear_user_cache(user_uid)
@@ -0,0 +1,139 @@
# retoor <retoor@molodetz.nl>
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from devplacepy.database import (
_now_iso,
get_table,
restore_event,
soft_delete,
)
from devplacepy.utils import clear_user_cache, create_notification
NOTICE_URL = "/docs/content-moderation.html"
REMOVABLE_TABLES: dict[str, str] = {
"post": "posts",
"gist": "gists",
"project": "projects",
"quiz": "quizzes",
"news": "news",
}
SUBJECT_ONLY_TARGETS: frozenset[str] = frozenset(
{"message", "user", "workspace", "poll", "devii_output"}
)
def notify_subject(user_uid: str, message: str) -> None:
if not user_uid or not message:
return
create_notification(user_uid, "moderation", message, user_uid, NOTICE_URL)
def can_remove(target_type: str) -> bool:
if target_type in REMOVABLE_TABLES:
return True
return target_type in ("comment", "attachment", "project_file")
def remove_content(request, admin: dict, target_type: str, target_uid: str) -> bool:
if target_type in REMOVABLE_TABLES:
from devplacepy.content import delete_content_item
table_name = REMOVABLE_TABLES[target_type]
delete_content_item(
request,
table_name,
target_type,
admin,
target_uid,
f"/{table_name}",
)
return True
if target_type == "comment":
from devplacepy.content import delete_comment_record
comment = get_table("comments").find_one(uid=target_uid, deleted_at=None)
if not comment:
return False
delete_comment_record(request, admin, comment)
return True
if target_type == "attachment":
from devplacepy.attachments import soft_delete_attachment
return bool(soft_delete_attachment(target_uid, admin["uid"]))
if target_type == "project_file":
node = get_table("project_files").find_one(uid=target_uid, deleted_at=None)
if not node:
return False
soft_delete("project_files", admin["uid"], uid=target_uid)
return True
return False
def restore_content(target_type: str, target_uid: str) -> bool:
table_name = REMOVABLE_TABLES.get(target_type)
if target_type == "comment":
table_name = "comments"
elif target_type == "attachment":
table_name = "attachments"
elif target_type == "project_file":
table_name = "project_files"
if not table_name:
return False
row = get_table(table_name).find_one(uid=target_uid)
if not row or not row.get("deleted_at"):
return False
restore_event(row["deleted_at"])
return True
def suspend_user(subject: dict, hours: int, reason: str) -> str:
until = (datetime.now(timezone.utc) + timedelta(hours=max(1, hours))).isoformat()
get_table("users").update(
{"uid": subject["uid"], "suspended_until": until, "suspension_reason": reason},
["uid"],
)
clear_user_cache(subject["uid"])
return until
def lift_suspension(subject: dict) -> None:
get_table("users").update(
{"uid": subject["uid"], "suspended_until": "", "suspension_reason": ""},
["uid"],
)
clear_user_cache(subject["uid"])
def ban_user(subject: dict, reason: str) -> None:
get_table("users").update(
{
"uid": subject["uid"],
"is_active": False,
"suspension_reason": reason,
"suspended_until": "",
},
["uid"],
)
revoke_sessions(subject["uid"])
clear_user_cache(subject["uid"])
def unban_user(subject: dict) -> None:
get_table("users").update(
{"uid": subject["uid"], "is_active": True, "suspension_reason": ""}, ["uid"]
)
clear_user_cache(subject["uid"])
def revoke_sessions(user_uid: str) -> int:
stamp = _now_iso()
revoked = soft_delete("sessions", user_uid, stamp=stamp, user_uid=user_uid)
revoked += soft_delete("access_tokens", user_uid, stamp=stamp, user_uid=user_uid)
revoked += soft_delete("devrant_tokens", user_uid, stamp=stamp, user_uid=user_uid)
return revoked
+127
View File
@@ -0,0 +1,127 @@
# retoor <retoor@molodetz.nl>
from __future__ import annotations
import logging
from dataclasses import dataclass
from devplacepy.database import get_int_setting, get_setting
from devplacepy.services.moderation.rules import (
ALWAYS_BLOCK_CATEGORIES,
DEFAULT_REVIEW_SCORE,
FILTER_MODES,
MATURE_CATEGORIES,
matches,
score_for,
)
logger = logging.getLogger(__name__)
VERDICT_ORDER: dict[str, int] = {"allow": 0, "label": 1, "review": 2, "block": 3}
MODE_ORDER: dict[str, int] = {"off": 0, "label": 1, "review": 2, "block": 3}
@dataclass(frozen=True)
class Classification:
verdict: str = "allow"
categories: tuple[str, ...] = ()
maturity: str = "general"
score: int = 0
failed: bool = False
detail: str = ""
@property
def flagged(self) -> bool:
return self.verdict in ("review", "block")
@dataclass(frozen=True)
class Screening:
classification: Classification
fields: tuple[str, ...] = ()
@property
def blocked(self) -> bool:
return self.classification.verdict == "block"
@property
def flagged(self) -> bool:
return self.classification.flagged
def filter_mode() -> str:
mode = get_setting("moderation_filter_mode", "review")
return mode if mode in FILTER_MODES else "review"
def review_score() -> int:
return max(
1, get_int_setting("moderation_filter_review_score", DEFAULT_REVIEW_SCORE)
)
def resolve_verdict(verdict: str, mode: str) -> str:
if mode == "off":
return "allow"
if mode == "label":
return "label" if VERDICT_ORDER[verdict] > VERDICT_ORDER["label"] else verdict
if mode == "block" and verdict == "review":
return "block"
return verdict
def classify(text: str, mode: str = "") -> Classification:
active = mode if mode in FILTER_MODES else filter_mode()
if active == "off" or not (text or "").strip():
return Classification()
try:
return _classify(text, active)
except Exception as exc:
logger.warning("moderation filter failed, falling back to review: %s", exc)
return Classification(
verdict="review",
categories=("other",),
failed=True,
detail=f"classification failed: {exc}",
)
def _classify(text: str, mode: str) -> Classification:
matched = matches(text)
if not matched:
return Classification()
categories = tuple(sorted({rule.category for rule in matched}))
score = score_for(text, matched)
if score <= 0:
return Classification(categories=categories)
if set(categories) & ALWAYS_BLOCK_CATEGORIES:
verdict = "block"
elif score >= review_score():
verdict = "review"
else:
verdict = "label"
maturity = "mature" if set(categories) & MATURE_CATEGORIES else "general"
return Classification(
verdict=resolve_verdict(verdict, mode),
categories=categories,
maturity=maturity,
score=score,
detail=", ".join(categories),
)
def screen(values: dict[str, str], mode: str = "") -> Screening:
worst = Classification()
flagged_fields: list[str] = []
for name, value in values.items():
result = classify(value or "", mode)
if result.verdict == "allow":
continue
flagged_fields.append(name)
if VERDICT_ORDER[result.verdict] > VERDICT_ORDER[worst.verdict] or (
result.verdict == worst.verdict and result.score > worst.score
):
worst = result
return Screening(classification=worst, fields=tuple(flagged_fields))
+358
View File
@@ -0,0 +1,358 @@
# retoor <retoor@molodetz.nl>
from __future__ import annotations
import json
from devplacepy.database import (
ACTIONS_TABLE,
MODERATION_ACTIONS,
REPORTABLE_TARGETS,
REPORTS_TABLE,
REPORT_OPEN_STATUSES,
REPORT_ORIGINS,
REPORT_REASONS,
REPORT_SEVERITIES,
REPORT_STATUSES,
SYSTEM_ACTOR,
_in_clause,
_now_iso,
build_pagination,
conditional_update_row,
db,
get_table,
get_users_by_uids,
resolve_object_url,
)
from devplacepy.utils import generate_uid
PER_PAGE = 25
CRITICAL_REASONS: frozenset[str] = frozenset(
{"sexual", "exploitative", "self_harm", "illegal"}
)
def severity_for_reason(reason: str) -> str:
if reason in CRITICAL_REASONS:
return "critical"
if reason == "spam":
return "info"
return "warn"
def owner_uid_for(target_type: str, target_uid: str) -> str:
table_name = REPORTABLE_TARGETS.get(target_type)
if not table_name or table_name not in db.tables:
return ""
row = get_table(table_name).find_one(uid=target_uid)
if not row:
return ""
if target_type == "user":
return row.get("uid", "")
for column in ("user_uid", "sender_uid", "owner_uid", "receiver_uid", "giver_uid"):
value = row.get(column)
if value:
return value
return ""
def open_report(target_type: str, target_uid: str, reporter_uid: str) -> dict | None:
if REPORTS_TABLE not in db.tables:
return None
for status in REPORT_OPEN_STATUSES:
found = get_table(REPORTS_TABLE).find_one(
target_type=target_type,
target_uid=target_uid,
reporter_uid=reporter_uid,
status=status,
deleted_at=None,
)
if found:
return found
return None
def raise_report(
*,
target_type: str,
target_uid: str,
reporter_uid: str,
reason: str,
detail: str = "",
origin: str = "member",
severity: str = "",
categories: list[str] | None = None,
) -> dict | None:
if target_type not in REPORTABLE_TARGETS or reason not in REPORT_REASONS:
return None
if origin not in REPORT_ORIGINS:
origin = "member"
if severity not in REPORT_SEVERITIES:
severity = severity_for_reason(reason)
table = get_table(REPORTS_TABLE)
now = _now_iso()
payload = json.dumps(categories or [])
existing = open_report(target_type, target_uid, reporter_uid)
if existing:
table.update(
{
"id": existing["id"],
"reason": reason,
"detail": detail,
"severity": severity,
"categories": payload,
"updated_at": now,
},
["id"],
)
return table.find_one(id=existing["id"])
uid = generate_uid()
table.insert(
{
"uid": uid,
"reporter_uid": reporter_uid,
"target_type": target_type,
"target_uid": target_uid,
"owner_uid": owner_uid_for(target_type, target_uid),
"reason": reason,
"detail": detail,
"severity": severity,
"status": "open",
"origin": origin,
"categories": payload,
"resolved_by": "",
"resolved_at": "",
"created_at": now,
"updated_at": now,
"deleted_at": None,
"deleted_by": None,
}
)
return table.find_one(uid=uid)
def get_report(uid: str) -> dict | None:
if REPORTS_TABLE not in db.tables:
return None
return get_table(REPORTS_TABLE).find_one(uid=uid, deleted_at=None)
def set_status(uid: str, status: str, actor_uid: str) -> dict | None:
if status not in REPORT_STATUSES:
return None
report = get_report(uid)
if not report:
return None
now = _now_iso()
changes = {"id": report["id"], "status": status, "updated_at": now}
if status in ("actioned", "dismissed"):
changes["resolved_by"] = actor_uid
changes["resolved_at"] = now
else:
changes["resolved_by"] = ""
changes["resolved_at"] = ""
get_table(REPORTS_TABLE).update(changes, ["id"])
return get_table(REPORTS_TABLE).find_one(id=report["id"])
def claim_open(uid: str, status: str, actor_uid: str) -> bool:
if status not in ("actioned", "dismissed"):
return False
placeholders, params = _in_clause(list(REPORT_OPEN_STATUSES), prefix="s")
params.update({"status": status, "actor": actor_uid, "resolved": _now_iso()})
changed = conditional_update_row(
REPORTS_TABLE,
uid,
"status = :status, resolved_by = :actor, resolved_at = :resolved",
f"deleted_at IS NULL AND status IN ({placeholders})",
params,
)
return changed == 1
def escalate(uid: str) -> dict | None:
report = get_report(uid)
if not report:
return None
get_table(REPORTS_TABLE).update(
{
"id": report["id"],
"severity": "critical",
"status": "acknowledged",
"resolved_by": "",
"resolved_at": "",
"updated_at": _now_iso(),
},
["id"],
)
return get_table(REPORTS_TABLE).find_one(id=report["id"])
def record_action(
*,
report_uid: str,
actor_uid: str,
action: str,
target_type: str,
target_uid: str,
subject_uid: str = "",
reason: str = "",
notes: str = "",
expires_at: str = "",
) -> dict | None:
if action not in MODERATION_ACTIONS:
return None
table = get_table(ACTIONS_TABLE)
uid = generate_uid()
table.insert(
{
"uid": uid,
"report_uid": report_uid,
"actor_uid": actor_uid,
"action": action,
"target_type": target_type,
"target_uid": target_uid,
"subject_uid": subject_uid,
"reason": reason,
"notes": notes,
"expires_at": expires_at,
"created_at": _now_iso(),
"deleted_at": None,
"deleted_by": None,
}
)
return table.find_one(uid=uid)
def actions_for_report(report_uid: str) -> list[dict]:
if ACTIONS_TABLE not in db.tables:
return []
return list(
get_table(ACTIONS_TABLE).find(
report_uid=report_uid, deleted_at=None, order_by=["-created_at"]
)
)
def actions_for_subject(subject_uid: str, limit: int = 20) -> list[dict]:
if not subject_uid or ACTIONS_TABLE not in db.tables:
return []
return list(
get_table(ACTIONS_TABLE).find(
subject_uid=subject_uid,
deleted_at=None,
order_by=["-created_at"],
_limit=limit,
)
)
def status_counts() -> dict[str, int]:
counts = {status: 0 for status in REPORT_STATUSES}
if REPORTS_TABLE not in db.tables:
return counts
rows = db.query(
f"SELECT status, COUNT(*) AS n FROM {REPORTS_TABLE} "
f"WHERE deleted_at IS NULL GROUP BY status"
)
for row in rows:
if row["status"] in counts:
counts[row["status"]] = int(row["n"])
return counts
def duplicate_counts(target_pairs: list[tuple[str, str]]) -> dict[tuple[str, str], int]:
if not target_pairs or REPORTS_TABLE not in db.tables:
return {}
uids = [uid for _, uid in target_pairs]
placeholders, params = _in_clause(uids)
rows = db.query(
f"SELECT target_type, target_uid, COUNT(*) AS n FROM {REPORTS_TABLE} "
f"WHERE deleted_at IS NULL AND target_uid IN ({placeholders}) "
f"GROUP BY target_type, target_uid",
**params,
)
return {(row["target_type"], row["target_uid"]): int(row["n"]) for row in rows}
def list_reports(
*,
status: str = "open",
reporter_uid: str = "",
page: int = 1,
per_page: int = PER_PAGE,
) -> tuple[list[dict], dict]:
if REPORTS_TABLE not in db.tables:
return [], build_pagination(page, 0, per_page)
table = get_table(REPORTS_TABLE)
filters: dict[str, object] = {"deleted_at": None}
if status in REPORT_STATUSES:
filters["status"] = status
if reporter_uid:
filters["reporter_uid"] = reporter_uid
total = table.count(**filters)
pagination = build_pagination(page, total, per_page)
offset = (pagination["page"] - 1) * pagination["per_page"]
rows = list(
table.find(
order_by=["created_at", "id"],
_limit=pagination["per_page"],
_offset=offset,
**filters,
)
)
return enrich_reports(rows), pagination
def enrich_reports(rows: list[dict]) -> list[dict]:
people = get_users_by_uids(
[row.get("reporter_uid") for row in rows if row.get("reporter_uid")]
+ [row.get("owner_uid") for row in rows if row.get("owner_uid")]
)
duplicates = duplicate_counts(
[(row["target_type"], row["target_uid"]) for row in rows]
)
enriched = []
for row in rows:
reporter = people.get(row.get("reporter_uid"))
owner = people.get(row.get("owner_uid"))
enriched.append(
{
"uid": row["uid"],
"target_type": row["target_type"],
"target_uid": row["target_uid"],
"target_url": resolve_object_url(row["target_type"], row["target_uid"]),
"reason": row["reason"],
"reason_label": REPORT_REASONS.get(row["reason"], row["reason"]),
"detail": row.get("detail", ""),
"severity": row.get("severity", "warn"),
"status": row.get("status", "open"),
"origin": row.get("origin", "member"),
"categories": _categories(row.get("categories")),
"created_at": row.get("created_at", ""),
"updated_at": row.get("updated_at", ""),
"resolved_at": row.get("resolved_at", ""),
"reporter_uid": row.get("reporter_uid", ""),
"reporter_name": reporter["username"]
if reporter
else (SYSTEM_ACTOR if row.get("reporter_uid") == SYSTEM_ACTOR else ""),
"owner_uid": row.get("owner_uid", ""),
"owner_name": owner["username"] if owner else "",
"report_count": duplicates.get(
(row["target_type"], row["target_uid"]), 1
),
}
)
return enriched
def _categories(raw) -> list[str]:
if not raw:
return []
try:
loaded = json.loads(raw)
except (TypeError, ValueError):
return []
return [str(item) for item in loaded] if isinstance(loaded, list) else []
+178
View File
@@ -0,0 +1,178 @@
# retoor <retoor@molodetz.nl>
from __future__ import annotations
import re
from dataclasses import dataclass
FILTER_MODES: tuple[str, ...] = ("off", "label", "review", "block")
ALWAYS_BLOCK_CATEGORIES: frozenset[str] = frozenset({"sexual", "exploitative"})
MATURE_CATEGORIES: frozenset[str] = frozenset({"sexual", "violence", "self_harm"})
DEFAULT_REVIEW_SCORE = 2
DEFAULT_BLOCK_SCORE = 6
@dataclass(frozen=True)
class Rule:
category: str
weight: int
pattern: re.Pattern[str]
def _rule(category: str, weight: int, expression: str) -> Rule:
return Rule(
category=category,
weight=weight,
pattern=re.compile(expression, re.IGNORECASE),
)
RULES: tuple[Rule, ...] = (
_rule(
"hate",
6,
r"\b(?:gas|exterminate|eradicate|purge)\s+(?:the\s+)?"
r"(?:jews|muslims|blacks|whites|asians|gays|trans(?:\s?people)?|immigrants)\b",
),
_rule(
"hate",
4,
r"\b(?:all|every)\s+(?:jews|muslims|blacks|whites|asians|gays|trans(?:\s?people)?|"
r"immigrants|women|men)\s+(?:are|should\s+be)\s+"
r"(?:subhuman|vermin|animals|scum|killed|deported|removed)\b",
),
_rule("hate", 5, r"\b(?:white|racial)\s+(?:power|supremacy)\b.{0,40}\b(?:rise|fight|war)\b"),
_rule(
"violence",
6,
r"\b(?:i\s+(?:will|am\s+going\s+to|wanna|want\s+to)|we\s+will)\s+"
r"(?:kill|murder|shoot|stab|behead|burn)\s+(?:you|him|her|them|u)\b",
),
_rule("violence", 5, r"\b(?:i\s+know\s+where\s+you\s+live|watch\s+your\s+back,?\s+(?:you|i))\b"),
_rule("violence", 4, r"\b(?:death|bomb)\s+threat\s+(?:to|against)\s+\w+"),
_rule(
"weapons",
5,
r"\b(?:how\s+to\s+)?(?:build|make|assemble|construct)\s+(?:a\s+|an\s+)?"
r"(?:pipe\s?bomb|nail\s?bomb|ied|pressure\s?cooker\s+bomb|nerve\s+agent|"
r"sarin|ricin|dirty\s+bomb)\b",
),
_rule(
"weapons",
4,
r"\b(?:untraceable|ghost)\s+(?:gun|firearm)\b.{0,40}\b(?:build|print|make|assemble)\b",
),
_rule(
"sexual",
8,
r"\b(?:child|minor|underage|preteen|loli|shota)\s?(?:porn|pornography|sex|nudes|cp)\b",
),
_rule("sexual", 5, r"\b(?:hardcore|explicit)\s+(?:porn|pornography|xxx)\b"),
_rule("sexual", 4, r"\b(?:nudes|sexting|camgirl|onlyfans)\b.{0,30}\b(?:dm|send|link|free)\b"),
_rule(
"exploitative",
8,
r"\b(?:sell|buy|trade|traffic(?:king)?)\s+(?:a\s+)?"
r"(?:child|children|minor|minors|girl|girls|boy|boys)\b",
),
_rule(
"religious",
4,
r"\b(?:all|every)\s+(?:christians|muslims|jews|hindus|buddhists|atheists)\s+"
r"(?:are|should\s+be)\s+(?:killed|removed|banned|scum|vermin)\b",
),
_rule(
"misinformation",
3,
r"\b(?:vaccines?\s+(?:cause|causes)\s+autism|drink(?:ing)?\s+bleach\s+"
r"(?:cures|to\s+cure)|the\s+election\s+was\s+stolen\s+and)\b",
),
_rule(
"harassment",
4,
r"\b(?:kill\s+your\s?self|kys|neck\s+your\s?self)\b",
),
_rule(
"harassment",
3,
r"\b(?:you\s+(?:are|'re|r)\s+(?:a\s+)?(?:worthless|pathetic|disgusting)\s+"
r"(?:piece\s+of\s+\w+|human|waste))\b",
),
_rule(
"harassment",
4,
r"\b(?:here\s+is|posting)\s+(?:his|her|their|your)\s+"
r"(?:home\s+address|real\s+name\s+and\s+address|phone\s+number\s+and\s+address)\b",
),
_rule(
"self_harm",
5,
r"\b(?:best|painless|easiest)\s+way\s+to\s+(?:kill\s+myself|end\s+my\s+life|"
r"commit\s+suicide)\b",
),
_rule(
"self_harm",
4,
r"\b(?:you\s+should\s+)?(?:go\s+)?(?:kill\s+yourself|end\s+your\s+life)\b",
),
_rule(
"spam",
3,
r"\b(?:buy\s+now|100%\s+free\s+money|work\s+from\s+home\s+\$\d+|"
r"click\s+here\s+to\s+claim|crypto\s+giveaway)\b",
),
_rule(
"illegal",
5,
r"\b(?:selling|buying|for\s+sale)\s+(?:stolen\s+)?"
r"(?:credit\s?cards?|cc\s+dumps|fullz|ssn\s+list|bank\s+logs)\b",
),
_rule(
"illegal",
4,
r"\b(?:hire|hiring)\s+(?:a\s+)?hit\s?man\b",
),
_rule(
"intellectual_property",
2,
r"\b(?:full\s+)?(?:cracked|nulled|warez)\s+(?:copy|version|download)\b",
),
)
TECHNICAL_CONTEXT = re.compile(
r"\b(?:cve-\d{4}-\d+|exploit|payload|vulnerabilit(?:y|ies)|proof\s+of\s+concept|"
r"reverse\s+engineer(?:ing)?|malware\s+analysis|penetration\s+test(?:ing)?|"
r"red\s+team|sandbox|disassembl(?:y|er)|fuzz(?:ing|er)|stack\s+trace|traceback|"
r"segmentation\s+fault|kernel\s+panic)\b",
re.IGNORECASE,
)
TECHNICAL_DISCOUNT_CATEGORIES: frozenset[str] = frozenset(
{"weapons", "violence", "illegal"}
)
TECHNICAL_DISCOUNT = 3
def matches(text: str) -> list[Rule]:
if not text:
return []
return [rule for rule in RULES if rule.pattern.search(text)]
def score_for(text: str, matched: list[Rule]) -> int:
if not matched:
return 0
technical = bool(TECHNICAL_CONTEXT.search(text))
total = 0
for rule in matched:
weight = rule.weight
if technical and rule.category in TECHNICAL_DISCOUNT_CATEGORIES:
weight = max(0, weight - TECHNICAL_DISCOUNT)
total += weight
return total
+142
View File
@@ -0,0 +1,142 @@
# retoor <retoor@molodetz.nl>
from __future__ import annotations
import logging
from devplacepy.database import (
MATURITY_TARGETS,
REPORT_REASONS,
SYSTEM_ACTOR,
set_maturity,
)
from devplacepy.services.background import background
from devplacepy.services.moderation.filter import Classification, Screening, screen
from devplacepy.services.moderation.queue import raise_report
logger = logging.getLogger(__name__)
SCREENED_FIELDS: dict[str, tuple[str, ...]] = {
"posts": ("title", "content"),
"projects": ("title", "description"),
"gists": ("title", "description"),
"news": ("title", "description"),
"quizzes": ("title", "description"),
"comments": ("content",),
"messages": ("content",),
"users": ("username", "bio", "location"),
}
REFUSAL = (
"This content matches a category prohibited by the community guidelines "
"({categories}) and was not published. See /docs/community-guidelines.html."
)
FAILURE_REASON = "other"
class ContentRefused(Exception):
def __init__(self, categories: tuple[str, ...]):
self.categories = categories
self.message = REFUSAL.format(categories=", ".join(categories) or "prohibited")
super().__init__(self.message)
def screen_fields(table_name: str, fields: dict) -> Screening:
columns = SCREENED_FIELDS.get(table_name)
if not columns:
return Screening(classification=Classification())
values = {
column: str(fields.get(column) or "")
for column in columns
if fields.get(column)
}
return screen(values)
def refuse_if_blocked(screening: Screening) -> None:
if screening.blocked:
raise ContentRefused(screening.classification.categories)
def _reason_for(categories: tuple[str, ...]) -> str:
for category in categories:
if category in REPORT_REASONS:
return category
return FAILURE_REASON
def record(
screening: Screening,
*,
target_type: str,
target_uid: str,
actor_uid: str = SYSTEM_ACTOR,
request=None,
) -> None:
from devplacepy.services.audit import record as audit
classification = screening.classification
if classification.verdict == "allow":
return
if classification.maturity != "general" and target_type in MATURITY_TARGETS:
labelled = set_maturity(
target_type, target_uid, classification.maturity, "filter", SYSTEM_ACTOR
)
if labelled:
audit.record(
request,
"filter.maturity",
actor_kind="system",
target_type=target_type,
target_uid=target_uid,
new_value=classification.maturity,
metadata={
"categories": list(classification.categories),
"score": classification.score,
"author_uid": actor_uid,
},
summary=(
f"content filter labelled {target_type} {target_uid} "
f"as {classification.maturity}"
),
links=[audit.target(target_type, target_uid)],
)
if not classification.flagged:
return
detail = classification.detail or "matched a prohibited category"
if classification.failed:
detail = classification.detail
background.submit(
raise_report,
target_type=target_type,
target_uid=target_uid,
reporter_uid=SYSTEM_ACTOR,
reason=_reason_for(classification.categories),
detail=f"Automated filter: {detail} (fields: {', '.join(screening.fields) or 'n/a'})",
origin="filter",
severity="critical" if classification.failed else "",
categories=list(classification.categories),
)
event = "filter.block" if classification.verdict == "block" else "filter.review"
logger.info(
f"filter {classification.verdict} on {target_type} {target_uid}: {detail}"
)
audit.record(
request,
event,
actor_kind="system",
target_type=target_type,
target_uid=target_uid,
result="denied" if classification.verdict == "block" else "success",
metadata={
"categories": list(classification.categories),
"score": classification.score,
"fields": list(screening.fields),
"failed": classification.failed,
"author_uid": actor_uid,
},
summary=f"content filter raised {classification.verdict} on {target_type} {target_uid}",
links=[audit.target(target_type, target_uid)],
)
+78
View File
@@ -0,0 +1,78 @@
# retoor <retoor@molodetz.nl>
import logging
from devplacepy.services.base import BaseService, ConfigField
from devplacepy.services.moderation import deletion, queue, sla
from devplacepy.services.moderation.deletion import DEFAULT_GRACE_HOURS
from devplacepy.services.moderation.sla import DEFAULT_SLA_HOURS
logger = logging.getLogger(__name__)
class ModerationService(BaseService):
title = "Moderation housekeeping"
description = (
"Purges deleted accounts once their grace window closes and reports the "
"moderation queue's service-level snapshot."
)
default_enabled = True
min_interval = 300
METRICS_SECONDS = 60
config_fields = [
ConfigField(
"moderation_sla_hours",
"Response window (hours)",
type="int",
default=DEFAULT_SLA_HOURS,
minimum=1,
help=(
"The published commitment. The admin queue badge turns red once the "
"oldest open report is older than this."
),
group="General",
),
ConfigField(
"account_deletion_grace_hours",
"Account deletion grace (hours)",
type="int",
default=DEFAULT_GRACE_HOURS,
minimum=0,
help=(
"How long a deleted account stays restorable before it is purged. "
"The account is anonymised immediately either way."
),
group="General",
),
]
def __init__(self) -> None:
super().__init__("moderation", interval_seconds=3600)
async def run_once(self) -> None:
purged = deletion.purge_due()
if purged:
self.log(f"Purged {purged} deleted account(s) past the grace window")
snapshot = sla.snapshot()
if snapshot["breached"]:
self.log(
f"{snapshot['breached']} report(s) past the "
f"{snapshot['sla_hours']}h response window; "
f"oldest open is {snapshot['oldest_open_hours']}h"
)
def collect_metrics(self) -> dict:
snapshot = sla.snapshot()
counts = queue.status_counts()
return {
"stats": [
{"label": "Open reports", "value": counts.get("open", 0)},
{"label": "Acknowledged", "value": counts.get("acknowledged", 0)},
{"label": "Actioned", "value": counts.get("actioned", 0)},
{"label": "Dismissed", "value": counts.get("dismissed", 0)},
{"label": "Oldest open (h)", "value": snapshot["oldest_open_hours"]},
{"label": "Response window (h)", "value": snapshot["sla_hours"]},
{"label": "Past window", "value": snapshot["breached"]},
{"label": "Pending purges", "value": len(deletion.due_purges())},
]
}
+80
View File
@@ -0,0 +1,80 @@
# retoor <retoor@molodetz.nl>
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from devplacepy.database import (
REPORTS_TABLE,
REPORT_OPEN_STATUSES,
_in_clause,
db,
get_int_setting,
)
DEFAULT_SLA_HOURS = 24
def sla_hours() -> int:
return max(1, get_int_setting("moderation_sla_hours", DEFAULT_SLA_HOURS))
def _hours_since(iso: str) -> float:
try:
stamp = datetime.fromisoformat(iso)
except (TypeError, ValueError):
return 0.0
if stamp.tzinfo is None:
stamp = stamp.replace(tzinfo=timezone.utc)
return max(0.0, (datetime.now(timezone.utc) - stamp).total_seconds() / 3600)
def oldest_open() -> dict | None:
if REPORTS_TABLE not in db.tables:
return None
placeholders, params = _in_clause(list(REPORT_OPEN_STATUSES), prefix="s")
rows = list(
db.query(
f"SELECT uid, created_at FROM {REPORTS_TABLE} "
f"WHERE deleted_at IS NULL AND status IN ({placeholders}) "
f"ORDER BY created_at ASC LIMIT 1",
**params,
)
)
return rows[0] if rows else None
def breach_count() -> int:
if REPORTS_TABLE not in db.tables:
return 0
cutoff = _cutoff_iso()
placeholders, params = _in_clause(list(REPORT_OPEN_STATUSES), prefix="s")
params["cutoff"] = cutoff
rows = list(
db.query(
f"SELECT COUNT(*) AS n FROM {REPORTS_TABLE} "
f"WHERE deleted_at IS NULL AND status IN ({placeholders}) "
f"AND created_at < :cutoff",
**params,
)
)
return int(rows[0]["n"]) if rows else 0
def _cutoff_iso() -> str:
return (datetime.now(timezone.utc) - timedelta(hours=sla_hours())).isoformat()
def snapshot() -> dict:
limit = sla_hours()
oldest = oldest_open()
age = _hours_since(oldest["created_at"]) if oldest else 0.0
breached = breach_count()
return {
"sla_hours": limit,
"oldest_open_hours": round(age, 1),
"oldest_open_uid": oldest["uid"] if oldest else "",
"breached": breached,
"within_sla": breached == 0,
}
@@ -129,3 +129,14 @@ Real providers sometimes charge more than a flat per-1M rate for one component:
- **Migration.** New `gateway_models` columns are added via `has_column`/`create_column_by_example` in `routing.ensure_tables()` (the `CREATE TABLE IF NOT EXISTS` DDL string alone would never reach a pre-existing table - see the `database/CLAUDE.md` column-ensure idiom).
- **Admin UI.** `/admin/gateway`'s model-route form has a "Tiered / off-peak pricing (optional)" subsection; off-peak start/end render as `<input type="time">` (converted to/from UTC minutes-of-day by `GatewayAdmin.js`), and the routes table shows `tiered`/`off-peak` badges when a route has either dimension configured.
- **DeepSeek's real pricing is already the tier-1 shape, not a new dimension.** DeepSeek's actual API (verified against `api-docs.deepseek.com/quick_start/pricing`) bills three flat per-1M rates - cache-hit input, cache-miss input, output - with no current context-length tier or off-peak window for the V4 models; that shape was already fully modeled by the pre-existing `chat_cache_hit_per_m`/`chat_cache_miss_per_m`/`chat_output_per_m` fields before this section's tier2/off-peak fields existed. `routing.seed_default_deepseek_routes()` (called once from `database.migrate_ai_gateway_settings()` at the end of `init_db()`) idempotently inserts four ready-made routes - `deepseek-v4-flash` (`$0.0028`/`$0.14`/`$0.28` per 1M, 1M context), `deepseek-v4-pro` (`$0.003625`/`$0.435`/`$0.87` per 1M, 1M context), and the two public `molodetz` aliases `molodetz` -> `deepseek-v4-flash` (flash rates) and `molodetz-pro` -> `deepseek-v4-pro` (pro rates) - only when that `source_model` row does not already exist, so a caller or Devii can request any name explicitly and get correctly-priced, decoupled from whatever the single global `gateway_model` default happens to be set to (switching that global setting between the two real models does NOT retroactively fix the flat Pricing config fields - the seeded routes are the model-agnostic, always-correct way to reference a specific priced model). The `molodetz`/`molodetz-pro` aliases are the public model names; `GET /v1/models` is served locally from these `source_model` rows (not proxied upstream), so it publishes exactly the models the gateway accepts. Neither seeded route sets the tier2/off-peak fields (DeepSeek does not use them today); an admin can add them later on the same row if DeepSeek (or any other provider routed here) introduces such pricing.
## Third-party AI consent gate (one place, two owner classes)
`GatewayService.handle()` calls `consent_denied(owner)` immediately after `resolve_owner`, before the quota check. This is the **only** consent gate in the platform; no consumer implements one of its own.
The split it enforces is the whole point:
- owner kind `user` / `admin` -> the call carries **that user's own content** (Devii, AI correction, the AI modifier, a member calling `/openai/v1/*` with their own key). It requires a granted `ai_third_party` consent and answers **403** with `CONSENT_REQUIRED_MESSAGE` otherwise, plus an `ai.consent.denied` audit row.
- owner kind `internal` / `key` / `anonymous` -> **platform processing** (news import, the bot fleet, SEO metadata, issue enhancement). Never gated: it is not the user's content.
`ai_third_party` is **never granted at signup**. The existing `ai_correction_enabled` / `ai_modifier_enabled` user columns survive unchanged as *preferences* subordinate to consent: withdrawing consent turns those features off regardless of the flag, so `ai_modifier_enabled`'s default of `1` is harmless. No existing preference was flipped and no consumer changed - the gate was simply added above them. `USER_CONTENT_OWNER_KINDS` is the single tuple defining the split; widen it only if a new owner kind genuinely carries a user's own content.
@@ -22,6 +22,13 @@ logger = logging.getLogger(__name__)
APP_REFERENCE_PATTERN = re.compile(r"^[a-zA-Z0-9_.-]{1,30}$")
DEFAULT_APP_REFERENCE = "default"
USER_CONTENT_OWNER_KINDS = ("user", "admin")
CONSENT_REQUIRED_MESSAGE = (
"Third-party AI processing of your content requires consent. "
"Grant it under Privacy on your profile."
)
def _validate_app_reference(value: str) -> str:
stripped = (value or "").strip()
@@ -515,6 +522,43 @@ class GatewayService(BaseService):
return (kind, user.get("uid") or "unknown")
return ("anonymous", "anonymous")
def user_content_owner(self, owner: tuple) -> str:
if owner[0] in USER_CONTENT_OWNER_KINDS and owner[1]:
return owner[1]
return ""
def consent_denied(self, owner: tuple) -> bool:
from devplacepy.database import consent_granted
owner_uid = self.user_content_owner(owner)
if not owner_uid:
return False
return not consent_granted("user", owner_uid, "ai_third_party")
def _audit_consent_denied(self, owner: tuple, app_reference: str) -> None:
from devplacepy.services.audit import record as audit
from devplacepy.services.openai_gateway.usage import audit_actor_for
actor_kind, actor_uid, actor_role = audit_actor_for(owner[0], owner[1])
audit.record_system(
"ai.consent.denied",
actor_kind=actor_kind,
actor_uid=actor_uid,
actor_role=actor_role,
origin="api",
result="denied",
summary=(
f"AI gateway call by {owner[0]}/{owner[1]} blocked - "
f"third-party AI consent not granted"
),
metadata={
"owner_kind": owner[0],
"owner_id": owner[1],
"app_reference": app_reference,
"consent": "ai_third_party",
},
)
def _audit_quota_exceeded(
self,
owner_kind: str,
@@ -584,6 +628,13 @@ class GatewayService(BaseService):
)
if subpath == "models" and request.method == "GET":
return self._models_response()
if self.consent_denied(owner):
self.log(
f"Rejected {owner[0]}:{owner[1]} app={app_reference}: "
f"third-party AI consent not granted"
)
self._audit_consent_denied(owner, app_reference)
raise HTTPException(status_code=403, detail=CONSENT_REQUIRED_MESSAGE)
limit, scope, rule = quota.resolve(owner[0], owner[1], app_reference, cfg)
if limit > 0:
spent = quota.spent_24h(*scope)
+8
View File
@@ -18,6 +18,12 @@ from devplacepy.database import get_online_users, set_last_seen
_last_write: dict[str, float] = {}
def recording_allowed(user_uid: str) -> bool:
from devplacepy.database import consent_granted
return bool(user_uid) and consent_granted("user", user_uid, "activity_recording")
def touch(user_uid: str) -> None:
if not user_uid:
return
@@ -25,6 +31,8 @@ def touch(user_uid: str) -> None:
if now - _last_write.get(user_uid, 0.0) < PRESENCE_WRITE_SECONDS:
return
_last_write[user_uid] = now
if not recording_allowed(user_uid):
return
set_last_seen(user_uid, datetime.now(timezone.utc).isoformat())
+30
View File
@@ -47,6 +47,24 @@
margin-bottom: 0.375rem;
}
.auth-field-check label {
display: flex;
align-items: flex-start;
gap: var(--space-sm);
font-weight: 400;
line-height: 1.5;
margin-bottom: 0;
}
.auth-field-check input {
margin-top: 0.2rem;
flex: none;
}
.auth-field-check a {
color: var(--accent);
}
.auth-field .input-wrap {
position: relative;
}
@@ -171,3 +189,15 @@
padding: 1rem;
}
}
.terms-links {
list-style: none;
padding: 0;
margin: 0 0 var(--space-xl);
display: grid;
gap: var(--space-sm);
}
.terms-links a {
color: var(--accent);
}
+33 -1
View File
@@ -104,4 +104,36 @@
text-align: right;
color: var(--text-muted);
font-size: 0.85rem;
}
}
.award-report-btn {
position: absolute;
top: var(--space-sm);
left: var(--space-sm);
padding: var(--space-xs) var(--space-sm);
border: none;
border-radius: var(--radius);
background: var(--overlay-dark);
color: var(--text-secondary);
font-size: 0.7rem;
cursor: pointer;
opacity: 0;
}
.award-tile:hover .award-report-btn,
.award-tile:focus-within .award-report-btn {
opacity: 1;
}
.award-report-btn:hover {
color: var(--danger);
}
.award-report-btn .label {
display: none;
}
@media (hover: none) and (pointer: coarse) {
.award-report-btn {
opacity: 1;
}
}
+48
View File
@@ -1376,3 +1376,51 @@ body:has(.page-messages) {
}
}
.recording-indicator {
position: fixed;
left: var(--space-lg);
bottom: calc(var(--space-2xl) + var(--space-2xl));
display: inline-flex;
align-items: center;
gap: var(--space-xs);
padding: var(--space-xs) var(--space-sm);
border-radius: var(--radius);
background: var(--bg-card);
border: 1px solid var(--border-light);
color: var(--text-muted);
font-size: 0.75rem;
z-index: var(--z-fab);
will-change: transform;
}
.recording-indicator a {
color: var(--text-muted);
}
.recording-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--danger);
flex: none;
}
.maturity-gate {
padding: var(--space-2xl);
text-align: center;
border: 1px dashed var(--border-light);
border-radius: var(--radius);
background: var(--bg-card);
margin: var(--space-lg) 0;
}
.maturity-gate h3 {
margin: 0 0 var(--space-md);
}
.maturity-gate p {
color: var(--text-secondary);
margin: 0 0 var(--space-lg);
}
+29
View File
@@ -626,3 +626,32 @@ dp-chat[mode="embed"] {
opacity: 1;
}
}
.message-report-btn {
display: inline-flex;
align-items: center;
gap: var(--space-xs);
margin-top: var(--space-xs);
padding: 0;
background: none;
border: none;
color: var(--text-muted);
font-size: 0.7rem;
opacity: 0;
cursor: pointer;
}
.message-bubble:hover .message-report-btn,
.message-bubble:focus-within .message-report-btn {
opacity: 1;
}
.message-report-btn:hover {
color: var(--danger);
}
@media (hover: none) and (pointer: coarse) {
.message-report-btn {
opacity: 1;
}
}
+11
View File
@@ -498,6 +498,17 @@
font-weight: 600;
}
.landing-feature p {
font-size: 0.75rem;
color: var(--text-secondary);
margin: var(--space-xs) 0 0;
line-height: 1.4;
}
.landing-feature p a {
color: var(--accent);
}
.landing-auth-link {
font-size: 0.875rem;
color: var(--text-secondary);
+8 -2
View File
@@ -107,7 +107,8 @@
background: var(--accent);
}
.media-delete-btn {
.media-delete-btn,
.media-report-btn {
margin-left: auto;
width: 32px;
height: 32px;
@@ -122,10 +123,15 @@
justify-content: center;
}
.media-delete-btn:hover {
.media-delete-btn:hover,
.media-report-btn:hover {
background: var(--danger);
}
.media-report-btn .label {
display: none;
}
.media-tile.media-removing {
opacity: 0;
transform: scale(0.92);
+179
View File
@@ -0,0 +1,179 @@
/* retoor <retoor@molodetz.nl> */
.page-narrow {
max-width: 900px;
margin: 0 auto;
padding: var(--space-lg);
}
.report-intro {
color: var(--text-secondary);
margin-bottom: var(--space-xl);
}
.report-hint {
color: var(--text-muted);
margin-bottom: var(--space-lg);
}
.sla-badge {
display: inline-flex;
align-items: center;
gap: var(--space-xs);
padding: var(--space-xs) var(--space-md);
border-radius: var(--radius);
font-weight: 600;
border: 1px solid var(--border-light);
}
.sla-ok {
color: var(--success);
border-color: var(--success);
}
.sla-breached {
color: var(--danger);
border-color: var(--danger);
}
.severity-badge,
.status-badge {
display: inline-block;
padding: var(--space-xs) var(--space-sm);
border-radius: var(--radius);
font-size: 0.85em;
text-transform: capitalize;
border: 1px solid var(--border-light);
}
.severity-info {
color: var(--info);
border-color: var(--info);
}
.severity-warn {
color: var(--warning);
border-color: var(--warning);
}
.severity-critical {
color: var(--danger);
border-color: var(--danger);
}
.status-open {
color: var(--warning);
border-color: var(--warning);
}
.status-acknowledged {
color: var(--info);
border-color: var(--info);
}
.status-actioned {
color: var(--success);
border-color: var(--success);
}
.status-dismissed {
color: var(--text-muted);
}
.report-summary,
.report-decision,
.report-history {
padding: var(--space-xl);
margin-bottom: var(--space-xl);
}
.report-facts {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: var(--space-lg);
margin: 0;
}
.report-facts dt {
color: var(--text-muted);
font-size: 0.85em;
margin-bottom: var(--space-xs);
}
.report-facts dd {
margin: 0;
color: var(--text-primary);
word-break: break-word;
}
.report-detail {
margin-top: var(--space-xl);
padding: var(--space-lg);
border-left: 3px solid var(--accent);
background: var(--bg-card-hover);
border-radius: var(--radius);
white-space: pre-wrap;
}
.report-categories {
margin-top: var(--space-lg);
color: var(--text-muted);
}
.report-form {
display: grid;
gap: var(--space-lg);
max-width: 640px;
}
.report-form-inline {
margin-top: var(--space-lg);
}
.reason-list {
display: grid;
gap: var(--space-md);
}
.reason-row {
display: grid;
grid-template-columns: minmax(160px, 220px) 1fr;
gap: var(--space-lg);
padding: var(--space-md) 0;
border-bottom: 1px solid var(--border);
}
.reason-row dt code {
font-family: var(--font-mono);
color: var(--accent);
}
.reason-row dd {
margin: 0;
color: var(--text-secondary);
}
@media (max-width: 768px) {
.reason-row {
grid-template-columns: 1fr;
gap: var(--space-xs);
}
.report-facts {
grid-template-columns: 1fr;
}
}
.workspace-desc {
display: block;
color: var(--text-muted);
font-size: 0.85em;
}
.delete-list {
margin: 0 0 var(--space-xl);
padding-left: var(--space-xl);
color: var(--text-secondary);
display: grid;
gap: var(--space-xs);
}
+26
View File
@@ -942,3 +942,29 @@ a.profile-stat-value:hover {
font-size: 0.8rem;
color: var(--text-muted);
}
.privacy-panel h3 {
margin: var(--space-xl) 0 var(--space-sm);
}
.privacy-panel h3:first-child {
margin-top: 0;
}
.privacy-intro {
color: var(--text-secondary);
margin: 0 0 var(--space-lg);
}
.privacy-intro a {
color: var(--accent);
}
.privacy-suspension {
padding: var(--space-md);
border-left: 3px solid var(--danger);
background: var(--bg-card-hover);
border-radius: var(--radius);
color: var(--text-primary);
margin: 0 0 var(--space-lg);
}
+2 -2
View File
@@ -1,7 +1,7 @@
/* retoor <retoor@molodetz.nl> */
.workspace-page {
max-width: var(--content-width);
max-width: var(--max-content);
margin: 0 auto;
padding: var(--space-md);
display: flex;
@@ -62,7 +62,7 @@
}
.workspace-badge {
background: var(--bg-hover);
background: var(--bg-card-hover);
color: var(--text-secondary);
border-radius: var(--radius);
padding: 0 var(--space-xs);
+2
View File
@@ -33,6 +33,7 @@ import { IssueReporter } from "./IssueReporter.js";
import { IssueAttachments } from "./IssueAttachments.js";
import { PlanningGenerator } from "./PlanningGenerator.js";
import { MediaGallery } from "./MediaGallery.js";
import { ReportDialog } from "./ReportDialog.js";
import WindowManager from "./components/WindowManager.js";
import { ContainerTerminalManager } from "./ContainerTerminalManager.js";
import { PubSubClient } from "./PubSubClient.js";
@@ -103,6 +104,7 @@ class Application {
this.issueAttachments = new IssueAttachments();
this.planningGenerator = new PlanningGenerator();
this.mediaGallery = new MediaGallery();
this.reportDialog = new ReportDialog();
this.liveNotifications = new LiveNotifications(this.pubsub, this.toast);
this.presence = new PresenceManager(this.pubsub);
this.onlineUsers = new OnlineUsers(this.pubsub);
+4
View File
@@ -102,3 +102,7 @@ Every file-upload UI is the one custom element `dp-upload` (`static/js/component
- `field` (create-post image, `feed.html`): wraps a real `<input type="file" name="image">` that submits with the form - no AJAX, inline-image flow unchanged.
The component validates size/type/count and reports errors via `app.toast`. CSS is `.dp-upload-*` in `components.css`; the old `.attachment-upload-*` upload-widget styles were removed, but the `.attachment-gallery`/`.attachment-lightbox` display styles (for already-saved attachments) remain.
## ReportDialog (`ReportDialog.js`, `app.reportDialog`)
One class, one dialog, every surface. It delegates a document-level click on `[data-report-type]` (emitted by `_report_button.html`), opens the single `#report-dialog` overlay with the standard `.visible` toggle, and submits through `Http.sendForm` to `/reports/{target_type}/{target_uid}`. The toast repeats the published response window returned by the endpoint, so the acknowledgement the user sees is the one the server actually committed to. It carries no reason list of its own - the options are server-rendered from the `REPORT_REASONS` Jinja global, so a client can never offer a reason the API would reject.
+74
View File
@@ -0,0 +1,74 @@
// retoor <retoor@molodetz.nl>
import { Http } from "./Http.js";
export class ReportDialog {
constructor() {
this.overlay = document.getElementById("report-dialog");
this.form = document.getElementById("report-form");
this.target = null;
document.addEventListener("click", (e) => {
const btn = e.target.closest("[data-report-type]");
if (!btn || btn.disabled) return;
e.preventDefault();
this.open(btn.dataset.reportType, btn.dataset.reportUid);
});
if (this.form) {
this.form.addEventListener("submit", (e) => {
e.preventDefault();
e.stopImmediatePropagation();
this.submit();
});
}
}
open(targetType, targetUid) {
if (!this.overlay || !this.form) return;
this.target = { targetType, targetUid };
this.form.reset();
this.overlay.classList.add("visible");
const reason = this.form.querySelector("[name='reason']");
if (reason) reason.focus();
}
close() {
if (this.overlay) this.overlay.classList.remove("visible");
this.target = null;
}
async submit() {
if (!this.target) return;
const data = new FormData(this.form);
const button = this.form.querySelector("button[type='submit']");
if (button) button.disabled = true;
try {
const result = await Http.sendForm(
`/reports/${this.target.targetType}/${this.target.targetUid}`,
{ reason: data.get("reason"), detail: data.get("detail") || "" },
{ silent: true },
);
const hours = result && result.data ? result.data.sla_hours : null;
this.close();
this.notify(
hours
? `Report received. A moderator reviews it within ${hours} hours.`
: "Report received.",
"success",
);
} catch (err) {
this.notify(err.message, "error");
} finally {
if (button) button.disabled = false;
}
}
notify(message, type) {
if (window.app && window.app.toast) {
window.app.toast.show(message, { type });
return;
}
console.info(message);
}
}
window.ReportDialog = ReportDialog;
+7 -1
View File
@@ -28,13 +28,19 @@ export class WorkspaceManager {
async send(form) {
try {
await Http.sendForm(form);
await Http.sendForm(form.action, this.params(form));
await this.refresh();
} catch (error) {
window.app?.toast?.show(error.message || "Action failed", { type: "error" });
}
}
params(form) {
const params = [];
new FormData(form).forEach((value, key) => params.push([key, value]));
return params;
}
subscribe() {
const uid = this.root.dataset.workspaceUid;
if (uid && window.app?.pubsub) {
+3
View File
@@ -75,6 +75,9 @@ Reuse these via `{% set _x = ... %}{% include %}` (the `_avatar_link.html` conve
- `_quiz_question.html` - one question in builder-preview or player mode. Locals: `_question`, `_mode` (`builder`|`player`), plus `_attempt_url` and `answer_max_chars` in player mode.
- `_quiz_answer_review.html` - one reviewed answer on the results screen. Local: `_question`.
- `_quiz_settings_fields.html` - the shared quiz settings fieldset. Optional local `_quiz` seeds the current values.
- `_report_button.html` - the Report control (and Block, when `_owner_name` is given) for any content action bar. Locals: `_type`, `_uid`, `_owner` (owner uid - the control hides on your own content), `_owner_name` (optional), `_class` (the surrounding surface's button class). Included at fifteen sites; a new content surface MUST include it (see the root `CLAUDE.md` rule).
- `_report_dialog.html` - the one report dialog, included once in `base.html` for signed-in users and driven by `ReportDialog.js`. Reasons come from the `REPORT_REASONS` Jinja global; never hand-roll a second dialog.
- `_maturity_gate.html` - the interstitial rendered in place of a maturity-labelled item. Local: `_level`. Guard the include with the `maturity_hidden(level, user)` global; the item's `maturity` is attached by `enrich_items`/`load_detail`.
Vote button styles live ONCE in `feed.css` (`.post-action-btn`) - never redefine them in `post.css`. Page-specific CSS goes in a `static/css/*.css` file referenced from `{% block extra_head %}`, never an inline `<style>` block. All CSS conventions (tokens, `--z-*` stacking bands, breakpoints, reduced motion) are in `devplacepy/static/css/CLAUDE.md`.
@@ -24,6 +24,7 @@
aria-label="Revoke award">Revoke</button>
</form>
{% endif %}
{% set _type = "award" %}{% set _uid = award['uid'] %}{% set _owner = award.get('receiver_uid', '') %}{% set _owner_name = "" %}{% set _class = "award-report-btn" %}{% include "_report_button.html" %}
</article>
{% else %}
<div class="empty-state">No awards yet.</div>
+1
View File
@@ -35,6 +35,7 @@
</form>
{% endif %}
{% set _type = "comment" %}{% set _uid = item.comment['uid'] %}{% set _reactions = item.reactions %}{% include "_reaction_bar.html" %}
{% set _type = "comment" %}{% set _uid = item.comment['uid'] %}{% set _owner = item.comment['user_uid'] %}{% set _owner_name = (item.author or {}).get('username', '') %}{% set _class = "comment-action-btn" %}{% include "_report_button.html" %}
</div>
{{ caller() }}
</div>
+5
View File
@@ -3,4 +3,9 @@
<a href="/swagger"><span class="icon">&#x1F9EA;</span> Swagger</a>
<a href="/openapi.json"><span class="icon">&#x1F9E9;</span> OpenAPI</a>
<a href="/issues"><span class="icon">&#x1F41B;</span> Issue Report</a>
<a href="/workspaces/index"><span class="icon">&#x1F5A5;&#xFE0F;</span> Workspaces</a>
<a href="/docs/terms.html"><span class="icon">&#x1F4DC;</span> Terms</a>
<a href="/docs/privacy.html"><span class="icon">&#x1F512;</span> Privacy</a>
<a href="/docs/community-guidelines.html"><span class="icon">&#x1F91D;</span> Guidelines</a>
<a href="/docs/contact.html"><span class="icon">&#x2709;&#xFE0F;</span> Contact</a>
</nav>
+13
View File
@@ -0,0 +1,13 @@
<div class="maturity-gate" role="note">
<h3>Hidden: {{ _level }} content</h3>
<p>This item is labelled <strong>{{ _level }}</strong>. It stays hidden until you choose to see this kind of content.</p>
{% if user %}
{% if _level == 'restricted' and (user.get('age_band') not in ('adult', '', none)) %}
<p>Your account's age band does not allow restricted content.</p>
{% else %}
<a href="/profile/{{ user['username'] }}?tab=privacy" class="btn btn-secondary btn-sm">Change this in privacy settings</a>
{% endif %}
{% else %}
<a href="/auth/login" class="btn btn-secondary btn-sm">Log in to change this</a>
{% endif %}
</div>
+1
View File
@@ -28,6 +28,7 @@
</form>
</noscript>
{% endif %}
{% set _type = "attachment" %}{% set _uid = item['uid'] %}{% set _owner = item.get('user_uid', '') %}{% set _owner_name = "" %}{% set _class = "media-report-btn" %}{% include "_report_button.html" %}
</div>
</div>
{% else %}
+5
View File
@@ -15,7 +15,11 @@
</a>
{% endif %}
{% if maturity_hidden(item.maturity, user) %}
{% set _level = item.maturity %}{% include "_maturity_gate.html" %}
{% else %}
<div class="post-content rendered-content">{{ render_content(item.post['content'][:300] ~ ('...' if item.post['content']|length > 300 else ''), author_is_admin=is_admin(item.author)) }}</div>
{% endif %}
{% if item.project_link %}
<a href="{{ item.project_link.url }}" class="project-link">Project: {{ item.project_link.name }}</a>
@@ -42,6 +46,7 @@
<button type="button" class="post-action-btn" data-share="{{ content_url(item.post, 'posts') }}"><span aria-hidden="true">&#x1F517;</span> Share</button>
{% endif %}
{% set _type = "post" %}{% set _uid = item.post['uid'] %}{% set _bookmarked = item.bookmarked %}{% include "_bookmark_button.html" %}
{% set _type = "post" %}{% set _uid = item.post['uid'] %}{% set _owner = item.post['user_uid'] %}{% set _owner_name = (item.author or {}).get('username', '') %}{% set _class = "post-action-btn" %}{% include "_report_button.html" %}
{% if owns(item.post, user) or is_admin(user) %}
<form method="POST" action="/posts/delete/{{ item.post['slug'] or item.post['uid'] }}" class="inline-form">
<button type="submit" class="post-action-btn" data-confirm="Delete this post?"><span aria-hidden="true">🗑️</span> Delete</button>
+11
View File
@@ -0,0 +1,11 @@
{% set _report_owner = _owner if _owner is defined else "" %}
{% set _report_owner_name = _owner_name if _owner_name is defined else "" %}
{% set _report_class = _class if _class is defined else "post-action-btn" %}
{% if not user or user['uid'] != _report_owner %}
<button type="button" class="{{ _report_class }} report-btn" data-report-type="{{ _type }}" data-report-uid="{{ _uid }}" aria-label="Report this {{ _type }}" title="Report"{{ guest_disabled(user) }}><span class="icon" aria-hidden="true">&#x1F6A9;</span><span class="label"> Report</span></button>
{% if user and _report_owner_name %}
<form method="POST" action="/block/{{ _report_owner_name }}" class="inline-form">
<button type="submit" class="{{ _report_class }} block-btn" data-confirm="Block {{ _report_owner_name }}? Their content is hidden from you everywhere except their profile."><span class="icon" aria-hidden="true">&#x1F6AB;</span><span class="label"> Block</span></button>
</form>
{% endif %}
{% endif %}
+22
View File
@@ -0,0 +1,22 @@
{% from "_macros.html" import modal %}
{% call modal("report-dialog", "Report content") %}
<form class="modal-body" id="report-form" method="POST" action="/reports/post/none">
<p class="text-secondary auth-field-gap">Tell us which rule this breaks. A moderator reviews every report; see the <a href="/docs/community-guidelines.html">community guidelines</a>.</p>
<div class="auth-field auth-field-gap">
<label for="report-reason">Reason</label>
<select id="report-reason" name="reason" required>
{% for reason in REPORT_REASONS %}
<option value="{{ reason.key }}">{{ reason.label }}</option>
{% endfor %}
</select>
</div>
<div class="auth-field auth-field-gap">
<label for="report-detail">Detail (optional)</label>
<textarea id="report-detail" name="detail" rows="4" maxlength="2000" placeholder="What should the moderator know?"></textarea>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-ghost modal-close">Cancel</button>
<button type="submit" class="btn btn-primary">Send report</button>
</div>
</form>
{% endcall %}
+26
View File
@@ -0,0 +1,26 @@
{% extends "base.html" %}
{% block extra_head %}
<link rel="stylesheet" href="{{ static_url('/static/css/auth.css') }}">
{% endblock %}
{% block content %}
<div class="auth-page">
<div class="auth-card">
<h2>The terms have changed</h2>
<p class="subtitle">Version {{ terms_version }} of the Terms of Service is now in force. Accept it to keep posting. You can carry on reading, and you can delete your account, without accepting.</p>
<ul class="terms-links">
<li><a href="/docs/terms.html" target="_blank" rel="noopener">Terms of Service</a></li>
<li><a href="/docs/community-guidelines.html" target="_blank" rel="noopener">Community Guidelines</a></li>
<li><a href="/docs/privacy.html" target="_blank" rel="noopener">Privacy Policy</a></li>
</ul>
<form class="auth-form" method="POST" action="/auth/accept-terms">
<button type="submit" class="auth-submit"><span class="icon">&#x2714;&#xFE0F;</span> Accept version {{ terms_version }}</button>
</form>
<div class="auth-footer">
<a href="/feed">Keep reading without accepting</a>
</div>
</div>
</div>
{% endblock %}
+48
View File
@@ -0,0 +1,48 @@
{% extends "base.html" %}
{% block extra_head %}
<link rel="stylesheet" href="{{ static_url('/static/css/auth.css') }}">
<link rel="stylesheet" href="{{ static_url('/static/css/moderation.css') }}">
{% endblock %}
{% block content %}
<div class="page-narrow">
<h1>Delete your account</h1>
<p class="report-intro">This removes your DevPlace account and your personal data. Read what happens before you confirm.</p>
<section class="card report-summary">
<h3>Removed immediately</h3>
<ul class="delete-list">
{% for item in removed %}
<li>{{ item }}</li>
{% endfor %}
</ul>
<h3>Retained</h3>
<ul class="delete-list">
{% for item in retained %}
<li>{{ item }}</li>
{% endfor %}
</ul>
<h3>Timing</h3>
<p class="report-intro">Your account is gone from your view and everyone else's the moment you confirm: your username is tombstoned and your email, profile, avatar, API key and password are cleared straight away, and every session and token is revoked.</p>
{% if grace_hours %}
<p class="report-intro">The removal stays reversible by an administrator for <strong>{{ grace_hours }} hours</strong> in case you delete by accident. After that window the whole deletion is permanently purged and cannot be recovered.</p>
{% else %}
<p class="report-intro">The removal is purged permanently on the next sweep and cannot be recovered.</p>
{% endif %}
</section>
<section class="card report-decision">
<h3>Confirm with your password</h3>
<form class="report-form" method="POST" action="/profile/{{ username }}/delete">
<div class="auth-field">
<label for="delete-password">Your password</label>
<input type="password" id="delete-password" name="password" required maxlength="128" autocomplete="current-password">
</div>
<button type="submit" class="btn btn-danger" data-confirm="Delete your account? This removes your content everywhere." data-confirm-danger data-confirm-title="Delete account">Delete my account</button>
</form>
</section>
<p class="report-intro"><a href="/profile/{{ username }}?tab=privacy">Back to privacy settings</a></p>
</div>
{% endblock %}
+3
View File
@@ -17,6 +17,9 @@
<a href="/admin/media" class="sidebar-link {% if admin_section == 'media' %}active{% endif %}">
<span class="sidebar-icon">&#x1F5BC;&#xFE0F;</span> Media
</a>
<a href="/admin/moderation" class="sidebar-link {% if admin_section == 'moderation' %}active{% endif %}">
<span class="sidebar-icon">&#x1F6A9;</span> Moderation
</a>
<a href="/admin/trash" class="sidebar-link {% if admin_section == 'trash' %}active{% endif %}">
<span class="sidebar-icon">&#x1F5D1;&#xFE0F;</span> Trash
</a>
@@ -0,0 +1,61 @@
{% extends "admin_base.html" %}
{% block extra_head %}
{{ super() }}
<link rel="stylesheet" href="{{ static_url('/static/css/moderation.css') }}">
{% endblock %}
{% block admin_content %}
<div class="admin-toolbar">
<h2>Moderation</h2>
<span class="sla-badge {% if sla.within_sla %}sla-ok{% else %}sla-breached{% endif %}" title="Oldest open report against the published response commitment">
Oldest open {{ sla.oldest_open_hours }}h / {{ sla.sla_hours }}h
{% if sla.breached %}&middot; {{ sla.breached }} over{% endif %}
</span>
</div>
<nav class="admin-tabs" data-overflow-tabs aria-label="Report status">
<div class="overflow-tabs-strip">
{% for tab in statuses %}
<a href="/admin/moderation?status={{ tab['key'] }}" class="admin-tab {% if tab['active'] %}active{% endif %}" data-menu-label="{{ tab['label'] }} ({{ tab['count'] }})">
{{ tab['label'] }} <span class="admin-tab-count">{{ tab['count'] }}</span>
</a>
{% endfor %}
</div>
<button type="button" class="admin-tab overflow-tabs-more" aria-haspopup="menu" aria-label="More tabs" hidden><span class="icon">&#x22EF;</span> More</button>
</nav>
<table class="admin-table">
<caption class="sr-only">Reported content awaiting a moderation decision</caption>
<thead>
<tr>
<th scope="col">Target</th>
<th scope="col">Reason</th>
<th scope="col">Severity</th>
<th scope="col">Reports</th>
<th scope="col">Author</th>
<th scope="col">Filed</th>
<th scope="col" class="admin-table-actions">Actions</th>
</tr>
</thead>
<tbody>
{% for report in reports %}
<tr>
<td><a href="{{ report['target_url'] }}">{{ report['target_type'] }}</a></td>
<td>{{ report['reason_label'] }}</td>
<td><span class="severity-badge severity-{{ report['severity'] }}">{{ report['severity'] }}</span></td>
<td>{{ report['report_count'] }}</td>
<td>{{ report['owner_name'] or '-' }}</td>
<td>{{ dt_ago(report['created_at']) }}</td>
<td class="admin-table-actions">
<a href="/admin/moderation/{{ report['uid'] }}" class="admin-btn admin-btn-sm">Review</a>
</td>
</tr>
{% else %}
<tr>
<td colspan="7" class="empty-state">No reports in this state.</td>
</tr>
{% endfor %}
</tbody>
</table>
{% set pagination_query = 'status=' ~ status ~ '&' %}{% include "_pagination.html" %}
{% endblock %}
+101
View File
@@ -0,0 +1,101 @@
{% extends "admin_base.html" %}
{% block extra_head %}
{{ super() }}
<link rel="stylesheet" href="{{ static_url('/static/css/moderation.css') }}">
{% endblock %}
{% block admin_content %}
{% macro decision_table(caption, rows, empty) %}
<table class="admin-table">
<caption class="sr-only">{{ caption }}</caption>
<thead>
<tr>
<th scope="col">Action</th>
<th scope="col">Moderator</th>
<th scope="col">Reason</th>
<th scope="col">When</th>
</tr>
</thead>
<tbody>
{% for action in rows %}
<tr>
<td>{{ action['action'].replace('_', ' ') }}</td>
<td>{{ action['actor_name'] }}</td>
<td>{{ action['reason'] or '-' }}</td>
<td>{{ local_dt(action['created_at']) }}</td>
</tr>
{% else %}
{% if empty %}<tr><td colspan="4" class="empty-state">{{ empty }}</td></tr>{% endif %}
{% endfor %}
</tbody>
</table>
{% endmacro %}
<div class="admin-toolbar">
<h2>Report {{ report['reason_label'] }}</h2>
<a href="/admin/moderation" class="admin-btn admin-btn-sm">Back to queue</a>
</div>
<section class="card report-summary">
<dl class="report-facts">
<div><dt>Target</dt><dd><a href="{{ report['target_url'] }}">{{ report['target_type'] }} {{ report['target_uid'] }}</a></dd></div>
<div><dt>Status</dt><dd>{{ report['status'] }}</dd></div>
<div><dt>Severity</dt><dd><span class="severity-badge severity-{{ report['severity'] }}">{{ report['severity'] }}</span></dd></div>
<div><dt>Origin</dt><dd>{{ report['origin'] }}</dd></div>
<div><dt>Reporter</dt><dd>{{ report['reporter_name'] or 'system' }}</dd></div>
<div><dt>Author</dt><dd>{{ report['owner_name'] or '-' }}</dd></div>
<div><dt>Filed</dt><dd>{{ local_dt(report['created_at']) }}</dd></div>
<div><dt>Reports on this target</dt><dd>{{ report['report_count'] }}</dd></div>
</dl>
{% if report['detail'] %}
<p class="report-detail">{{ report['detail'] }}</p>
{% endif %}
{% if report['categories'] %}
<p class="report-categories">Filter categories: {{ report['categories']|join(', ') }}</p>
{% endif %}
</section>
<section class="card report-decision">
<h3>Decide</h3>
{% if not can_remove %}
<p class="report-hint">Content removal is not available for a {{ report['target_type'] }}; act on the account instead.</p>
{% endif %}
<form method="POST" action="/admin/moderation/{{ report['uid'] }}/decide" class="report-form">
<div class="admin-field">
<label for="decision-action">Action</label>
<select id="decision-action" name="action" required>
{% for action in available_actions %}
<option value="{{ action }}">{{ action.replace('_', ' ') }}</option>
{% endfor %}
</select>
</div>
<div class="admin-field">
<label for="decision-reason">Reason shown to the user</label>
<input id="decision-reason" name="reason" maxlength="200" placeholder="Breaches the community guidelines on harassment">
</div>
<div class="admin-field">
<label for="decision-hours">Suspension length (hours)</label>
<input id="decision-hours" name="duration_hours" type="number" min="1" max="8760" value="24">
</div>
<div class="admin-field">
<label for="decision-notes">Internal notes</label>
<textarea id="decision-notes" name="notes" rows="3" maxlength="2000"></textarea>
</div>
<button type="submit" class="btn btn-primary" data-confirm="Apply this moderation decision?">Apply decision</button>
</form>
<form method="POST" action="/admin/moderation/{{ report['uid'] }}/status" class="report-form-inline">
<input type="hidden" name="status" value="acknowledged">
<button type="submit" class="admin-btn admin-btn-sm">Acknowledge</button>
</form>
</section>
<section class="card report-history">
<h3>Decisions on this report</h3>
{{ decision_table("Moderation decisions recorded against this report", actions, "No decision recorded yet.") }}
</section>
{% if history %}
<section class="card report-history">
<h3>Author history</h3>
{{ decision_table("Earlier moderation decisions against this author", history, "") }}
</section>
{% endif %}
{% endblock %}
+84 -1
View File
@@ -21,7 +21,7 @@
<div class="admin-field">
<label for="site_tagline">Tagline</label>
<input type="text" id="site_tagline" name="site_tagline" value="{{ settings.get('site_tagline', 'Track industry shifts. Discover bold releases. Share what you are building in an open, uncensored environment.') }}" maxlength="500">
<input type="text" id="site_tagline" name="site_tagline" value="{{ settings.get('site_tagline', 'Track industry shifts. Discover bold releases. Share what you are building in an open environment built by developers, for developers.') }}" maxlength="500">
</div>
<div class="admin-field">
@@ -119,6 +119,89 @@
<small class="hint-text">Routes every outbound stealth-client request (Devii fetch, DeepSearch, news, attachments, and more) through this proxy. Leave empty to connect directly. Needed only when a destination blocks this server's own IP/network (not its browser fingerprint) - most sites never need this.</small>
</div>
<h3 class="admin-settings-group-title admin-settings-group-title-spaced">Moderation &amp; Safety</h3>
<div class="admin-field">
<label for="moderation_filter_mode">Content Filter Mode</label>
<select id="moderation_filter_mode" name="moderation_filter_mode" class="admin-settings-select">
{% set _mode = settings.get('moderation_filter_mode', 'review') %}
<option value="off"{% if _mode == 'off' %} selected{% endif %}>off - no classification</option>
<option value="label"{% if _mode == 'label' %} selected{% endif %}>label - maturity labels only</option>
<option value="review"{% if _mode == 'review' %} selected{% endif %}>review - publish and raise a report</option>
<option value="block"{% if _mode == 'block' %} selected{% endif %}>block - refuse at creation</option>
</select>
<small class="hint-text">How the classifier acts on a match. <strong>review</strong> is the default: nothing legitimate is ever suppressed by a machine and a human sees every flag. Move to <strong>block</strong> only with evidence from the queue - on a developer platform a blanket block produces false positives on security research and error messages. The sexual and exploitative categories always block, whatever this setting says.</small>
</div>
<div class="admin-field">
<label for="moderation_sla_hours">Moderation Response Window (hours)</label>
<input type="number" id="moderation_sla_hours" name="moderation_sla_hours" value="{{ settings.get('moderation_sla_hours', '24') }}" min="1" max="720" class="admin-settings-num">
<small class="hint-text">The commitment published in the terms and shown on every report acknowledgement. The moderation queue badge turns red once the oldest open report is older than this.</small>
</div>
<div class="admin-field">
<label for="moderation_minimum_age">Minimum Age</label>
<input type="number" id="moderation_minimum_age" name="moderation_minimum_age" value="{{ settings.get('moderation_minimum_age', '16') }}" min="13" max="21" class="admin-settings-num">
<small class="hint-text">Signup is refused below this age. Only the derived age band is stored, never the date of birth.</small>
</div>
<div class="admin-field">
<label for="moderation_mature_default_hidden">Hide Mature Content By Default</label>
<select id="moderation_mature_default_hidden" name="moderation_mature_default_hidden" class="admin-settings-select">
<option value="1"{% if settings.get('moderation_mature_default_hidden', '1') == '1' %} selected{% endif %}>Yes</option>
<option value="0"{% if settings.get('moderation_mature_default_hidden', '1') == '0' %} selected{% endif %}>No</option>
</select>
<small class="hint-text">When on, mature-labelled content renders behind an interstitial until the viewer explicitly opts in. Minor age bands are never offered the reveal for restricted content.</small>
</div>
<div class="admin-field">
<label for="account_deletion_grace_hours">Account Deletion Grace (hours)</label>
<input type="number" id="account_deletion_grace_hours" name="account_deletion_grace_hours" value="{{ settings.get('account_deletion_grace_hours', '24') }}" min="0" max="720" class="admin-settings-num">
<small class="hint-text">How long a deleted account stays restorable from the trash before it is permanently purged. The account is anonymised immediately either way; this only sets when the purge runs.</small>
</div>
<div class="admin-field">
<label for="ai_third_party_provider">Third-party AI Provider Name</label>
<input type="text" id="ai_third_party_provider" name="ai_third_party_provider" value="{{ settings.get('ai_third_party_provider', '') }}" maxlength="120" placeholder="e.g. Acme Model Co.">
<small class="hint-text">Named in the consent copy and the privacy policy, so the user knows who receives their content before they grant consent.</small>
</div>
<h3 class="admin-settings-group-title admin-settings-group-title-spaced">Policy Versions</h3>
<div class="admin-field">
<label for="terms_version">Terms Version</label>
<input type="text" id="terms_version" name="terms_version" value="{{ settings.get('terms_version', '1') }}" maxlength="20" class="admin-settings-num">
<small class="hint-text">Bumping this forces every member to accept the terms again before their next write. Reading and account deletion are never blocked.</small>
</div>
<div class="admin-field">
<label for="privacy_version">Privacy Policy Version</label>
<input type="text" id="privacy_version" name="privacy_version" value="{{ settings.get('privacy_version', '1') }}" maxlength="20" class="admin-settings-num">
</div>
<div class="admin-field">
<label for="guidelines_version">Community Guidelines Version</label>
<input type="text" id="guidelines_version" name="guidelines_version" value="{{ settings.get('guidelines_version', '1') }}" maxlength="20" class="admin-settings-num">
</div>
<h3 class="admin-settings-group-title admin-settings-group-title-spaced">Published Contact Details</h3>
<div class="admin-field">
<label for="contact_email">Contact Email</label>
<input type="text" id="contact_email" name="contact_email" value="{{ settings.get('contact_email', '') }}" maxlength="200" placeholder="support@example.com">
<small class="hint-text">Rendered on /docs/contact.html. Keep it identical to the trader email declared in every app store that carries a DevPlace client.</small>
</div>
<div class="admin-field">
<label for="contact_phone">Contact Phone</label>
<input type="text" id="contact_phone" name="contact_phone" value="{{ settings.get('contact_phone', '') }}" maxlength="60" placeholder="+31 00 000 0000">
</div>
<div class="admin-field">
<label for="contact_address">Contact Address</label>
<textarea id="contact_address" name="contact_address" rows="3" maxlength="500" placeholder="Street, number, postcode, city, country">{{ settings.get('contact_address', '') }}</textarea>
</div>
<h3 class="admin-settings-group-title admin-settings-group-title-spaced">Custom Code</h3>
<div class="admin-field">
+9 -1
View File
@@ -6,7 +6,7 @@
<meta name="theme-color" content="#0f0a1a">
<meta name="asset-version" content="{{ static_version }}">
<title>{% if page_title %}{{ page_title }}{% else %}DevPlace - The Developer Social Network{% endif %}</title>
<meta name="description" content="{% if meta_description %}{{ meta_description }}{% else %}Track industry shifts. Discover bold releases. Share what you're building in an open, uncensored environment.{% endif %}">
<meta name="description" content="{% if meta_description %}{{ meta_description }}{% else %}Track industry shifts. Discover bold releases. Share what you're building in an open environment built by developers, for developers.{% endif %}">
<link rel="canonical" href="{{ canonical_url or request.url }}">
{% if prev_url %}<link rel="prev" href="{{ prev_url }}">{% endif %}
{% if next_url %}<link rel="next" href="{{ next_url }}">{% endif %}
@@ -212,10 +212,18 @@
</footer>
{% endblock %}
{% if user and activity_recording_on(user) %}
<div class="recording-indicator" role="status" aria-live="off" title="Your presence and session activity are recorded while you are signed in">
<span class="recording-dot" aria-hidden="true"></span>
<a href="/profile/{{ user['username'] }}?tab=privacy">Activity recording on</a>
</div>
{% endif %}
{%- set _response_time = response_time_ms(request) -%}
{%- if _response_time %}<div class="response-time-indicator" title="Server response time" aria-hidden="true">{{ _response_time }} ms</div>{% endif -%}
{% if user %}
{% include "_report_dialog.html" %}
<template id="comment-reply-template">
{% set _comment_target_uid = "" %}{% set _comment_target_type = "" %}{% include "_comment_form.html" %}
</template>

Some files were not shown because too many files have changed in this diff Show More