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