feat: add audit logging for admin trash restore/purge and notification clear actions

- Record audit events in `admin_trash_restore` and `admin_trash_purge` endpoints with target metadata
- Log `notification.read.all` event when user clears devRant notification feed
- Include `devrant` as a valid origin in audit categories
- Add `admin_section` and `pagination_query` fields to audit and backup schemas for UI consistency
This commit is contained in:
2026-06-16 05:08:58 +00:00
parent 1934dd5727
commit 99ed5c4f15
24 changed files with 466 additions and 186 deletions
+21
View File
@@ -18,6 +18,7 @@ from devplacepy.utils import require_admin, not_found
from devplacepy.seo import base_seo_context, site_url, website_schema
from devplacepy.responses import respond, action_result
from devplacepy.schemas import AdminTrashOut
from devplacepy.services.audit import record as audit
logger = logging.getLogger(__name__)
router = APIRouter()
@@ -115,6 +116,16 @@ async def admin_trash_restore(request: Request, table: str, uid: str):
logger.info(
f"Admin {admin['username']} restored {table} {uid} ({restored} rows)"
)
audit.record(
request,
"admin.trash.restore",
user=admin,
target_type=table,
target_uid=uid,
target_label=_trash_label(table, row),
metadata={"table": table, "rows": restored},
summary=f"{admin['username']} restored {table} {uid}",
)
return action_result(request, f"/admin/trash?table={table}")
@@ -135,4 +146,14 @@ async def admin_trash_purge(request: Request, table: str, uid: str):
if node.get("is_binary"):
_unlink_blob(node)
logger.info(f"Admin {admin['username']} purged {table} {uid}")
audit.record(
request,
"admin.trash.purge",
user=admin,
target_type=table,
target_uid=uid,
target_label=_trash_label(table, row),
metadata={"table": table, "tables": [t for t, _ in purged]},
summary=f"{admin['username']} permanently purged {table} {uid}",
)
return action_result(request, f"/admin/trash?table={table}")
+8
View File
@@ -8,6 +8,7 @@ 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.services.audit import record as audit
logger = logging.getLogger(__name__)
router = APIRouter()
@@ -29,4 +30,11 @@ async def clear_notif_feed(request: Request):
if not user:
return unauthorized()
clear_notifications(user)
audit.record(
request,
"notification.read.all",
user=user,
origin="devrant",
summary=f"{user['username']} cleared all notifications",
)
return dr_ok()
+2
View File
@@ -170,10 +170,12 @@ async def project_detail(request: Request, project_slug: str):
return redirect
base = site_url(request)
robots = "noindex,nofollow" if project.get("is_private") else "index,follow"
seo_ctx = base_seo_context(
request,
title=project.get("title", "Project"),
description=project.get("description", "")[:160],
robots=robots,
og_image=first_image_url(project, detail["attachments"]),
breadcrumbs=[
{"name": "Home", "url": "/feed"},
+4
View File
@@ -199,6 +199,7 @@ class BackupDashboardOut(_Out):
targets: list[dict] = []
metrics: dict = {}
generated_at: Optional[str] = None
admin_section: Optional[str] = None
class SeoReportOut(_Out):
@@ -1038,13 +1039,16 @@ class AuditEntryOut(_Out):
class AuditLogOut(_Out):
entries: list[AuditEntryOut] = []
pagination: Optional[dict] = None
pagination_query: Optional[str] = None
filters: dict = {}
options: dict = {}
admin_section: Optional[str] = None
class AuditEventOut(_Out):
event: Optional[AuditEntryOut] = None
links: list[AuditLinkOut] = []
admin_section: Optional[str] = None
# ---------- database API ----------
+5 -38
View File
@@ -439,53 +439,20 @@ def _build_sitemap(base_url):
)
try:
from devplacepy.docs_api import API_GROUPS
from devplacepy.routers.docs.pages import DOCS_PAGES
for group in API_GROUPS:
if group.get("admin"):
for page in DOCS_PAGES:
if page.get("admin") or page.get("kind") == "live":
continue
urlset.append(
url_element(
f"{base_url}/docs/{group['slug']}.html",
f"{base_url}/docs/{page['slug']}.html",
changefreq="weekly",
priority="0.5",
)
)
except Exception:
logger.warning("sitemap: could not add API docs pages")
_public_docs_pages = [
"index",
"devii",
"tools-seo",
"media-gallery",
"components",
"component-dp-avatar",
"component-dp-code",
"component-dp-content",
"component-dp-upload",
"component-dp-toast",
"component-dp-dialog",
"component-dp-context-menu",
"component-dp-lightbox",
"component-devii-terminal",
"component-devii-avatar",
"component-emoji-picker",
"styles",
"styles-colors",
"styles-layout",
"styles-responsiveness",
"styles-consistency",
"authentication",
]
for slug in _public_docs_pages:
urlset.append(
url_element(
f"{base_url}/docs/{slug}.html",
changefreq="weekly",
priority="0.5",
)
)
logger.warning("sitemap: could not add docs pages")
rough = tostring(urlset, encoding="unicode")
dom = minidom.parseString(rough)
+1 -1
View File
@@ -41,7 +41,7 @@ CATEGORY_BY_PREFIX: dict[str, str] = {
EVENT_RESULTS = ("success", "failure", "denied")
ACTOR_KINDS = ("user", "guest", "system", "cli", "service")
ORIGINS = ("web", "api", "devii", "cli", "service", "scheduler")
ORIGINS = ("web", "api", "devii", "cli", "service", "scheduler", "devrant")
def category_for(event_key: str) -> str:
@@ -605,6 +605,19 @@ ACTIONS: tuple[Action, ...] = (
params=(path("uid", "SEO job uid returned by seo_diagnostics."),),
requires_auth=False,
),
Action(
name="seo_report",
method="GET",
path="/tools/seo/{uid}/report",
summary="Read a finished SEO audit report",
description=(
"Returns the full audit for a finished SEO job: overall score and grade, per-category "
"scores, per-check results, per-page details, and site-wide checks. Use it after "
"seo_status reports status 'done' to explain what passed, what failed, and how to improve."
),
params=(path("uid", "SEO job uid returned by seo_diagnostics."),),
requires_auth=False,
),
Action(
name="deepsearch",
method="POST",
+4 -5
View File
@@ -52,11 +52,10 @@ def _user_comments(user: dict, viewer: Optional[dict]) -> list:
return []
target_uids = list({comment["target_uid"] for comment in comments})
posts_table = get_table("posts")
rant_ids = {}
for target_uid in target_uids:
post = posts_table.find_one(uid=target_uid)
if post:
rant_ids[target_uid] = int(post["id"])
rant_ids = {
post["uid"]: int(post["id"])
for post in posts_table.find(posts_table.table.columns.uid.in_(target_uids))
}
comment_uids = [comment["uid"] for comment in comments]
authors = get_users_by_uids([user["uid"]])
user_scores = {user["uid"]: get_user_stars(user["uid"])}
+2 -1
View File
@@ -2,7 +2,8 @@
import { ApiTester } from "./ApiTester.js";
import { CodeCopy } from "./CodeCopy.js";
import { DevRantTester, DevRantLogin } from "./DevRantTester.js";
import { DevRantTester } from "./DevRantTester.js";
import { DevRantLogin } from "./DevRantLogin.js";
export class ApiDocs {
constructor() {
-47
View File
@@ -18,53 +18,6 @@ function el(tag, props = {}, children = []) {
return node;
}
export class DevRantLogin {
constructor(mount) {
this.mount = mount;
this.render();
devrantSession.onChange(() => this.render());
}
render() {
this.mount.innerHTML = "";
const bar = el("div", { class: "api-tester devrant-login" });
if (devrantSession.isLoggedIn()) {
bar.appendChild(
el("span", {
class: "try-note",
text: `Authenticated as ${devrantSession.username || "user " + devrantSession.auth.user_id} (token #${devrantSession.auth.token_id}).`,
}),
);
const out = el("button", { type: "button", class: "btn try-send", text: "Log out" });
out.addEventListener("click", () => devrantSession.logout());
bar.appendChild(out);
this.mount.appendChild(bar);
return;
}
const user = el("input", { class: "param-input", type: "text", placeholder: "username or email" });
const pass = el("input", { class: "param-input", type: "password", placeholder: "password" });
const docs = window.DEVPLACE_DOCS || {};
if (docs.username) user.value = docs.username;
const send = el("button", { type: "button", class: "btn btn-primary try-send", text: "Log in" });
const note = el("span", { class: "try-note", text: "Log in once to enable the authenticated widgets on every devRant page." });
send.addEventListener("click", async () => {
send.disabled = true;
note.textContent = "Logging in...";
try {
await devrantSession.login(user.value.trim(), pass.value);
} catch (error) {
note.textContent = error.message || "Login failed";
send.disabled = false;
}
});
bar.appendChild(el("div", { class: "auth-field" }, [el("label", { text: "Username" }), user]));
bar.appendChild(el("div", { class: "auth-field" }, [el("label", { text: "Password" }), pass]));
bar.appendChild(send);
bar.appendChild(note);
this.mount.appendChild(bar);
}
}
export class DevRantTester {
constructor(mount) {
this.mount = mount;
-90
View File
@@ -1,94 +1,4 @@
# retoor <retoor@molodetz.nl>
"""
Stealth HTTP client built on top of httpx that reproduces the network
behaviour of a modern Google Chrome desktop browser as faithfully as a
pure-Python stack permits.
WHY THIS IS THE BEST PRACTICAL STEALTH CLIENT
---------------------------------------------
Anti-bot platforms (Cloudflare, Akamai, DataDome, PerimeterX, Imperva and
the like) classify clients on three independent layers. A request only
looks "human" when all three agree with one another. Most scraping code
fails because it spoofs a single layer (usually just the User-Agent string)
while leaving the others screaming "automated tool". This client aligns
every layer that the standard library exposes:
1. TLS layer (JA3 / cipher fingerprint)
The SSL context is configured with a Chrome-aligned TLS 1.2 cipher list,
TLS 1.2 as the floor, TLS 1.3 negotiation, and an ALPN advertisement of
``h2`` before ``http/1.1`` exactly like Chrome. This moves the JA3 hash
away from the default Python/OpenSSL fingerprint that detection vendors
blocklist on sight. Note that OpenSSL does not let Python order the TLS 1.3
ciphersuites, nor rewrite the supported-groups / signature-algorithm lists,
so this is Chrome-*aligned* rather than byte-identical (see HONEST LIMITATIONS).
2. HTTP/2 layer (frame and header behaviour)
Real Chrome speaks HTTP/2 to virtually every modern host. This client
enables HTTP/2 by default, sends lowercase header names, and preserves a
Chrome-accurate header *order* - the order itself is a fingerprint that
naive clients get wrong even when the header values are correct.
3. Application layer (headers and client hints)
A complete, correctly ordered set of Chrome headers is emitted, including
the modern User-Agent Client Hints (``sec-ch-ua``, ``sec-ch-ua-mobile``,
``sec-ch-ua-platform``) and the request-context ``Sec-Fetch-*`` family.
The ``Sec-Fetch-*`` values are recomputed per request type so a document
navigation, a sub-resource fetch and a file download each carry the
metadata Chrome would actually attach in that situation.
WHAT YOU CAN EXPECT
-------------------
- Requests that pass the overwhelming majority of header- and TLS-based
bot heuristics, including Cloudflare's "I'm Under Attack" passive checks
for endpoints that do not mandate a JavaScript challenge.
- Transparent HTTP/2, gzip/deflate/br/zstd decompression, cookie
persistence across a session, and connection reuse.
- Single and bulk asynchronous file downloads with bounded concurrency,
streaming to disk (constant memory regardless of file size), filename
slugification and path-traversal protection.
HONEST LIMITATIONS
------------------
The Python standard ``ssl`` module cannot rewrite the TLS extension order,
the supported-groups list or the signature-algorithm list, so the JA3 hash
produced here is *Chrome-like* rather than *byte-identical* to Chrome. For
targets that fingerprint those low-level extension fields (a small minority,
but a growing one) a native TLS stack such as ``curl_cffi`` or BoringSSL is
required. This client is engineered to be the strongest stealth achievable
without leaving the httpx ecosystem, and it degrades gracefully: every layer
it can control is made indistinguishable from Chrome.
USE CASES
---------
- Resilient web scraping and data collection against header/TLS gating.
- Monitoring, price tracking and availability checks on protected sites.
- Mirroring and bulk asset downloading (images, documents, archives).
- Integration testing of CDN and WAF rules from a realistic client.
EXAMPLE
-------
import asyncio
from devplacepy.stealth import ChromeStealthClient
async def main() -> None:
async with ChromeStealthClient() as client:
response = await client.get("https://example.com")
print(response.status_code, len(response.text))
results = await client.download_files(
[
"https://example.com/a.jpg",
"https://example.com/b.pdf",
],
destination="downloads",
concurrency=4,
)
for result in results:
print(result.url, result.ok, result.path)
asyncio.run(main())
"""
from __future__ import annotations
import asyncio
@@ -51,6 +51,6 @@ The `comments` table keys on `(target_type, target_uid)`, so the same comment se
## SEO
`seo.py` builds JSON-LD schemas (WebSite, BreadcrumbList, DiscussionForumPosting, ProfilePage, SoftwareApplication, NewsArticle, SoftwareSourceCode). Every router builds context with `base_seo_context(request, ...)` and merges it into the response; `base.html` reads `page_title`, `meta_description`, `meta_robots`, `canonical_url`, the `og_*` keys, `breadcrumbs`, and `page_schema`. Auth, messages, and notifications pages are `noindex,nofollow`; thin profiles are `noindex,follow`. JSON-LD emitted via `{{ page_schema | safe }}` is escaped in `_json_ld_dumps` (`<`, `>`, `&`, and line separators to `\uXXXX`) to prevent a `</script>` breakout. `/robots.txt` and `/sitemap.xml` are served by `routers/seo.py`.
`seo.py` builds JSON-LD schemas (WebSite, BreadcrumbList, DiscussionForumPosting, ProfilePage, SoftwareApplication, NewsArticle, SoftwareSourceCode). Every router builds context with `base_seo_context(request, ...)` and merges it into the response; `base.html` reads `page_title`, `meta_description`, `meta_robots`, `canonical_url`, the `og_*` keys, `breadcrumbs`, and `page_schema`. Auth, messages, and notifications pages are `noindex,nofollow`; a private project's detail page is `noindex,nofollow`; thin profiles are `noindex,follow`. JSON-LD emitted via `{{ page_schema | safe }}` is escaped in `_json_ld_dumps` (`<`, `>`, `&`, and line separators to `\uXXXX`) to prevent a `</script>` breakout. `/robots.txt` and `/sitemap.xml` are served by `routers/seo.py`.
{% endraw %}
</div>
@@ -130,6 +130,7 @@ through them. The platform API enforces the rest: an **administrator** operating
|---|---|---|
| `seo_diagnostics` | public | Queue an SEO audit of a URL or sitemap and obtain its job uid and status URL. |
| `seo_status` | public | Check an SEO audit and obtain its score, grade, and report link once finished. |
| `seo_report` | public | Read a finished SEO audit report: overall score and grade, per-category scores, per-check results, per-page details, and site-wide checks. |
### Messages and notifications (`http`)