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
+24
View File
@@ -501,3 +501,27 @@
.game-perks {
margin-top: var(--space-xl);
}
.game-legacy {
margin-top: var(--space-xl);
}
.game-legacy-note {
color: var(--text-muted);
font-size: 0.85rem;
margin: 0 0 var(--space-md);
}
.legacy-card {
border-color: #f5c518;
}
.game-plot-golden {
border-color: #f5c518;
box-shadow: 0 0 0 1px #f5c518 inset, 0 0 12px rgba(245, 197, 24, 0.4);
}
.plot-steal-locked {
color: var(--warning);
font-family: var(--font-mono);
}
+5
View File
@@ -28,6 +28,11 @@
margin: 0 auto;
}
.avatar-regen-btn {
display: block;
margin: 0.5rem auto 0;
}
.profile-name {
font-size: 1.25rem;
font-weight: 700;
+2
View File
@@ -18,6 +18,7 @@ import { ReactionBar } from "./ReactionBar.js";
import { BookmarkManager } from "./BookmarkManager.js";
import { PollManager } from "./PollManager.js";
import { ApiKeyManager } from "./ApiKeyManager.js";
import { AvatarRegenerator } from "./AvatarRegenerator.js";
import { CustomizationToggle } from "./CustomizationToggle.js";
import { NotificationPrefs } from "./NotificationPrefs.js";
import { AiCorrection } from "./AiCorrection.js";
@@ -67,6 +68,7 @@ class Application {
this.bookmarks = new BookmarkManager();
this.polls = new PollManager();
this.apiKey = new ApiKeyManager();
this.avatarRegenerator = new AvatarRegenerator();
this.customizationToggle = new CustomizationToggle();
this.notificationPrefs = new NotificationPrefs(this.pubsub);
this.aiCorrection = new AiCorrection();
+47
View File
@@ -0,0 +1,47 @@
// retoor <retoor@molodetz.nl>
import { Http } from "./Http.js";
import { Toast } from "./Toast.js";
let confirmedThisSession = false;
class AvatarRegenerator {
constructor() {
this.btn = document.querySelector("[data-regenerate-avatar]");
if (!this.btn) return;
this.preview = document.getElementById("profile-avatar-preview");
this.btn.addEventListener("click", () => this.regenerate());
}
async regenerate() {
if (!confirmedThisSession) {
const ok = await window.app.dialog.confirm({
title: "Regenerate avatar",
message: "A new random avatar will be generated. Your current avatar is gone for good and can never be brought back.",
confirmLabel: "Regenerate",
danger: true,
});
if (!ok) return;
confirmedThisSession = true;
}
const username = this.btn.dataset.username;
this.btn.disabled = true;
try {
const result = await Http.postJson(`/profile/${username}/regenerate-avatar`, {});
const url = result && result.data ? result.data.avatar_url : "";
if (!url) {
Toast.flash(this.btn, "Error", 2000, "Regenerate avatar");
return;
}
if (this.preview) this.preview.src = url;
Toast.flash(this.btn, "Regenerated", 2000, "Regenerate avatar");
} catch {
Toast.flash(this.btn, "Error", 2000, "Regenerate avatar");
} finally {
this.btn.disabled = false;
}
}
}
window.AvatarRegenerator = AvatarRegenerator;
export { AvatarRegenerator };
+4 -3
View File
@@ -183,8 +183,10 @@ export class CommentManager {
actions.insertAdjacentElement("afterend", form);
this.enhanceForm(form);
const textarea = form.querySelector("textarea");
if (textarea) textarea.focus();
setTimeout(() => {
const textarea = form.querySelector("textarea");
if (textarea) textarea.focus();
}, 20);
}
enhanceForm(form) {
@@ -192,7 +194,6 @@ export class CommentManager {
if (enhancer) {
enhancer.initEmojiPickers();
enhancer.initMentionInputs();
enhancer.initAttachmentManagers();
}
const textarea = form.querySelector("textarea");
if (textarea) {
+21 -3
View File
@@ -69,6 +69,7 @@ export class GameFarm {
if (this.mode === "own") {
this._setHost("[data-shop-host]", this._shopHtml(farm));
this._setHost("[data-perk-host]", this._perksHtml(farm));
this._setHost("[data-legacy-host]", this._legacyHtml(farm));
this._setHost("[data-daily-host]", this._dailyHtml(farm));
this._setHost("[data-quest-host]", this._questsHtml(farm));
}
@@ -92,6 +93,7 @@ export class GameFarm {
set("[data-hud-harvests]", farm.total_harvests);
set("[data-hud-prestige]", farm.prestige);
set("[data-hud-prestige-mult]", `+${Math.round(farm.prestige_multiplier * 100 - 100)}% coins`);
set("[data-hud-stars]", farm.stars);
const fill = this.root.querySelector("[data-hud-xp-fill]");
if (fill) {
const pct = farm.level_is_max || !farm.level_span ? 100 : Math.floor((100 * farm.level_into) / farm.level_span);
@@ -130,6 +132,17 @@ export class GameFarm {
.join("");
}
_legacyHtml(farm) {
return (farm.legacy || [])
.map((up) => {
const action = up.maxed
? `<span class="perk-maxed">Maxed</span>`
: `<form method="post" action="/game/legacy" data-game-action="legacy"><input type="hidden" name="key" value="${up.key}"><button type="submit" class="btn btn-sm">Buy (${up.cost} &#9733;)</button></form>`;
return `<div class="perk-card legacy-card"><span class="perk-icon" aria-hidden="true">${up.icon}</span><strong class="perk-name">${up.name}</strong><span class="perk-level">Lv ${up.level}/${up.max_level}</span><span class="perk-effect">${up.effect || up.description}</span>${action}</div>`;
})
.join("");
}
_dailyHtml(farm) {
const action = farm.daily_available
? `<form method="post" action="/game/daily" data-game-action="daily"><button type="submit" class="btn btn-sm btn-primary">Claim +${farm.daily_reward}c</button></form>`
@@ -164,7 +177,8 @@ export class GameFarm {
}
_plotHtml(plot, farm) {
const head = `<div class="game-plot game-plot-${plot.state}" data-slot="${plot.slot}" data-state="${plot.state}" data-ready-at="${plot.ready_at}">`;
const golden = plot.is_golden ? " game-plot-golden" : "";
const head = `<div class="game-plot game-plot-${plot.state}${golden}" data-slot="${plot.slot}" data-state="${plot.state}" data-ready-at="${plot.ready_at}">`;
let body = "";
if (plot.state === "empty") {
if (farm.is_owner) {
@@ -188,11 +202,15 @@ export class GameFarm {
body += `<form method="post" action="/game/fertilize" data-game-action="fertilize"><input type="hidden" name="slot" value="${plot.slot}"><button type="submit" class="btn btn-sm">&#9889; Fertilize (${plot.fertilize_cost}c)</button></form>`;
}
} else {
body = `<span class="plot-icon plot-ready-icon" aria-hidden="true">${plot.crop_icon}</span><span class="plot-crop-name">${plot.crop_name}</span>`;
const goldenMark = plot.is_golden ? " &#10024;" : "";
body = `<span class="plot-icon plot-ready-icon" aria-hidden="true">${plot.crop_icon}</span><span class="plot-crop-name">${plot.crop_name}${goldenMark}</span>`;
if (farm.is_owner) {
body += `<form method="post" action="/game/harvest" data-game-action="harvest"><input type="hidden" name="slot" value="${plot.slot}"><button type="submit" class="btn btn-sm btn-primary">Harvest +${plot.reward_coins}c</button></form>`;
const label = plot.is_golden ? "Harvest golden" : "Harvest";
body += `<form method="post" action="/game/harvest" data-game-action="harvest"><input type="hidden" name="slot" value="${plot.slot}"><button type="submit" class="btn btn-sm btn-primary">${label}</button></form>`;
} else if (plot.can_steal) {
body += `<form method="post" action="/game/farm/${farm.owner_username}/steal" data-game-action="steal"><input type="hidden" name="slot" value="${plot.slot}"><button type="submit" class="btn btn-sm btn-danger" data-confirm="Steal this ready build?">Steal +${plot.steal_coins}c</button></form>`;
} else if (plot.steal_reason === "cooldown") {
body += `<span class="plot-ready-label plot-steal-locked">Raid again in ${this._format(plot.steal_cooldown_seconds)}</span>`;
} else {
body += `<span class="plot-ready-label">Ready</span>`;
}