feat(profile): a full identity system — name styles, decorations, effects, nameplates, tag, links, board
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
OpenSSF Scorecard / Scorecard analysis (push) Canceled after 0s

The owner asked for profile customisation that is "completo e FODA", pointing
at Discord's profile editor. This is the whole thing, end to end, with a live
preview.

What a user can now set, and where it shows:

  - Display name style: 10 fonts × 9 effects (solid, gradient, neon, toon,
    pop, gummy, prism, glitch, fire) × two colours. Popout and full profile.
  - Avatar decoration: 10 (ring, neon, flames, stars, halo, vines, pixel,
    hearts, frost, crown). Inline SVG — no image assets, scales to any size.
  - Profile effect over the banner: 9 (sparkles, rain, snow, matrix,
    confetti, aurora, embers, bubbles, hearts). CSS particles with seeded
    positions so re-renders do not change the weather.
  - Nameplate behind the member-list row: 10 presets + custom two-colour.
  - Theme: two colours washed behind the card, and the banner fallback.
  - Pronouns, and a clan-style tag (emoji + 2–4 letters) beside the name,
    also on the member row.
  - Links, up to 5, with the brand icon picked from the host.
  - Board: favourite game, up to 5 in rotation, up to 10 wanted, a quote.
    Games come from Steam search through a small server proxy (cached,
    10 min) or are typed by hand; covers only load from Steam's CDN.
  - Badges, derived rather than stored: instance admin, server owner,
    founder (joined in the space's first month), veteran (a year), OG (six
    months), stylist. Nothing to grant, revoke or migrate.

Storage is one JSON column, users.profile_style (migration 0019), and one
normaliser in shared/profile.ts that both the server (on write and on read)
and the client (on render) run. It never throws: unknown ids fall back,
strings are cut to their limits, colours must be #rrggbb, link URLs must be
https, cover URLs must be a Steam CDN host. Kept out of the federation
durable-field set on purpose — peers on older builds do not know the field,
and it is cosmetic.

The editor is a new Settings > Profile tab. Its preview is the same
ProfileCard component the popout renders, with the draft style swapped in —
what you see while editing is, by construction, what others see.

Fonts are OFL builds self-hosted from public/fonts alongside DM Sans, latin
subsets only, ~120 KB in total.

Verified: web build, 517 web tests (11 new: normaliser bounds and fallbacks,
link icon detection, badge derivation), server typecheck, 804 server tests.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
2026-09-02 23:36:23 -03:00
co-authored by Claude Fable 5.1
parent 872066e5be
commit ef9c73501c
44 changed files with 6441 additions and 116 deletions
@@ -0,0 +1 @@
ALTER TABLE `users` ADD `profile_style` text;
File diff suppressed because it is too large Load Diff
@@ -134,6 +134,13 @@
"when": 1788401450986,
"tag": "0018_eminent_kid_colt",
"breakpoints": true
},
{
"idx": 19,
"version": "6",
"when": 1788402724322,
"tag": "0019_glamorous_crusher_hogan",
"breakpoints": true
}
]
}
+4
View File
@@ -22,6 +22,10 @@ export const users = sqliteTable('users', {
profileUpdatedAt: integer('profile_updated_at'),
passwordChangedAt: integer('password_changed_at'),
showActivity: integer('show_activity').notNull().default(1),
// JSON ProfileStyle (shared/profile.ts), normalised on write. One column,
// not twelve: every field is optional, nothing is queried by value, and the
// shape keeps growing. Null means defaults.
profileStyle: text('profile_style'),
federationRegistryUpdatedAt: integer('federation_registry_updated_at').default(0),
federationHealPending: integer('federation_heal_pending').default(0),
federationHomeOrphaned: integer('federation_home_orphaned').default(0),
+2
View File
@@ -31,6 +31,7 @@ import { searchRoutes } from './routes/search.js';
import { adminRoutes } from './routes/admin.js';
import { gifRoutes } from './routes/gif.js';
import { federationRoutes } from './routes/federation.js';
import { gameRoutes } from './routes/games.js';
import { startFederationWorkers, stopFederationWorkers } from './utils/federationWorker.js';
import { startBackupWorker, stopBackupWorker } from './utils/backupWorker.js';
import './utils/federationRollback.js'; // Side-effect: registers rollback callbacks for outbox terminal failures.
@@ -150,6 +151,7 @@ async function main(): Promise<void> {
await app.register(searchRoutes);
await app.register(adminRoutes);
await app.register(gifRoutes);
await app.register(gameRoutes);
await app.register(federationRoutes);
await app.register(registerWebSocket);
+48
View File
@@ -0,0 +1,48 @@
import type { FastifyInstance } from 'fastify';
import { authenticate } from '../utils/auth.js';
import { steamCoverUrl } from '@backspace/shared/src/profile.js';
/**
* Game lookup for profile widgets, backed by Steam's public store search.
*
* Proxied rather than called from the browser: the store endpoint has no CORS
* headers, and going through here means one place to cache, to bound the
* query, and to swap the source later without touching clients. No key is
* involved — it is the same unauthenticated endpoint the store website uses.
*/
interface SteamSearchItem { id: number; name: string; tiny_image?: string }
interface GameHit { appId: number; name: string; cover: string; header: string }
const CACHE_TTL_MS = 10 * 60 * 1000;
const cache = new Map<string, { at: number; hits: GameHit[] }>();
export async function gameRoutes(app: FastifyInstance): Promise<void> {
app.get<{ Querystring: { q?: string } }>('/api/games/search', { preHandler: authenticate }, async (request, reply) => {
const q = (request.query.q ?? '').trim().slice(0, 60);
if (q.length < 2) return reply.send({ hits: [] });
const key = q.toLowerCase();
const cached = cache.get(key);
if (cached && Date.now() - cached.at < CACHE_TTL_MS) return reply.send({ hits: cached.hits });
try {
const url = `https://store.steampowered.com/api/storesearch/?term=${encodeURIComponent(q)}&l=brazilian&cc=BR`;
const res = await fetch(url, { signal: AbortSignal.timeout(6000), headers: { 'User-Agent': 'Resenhacord/1.0' } });
if (!res.ok) return reply.send({ hits: [] });
const data = (await res.json()) as { items?: SteamSearchItem[] };
const hits: GameHit[] = (data.items ?? []).slice(0, 8).map((it) => ({
appId: it.id,
name: it.name,
cover: steamCoverUrl(it.id),
header: `https://cdn.cloudflare.steamstatic.com/steam/apps/${it.id}/header.jpg`,
}));
cache.set(key, { at: Date.now(), hits });
if (cache.size > 500) cache.delete(cache.keys().next().value!);
return reply.send({ hits });
} catch {
// Upstream hiccup is not the user's problem: an empty list lets them
// type the title by hand instead.
return reply.send({ hits: [] });
}
});
}
+13 -2
View File
@@ -15,6 +15,7 @@ import { config } from '../config.js';
import { buildFederationHeaders, getOurOrigin } from '../utils/federationAuth.js';
import { extractDomain } from './federation.js';
import path from 'path';
import { normalizeProfileStyle } from '@backspace/shared/src/profile.js';
/** Validates that a URL is a safe asset URL (relative upload path, bare filename, or http/https) */
export function isValidAssetUrl(url: string | null | undefined): boolean {
@@ -187,7 +188,7 @@ export async function userRoutes(app: FastifyInstance): Promise<void> {
});
app.patch<{ Body: UpdateUserRequest }>('/api/users/@me', { preHandler: authenticate }, async (request, reply) => {
const { displayName, avatar, banner, accentColor, avatarColor, bio, customStatus, status, replicatedInstances, homeUserId, profileUpdatedAt, discoverable, showActivity } = request.body;
const { displayName, avatar, banner, accentColor, avatarColor, bio, customStatus, status, replicatedInstances, homeUserId, profileUpdatedAt, discoverable, showActivity, profileStyle } = request.body;
const db = getDb();
const updateData: Record<string, string | null | undefined> = {};
@@ -426,12 +427,22 @@ export async function userRoutes(app: FastifyInstance): Promise<void> {
(updateData as Record<string, unknown>).showActivity = showActivity ? 1 : 0;
}
// Profile style is normalised, never rejected: the shared normaliser
// clamps lengths, drops bad URLs and falls back on unknown ids, so the
// stored value is always well-formed. Null resets to defaults. Kept out of
// DURABLE_PROFILE_FIELDS on purpose — peers on older builds do not know
// the field, and it is cosmetic, so it stays local to this instance.
if (profileStyle !== undefined) {
(updateData as Record<string, unknown>).profileStyle =
profileStyle === null ? null : JSON.stringify(normalizeProfileStyle(profileStyle));
}
if (Object.keys(updateData).length === 0) {
return reply.code(400).send({ error: 'No fields to update', statusCode: 400 });
}
// Detect profile changes and stamp timestamp
const hasProfileChange = ['displayName', 'avatar', 'banner', 'accentColor', 'avatarColor', 'bio', 'customStatus'].some(f => f in updateData);
const hasProfileChange = ['displayName', 'avatar', 'banner', 'accentColor', 'avatarColor', 'bio', 'customStatus', 'profileStyle'].some(f => f in updateData);
const hasDurableChange = DURABLE_PROFILE_FIELDS.some(f => f in updateData);
if (hasDurableChange) {
(updateData as Record<string, unknown>).profileUpdatedAt = Date.now();
+8
View File
@@ -1,6 +1,11 @@
import type { User, ReplicatedInstance } from '@backspace/shared';
import { normalizeProfileStyle } from '@backspace/shared/src/profile.js';
import { schema } from '../db/index.js';
function safeParse(json: string): unknown {
try { return JSON.parse(json); } catch { return null; }
}
export function sanitizeUser(row: typeof schema.users.$inferSelect, isSelf = false): User {
// Tombstoned (deleted) users — return anonymized profile
if (row.isDeleted === 1) {
@@ -54,6 +59,9 @@ export function sanitizeUser(row: typeof schema.users.$inferSelect, isSelf = fal
homeInstance: row.homeInstance ?? null,
homeUserId: row.homeUserId ?? null,
replicatedInstances,
// Re-normalised on read as well: a row written by an older build (or a
// stricter future one) still comes out complete and bounded.
profileStyle: row.profileStyle ? normalizeProfileStyle(safeParse(row.profileStyle)) : null,
...(isSelf
? {
showActivity: row.showActivity !== 0,