feat: add per-user avatar seed regeneration with irreversible random avatar replacement

Implement a new `avatar_seed` column on the users table that overrides the username-based seed for Multiavatar generation. Introduce a null-safe `avatar_seed(user)` choke point in `avatar.py` that resolves `user.get("avatar_seed") or user.get("username")`, registered as a Jinja global so every render site (`_avatar_link.html`, `avatar_url(...)` calls, SEO `og_image`, issues ad-hoc dicts, devRant payload/PNG) propagates a regenerated seed. Add `POST /profile/{username}/regenerate-avatar` endpoint (owner-or-admin only) that writes a fresh `generate_uid()` to `avatar_seed`, invalidates the target's user cache, and audits `profile.avatar.regenerate`. The previous seed is overwritten and never stored, making regeneration irreversible. Document the feature in `AGENTS.md` and `README.md`, add the API endpoint to `docs_api.py`, and include the `regenerate_avatar` Devii tool in `CONFIRM_REQUIRED`.
This commit is contained in:
2026-06-27 22:31:34 +00:00
parent e773067106
commit 6f340a6818
60 changed files with 1626 additions and 97 deletions
+3 -1
View File
@@ -8,7 +8,7 @@ from fastapi.responses import JSONResponse, RedirectResponse
from devplacepy import stealth
from devplacepy.constants import DEVII_GUEST_COOKIE
from devplacepy.database import get_int_setting
from devplacepy.database import get_int_setting, mark_notifications_read_by_target
from devplacepy.seo import base_seo_context, site_url
from devplacepy.services.manager import service_manager
@@ -77,6 +77,8 @@ def _owner_from_request(request: Request):
@router.get("/")
async def devii_page(request: Request):
user = get_current_user(request)
if user:
mark_notifications_read_by_target(user["uid"], "/devii")
seo_ctx = base_seo_context(
request,
title="Devii",
+11
View File
@@ -6,11 +6,13 @@ from fastapi import APIRouter, Form, Request
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
from devplacepy.models import (
GameLegacyForm,
GamePerkForm,
GamePlantForm,
GameQuestForm,
GameSlotForm,
)
from devplacepy.database import mark_notifications_read_by_target
from devplacepy.responses import json_error, respond, wants_json
from devplacepy.schemas import GameLeaderboardOut, GameStateOut
from devplacepy.services.game import GameError, store
@@ -24,6 +26,7 @@ router = APIRouter()
@router.get("", response_class=HTMLResponse)
async def game_home(request: Request):
user = require_user(request)
mark_notifications_read_by_target(user["uid"], "/game")
farm = state_payload(user)
seo_ctx = game_seo(
request,
@@ -130,6 +133,14 @@ async def game_prestige(request: Request):
return await _respond_action(request, user, lambda: store.prestige(user))
@router.post("/legacy")
async def game_legacy(request: Request, data: Annotated[GameLegacyForm, Form()]):
user = require_user(request)
return await _respond_action(
request, user, lambda: store.upgrade_legacy(user, data.key)
)
@router.post("/quests/claim")
async def game_claim_quest(request: Request, data: Annotated[GameQuestForm, Form()]):
user = require_user(request)
+6
View File
@@ -12,6 +12,8 @@ from devplacepy.database import (
get_recent_comments_by_target_uids,
paginate,
text_search_clause,
resolve_object_url,
mark_notifications_read_by_target,
)
from devplacepy.content import (
load_detail,
@@ -153,6 +155,10 @@ async def gist_detail(request: Request, gist_slug: str):
redirect = canonical_redirect("gists", gist, gist_slug)
if redirect:
return redirect
if user:
mark_notifications_read_by_target(
user["uid"], resolve_object_url("gist", gist["uid"])
)
base = site_url(request)
seo_ctx = base_seo_context(
+1 -1
View File
@@ -21,7 +21,7 @@ def author_fields(author_uid: str | None, users_map: dict, fallback_login: str)
return {
"author_username": user["username"],
"author_uid": author_uid,
"author_avatar_seed": user["username"],
"author_avatar_seed": user.get("avatar_seed") or user["username"],
"is_local_author": True,
}
return {
+9 -1
View File
@@ -6,7 +6,12 @@ from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse
from devplacepy.attachments import get_attachments, get_attachments_batch
from devplacepy.database import build_pagination, get_users_by_uids, get_blocked_uids
from devplacepy.database import (
build_pagination,
get_users_by_uids,
get_blocked_uids,
mark_notifications_read_by_target,
)
from devplacepy.responses import respond
from devplacepy.schemas import IssueDetailOut, IssuesOut
from devplacepy.services.gitea import runtime, store
@@ -101,6 +106,9 @@ async def issue_detail(request: Request, number: int):
logger.warning("Could not load comments for issue #%s: %s", number, exc)
comments = []
if user:
mark_notifications_read_by_target(user["uid"], f"/issues?highlight={number}")
author_uid = store.author_uid_for_issue(number)
comment_authors = store.comment_author_map(
[int(comment.get("id", 0)) for comment in comments]
+4
View File
@@ -12,6 +12,7 @@ from devplacepy.database import (
get_users_by_uids,
search_users_by_username,
get_blocked_uids,
mark_notifications_read_by_target,
)
from devplacepy.attachments import get_attachments_batch
from devplacepy.templating import clear_messages_cache
@@ -149,6 +150,9 @@ async def messages_page(request: Request, with_uid: str = None, search: str = ""
if with_uid:
messages, other_user = get_conversation_messages(user["uid"], with_uid)
mark_conversation_read(user["uid"], with_uid)
mark_notifications_read_by_target(
user["uid"], f"/messages?with_uid={with_uid}"
)
current_conversation = with_uid
other_online = message_hub.is_online(with_uid)
other_last_seen = message_hub.last_seen(with_uid)
+6
View File
@@ -13,6 +13,8 @@ from devplacepy.database import (
get_recent_comments_by_target_uids,
get_user_bookmarks,
paginate,
resolve_object_url,
mark_notifications_read_by_target,
)
from devplacepy.utils import get_current_user, time_ago, not_found
from devplacepy.content import canonical_redirect
@@ -104,6 +106,10 @@ async def news_detail_page(request: Request, news_slug: str):
redirect = canonical_redirect("news", article, news_slug)
if redirect:
return redirect
if user:
mark_notifications_read_by_target(
user["uid"], resolve_object_url("news", article["uid"])
)
image_url = article.get("image_url", "") or ""
if not image_url and "news_images" in db.tables:
+11 -1
View File
@@ -6,7 +6,13 @@ from datetime import datetime, timezone
from fastapi import Depends, APIRouter, Request
from fastapi.responses import HTMLResponse
from devplacepy.constants import TOPICS
from devplacepy.database import db, get_table, resolve_by_slug
from devplacepy.database import (
db,
get_table,
resolve_by_slug,
resolve_object_url,
mark_notifications_read_by_target,
)
from devplacepy.utils import (
get_current_user,
require_user,
@@ -138,6 +144,10 @@ async def view_post(request: Request, post_slug: str):
redirect = canonical_redirect("posts", post, post_slug)
if redirect:
return redirect
if user:
mark_notifications_read_by_target(
user["uid"], resolve_object_url("post", post["uid"])
)
author = detail["author"]
top_level = detail["comments"]
+2
View File
@@ -5,6 +5,7 @@ from fastapi import APIRouter
from devplacepy.routers.profile import (
ai_correction,
ai_modifier,
avatar,
customization,
index,
notifications,
@@ -18,6 +19,7 @@ router.include_router(customization.router)
router.include_router(notifications.router)
router.include_router(ai_correction.router)
router.include_router(ai_modifier.router)
router.include_router(avatar.router)
router.include_router(telegram.router)
__all__ = ["router", "_ai_quota"]
+46
View File
@@ -0,0 +1,46 @@
# retoor <retoor@molodetz.nl>
import logging
from fastapi import APIRouter, Request
from devplacepy.avatar import avatar_url
from devplacepy.database import get_table
from devplacepy.responses import action_result
from devplacepy.utils import clear_user_cache, generate_uid
from devplacepy.services.audit import record as audit
from devplacepy.routers.profile._shared import resolve_customization_target
logger = logging.getLogger(__name__)
router = APIRouter()
@router.post("/{username}/regenerate-avatar")
async def regenerate_avatar(request: Request, username: str):
target, denied = resolve_customization_target(request, username)
if denied is not None:
return denied
seed = generate_uid()
get_table("users").update({"uid": target["uid"], "avatar_seed": seed}, ["uid"])
clear_user_cache(target["uid"])
logger.info(f"Avatar regenerated for {target['username']}")
audit.record(
request,
"profile.avatar.regenerate",
target_type="user",
target_uid=target["uid"],
target_label=target["username"],
new_value=seed,
summary=f"regenerated avatar for {target['username']}",
links=[audit.target("user", target["uid"], target["username"])],
)
url = f"/profile/{target['username']}"
return action_result(
request,
url,
data={
"url": url,
"avatar_seed": seed,
"avatar_url": avatar_url("multiavatar", seed, 80),
},
)
+7 -2
View File
@@ -25,6 +25,7 @@ from devplacepy.database import (
get_user_relations,
get_user_media,
search_users_by_username,
mark_notifications_read_by_target,
)
from devplacepy.content import can_view_project, enrich_items
from devplacepy.utils import (
@@ -40,7 +41,7 @@ from devplacepy.utils import (
)
from devplacepy.responses import respond, action_result
from devplacepy.schemas import ProfileOut
from devplacepy.avatar import avatar_url
from devplacepy.avatar import avatar_url, avatar_seed
from devplacepy.seo import (
base_seo_context,
site_url,
@@ -120,6 +121,10 @@ async def profile_page(
profile_user = users.find_one(username=username)
if not profile_user:
raise not_found("Profile not found")
if current_user:
mark_notifications_read_by_target(
current_user["uid"], f"/profile/{profile_user['username']}"
)
profile_user["stars"] = get_user_stars(profile_user["uid"])
rank = get_user_rank(profile_user["uid"])
follow_counts = get_follow_counts(profile_user["uid"])
@@ -321,7 +326,7 @@ async def profile_page(
description=desc,
robots=robots,
og_type="profile",
og_image=avatar_url("multiavatar", profile_user["username"], 256),
og_image=avatar_url("multiavatar", avatar_seed(profile_user), 256),
breadcrumbs=[
{"name": "Home", "url": "/feed"},
{
+6
View File
@@ -16,6 +16,8 @@ from devplacepy.database import (
paginate,
text_search_clause,
resolve_by_slug,
resolve_object_url,
mark_notifications_read_by_target,
get_fork_parent,
count_forks,
get_top_authors,
@@ -178,6 +180,10 @@ async def project_detail(request: Request, project_slug: str):
redirect = canonical_redirect("projects", project, project_slug)
if redirect:
return redirect
if user:
mark_notifications_read_by_target(
user["uid"], resolve_object_url("project", project["uid"])
)
base = site_url(request)
robots = "noindex,nofollow" if project.get("is_private") else "index,follow"