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:
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"]
|
||||
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
)
|
||||
@@ -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"},
|
||||
{
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user