feat: latin-only slugs, server-side avatar generation, remove unused User fields

This commit is contained in:
Kirill
2026-05-22 19:32:30 +05:00
parent 02c7d7ba36
commit 20e4b1e0ab
9 changed files with 48 additions and 24 deletions
-3
View File
@@ -7,9 +7,6 @@ export type AuthUser = {
id: string id: string
email: string email: string
displayName?: string | null displayName?: string | null
firstName?: string | null
lastName?: string | null
gender?: string | null
avatar?: string | null avatar?: string | null
avatarStyle?: string | null avatarStyle?: string | null
isAdmin?: boolean isAdmin?: boolean
@@ -0,0 +1,27 @@
/*
Warnings:
- You are about to drop the column `firstName` on the `User` table. All the data in the column will be lost.
- You are about to drop the column `gender` on the `User` table. All the data in the column will be lost.
- You are about to drop the column `lastName` on the `User` table. All the data in the column will be lost.
*/
-- RedefineTables
PRAGMA defer_foreign_keys=ON;
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_User" (
"id" TEXT NOT NULL PRIMARY KEY,
"email" TEXT NOT NULL,
"displayName" TEXT,
"avatar" TEXT,
"avatarStyle" TEXT,
"passwordHash" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
INSERT INTO "new_User" ("avatar", "avatarStyle", "createdAt", "displayName", "email", "id", "passwordHash", "updatedAt") SELECT "avatar", "avatarStyle", "createdAt", "displayName", "email", "id", "passwordHash", "updatedAt" FROM "User";
DROP TABLE "User";
ALTER TABLE "new_User" RENAME TO "User";
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
PRAGMA foreign_keys=ON;
PRAGMA defer_foreign_keys=OFF;
-3
View File
@@ -78,9 +78,6 @@ model User {
id String @id @default(cuid()) id String @id @default(cuid())
email String @unique email String @unique
displayName String? displayName String?
firstName String?
lastName String?
gender String?
avatar String? avatar String?
avatarStyle String? avatarStyle String?
passwordHash String? passwordHash String?
+3 -1
View File
@@ -1,4 +1,5 @@
import { normalizeEmail } from './auth.js' import { normalizeEmail } from './auth.js'
import { generateAvatar } from './generate-avatar.js'
import { prisma } from './prisma.js' import { prisma } from './prisma.js'
export async function ensureAdminUser() { export async function ensureAdminUser() {
@@ -8,10 +9,11 @@ export async function ensureAdminUser() {
throw new Error('ADMIN_EMAIL должен быть валидным email') throw new Error('ADMIN_EMAIL должен быть валидным email')
} }
const avatarUri = await generateAvatar(adminEmail)
await prisma.user.upsert({ await prisma.user.upsert({
where: { email: adminEmail }, where: { email: adminEmail },
update: {}, update: {},
create: { email: adminEmail }, create: { email: adminEmail, avatar: avatarUri, avatarStyle: 'avataaars' },
}) })
// Ensure admin notification settings exist // Ensure admin notification settings exist
+9
View File
@@ -0,0 +1,9 @@
import { createAvatar } from '@dicebear/core'
import { avataaars } from '@dicebear/collection'
const DEFAULT_STYLE = avataaars
export async function generateAvatar(seed) {
const avatar = createAvatar(DEFAULT_STYLE, { seed: String(seed) })
return avatar.toDataUri()
}
@@ -2,22 +2,16 @@ import { describe, it, expect } from 'vitest'
import { prisma } from '../../lib/prisma.js' import { prisma } from '../../lib/prisma.js'
describe('OAuth — User model fields', () => { describe('OAuth — User model fields', () => {
it('stores displayName, firstName, lastName, gender, avatar fields on User model', async () => { it('stores displayName and avatar fields on User model', async () => {
const user = await prisma.user.create({ const user = await prisma.user.create({
data: { data: {
email: 'test-oauth@example.com', email: 'test-oauth@example.com',
displayName: 'Test User', displayName: 'Test User',
firstName: 'Test',
lastName: 'User',
gender: 'male',
avatar: 'https://example.com/avatar.jpg', avatar: 'https://example.com/avatar.jpg',
}, },
}) })
expect(user.displayName).toBe('Test User') expect(user.displayName).toBe('Test User')
expect(user.firstName).toBe('Test')
expect(user.lastName).toBe('User')
expect(user.gender).toBe('male')
expect(user.avatar).toBe('https://example.com/avatar.jpg') expect(user.avatar).toBe('https://example.com/avatar.jpg')
await prisma.user.delete({ where: { id: user.id } }) await prisma.user.delete({ where: { id: user.id } })
@@ -31,9 +25,6 @@ describe('OAuth — User model fields', () => {
}) })
expect(user.displayName).toBeNull() expect(user.displayName).toBeNull()
expect(user.firstName).toBeNull()
expect(user.lastName).toBeNull()
expect(user.gender).toBeNull()
expect(user.avatar).toBeNull() expect(user.avatar).toBeNull()
await prisma.user.delete({ where: { id: user.id } }) await prisma.user.delete({ where: { id: user.id } })
+1 -1
View File
@@ -5,7 +5,7 @@ export function slugify(input) {
.toLowerCase() .toLowerCase()
.trim() .trim()
.replace(/\s+/g, '-') .replace(/\s+/g, '-')
.replace(/[^a-z0-9-а-яё]/gi, '') .replace(/[^a-z0-9-]/gi, '')
} }
export function safeExtFromFilename(filename) { export function safeExtFromFilename(filename) {
+5 -5
View File
@@ -8,6 +8,7 @@ import {
validatePassword, validatePassword,
verifyEmailCode, verifyEmailCode,
} from '../lib/auth.js' } from '../lib/auth.js'
import { generateAvatar } from '../lib/generate-avatar.js'
import { prisma } from '../lib/prisma.js' import { prisma } from '../lib/prisma.js'
import { checkLoginRateLimit } from '../lib/rate-limit.js' import { checkLoginRateLimit } from '../lib/rate-limit.js'
@@ -18,9 +19,6 @@ export function mapUserForClient(user) {
id: user.id, id: user.id,
email: user.email, email: user.email,
displayName: user.displayName, displayName: user.displayName,
firstName: user.firstName,
lastName: user.lastName,
gender: user.gender,
avatar: user.avatar, avatar: user.avatar,
avatarStyle: user.avatarStyle, avatarStyle: user.avatarStyle,
isAdmin: Boolean(adminEmail) && userEmail === adminEmail, isAdmin: Boolean(adminEmail) && userEmail === adminEmail,
@@ -55,10 +53,11 @@ export async function registerAuthRoutes(fastify) {
const ok = await verifyEmailCode({ email, purpose: 'login', code }) const ok = await verifyEmailCode({ email, purpose: 'login', code })
if (!ok) return reply.code(401).send({ error: 'Неверный или истёкший код' }) if (!ok) return reply.code(401).send({ error: 'Неверный или истёкший код' })
const avatarUri = await generateAvatar(email)
const user = await prisma.user.upsert({ const user = await prisma.user.upsert({
where: { email }, where: { email },
update: {}, update: {},
create: { email }, create: { email, avatar: avatarUri, avatarStyle: 'avataaars' },
}) })
// Ensure notification preference exists // Ensure notification preference exists
@@ -88,12 +87,13 @@ export async function registerAuthRoutes(fastify) {
if (exists) return reply.code(409).send({ error: 'Эта почта уже зарегистрирована' }) if (exists) return reply.code(409).send({ error: 'Эта почта уже зарегистрирована' })
const passwordHash = await hashPassword(password) const passwordHash = await hashPassword(password)
const avatarUri = await generateAvatar(email)
const user = await prisma.user.create({ const user = await prisma.user.create({
data: { data: {
email, email,
passwordHash, passwordHash,
displayName: displayName || null, displayName: displayName || null,
avatar: null, avatar: avatarUri,
avatarStyle: 'avataaars', avatarStyle: 'avataaars',
}, },
}) })
+2 -1
View File
@@ -1,4 +1,5 @@
import { normalizeEmail } from '../lib/auth.js' import { normalizeEmail } from '../lib/auth.js'
import { generateAvatar } from '../lib/generate-avatar.js'
import { prisma } from '../lib/prisma.js' import { prisma } from '../lib/prisma.js'
function clientRedirect(fastify, reply, token) { function clientRedirect(fastify, reply, token) {
@@ -57,7 +58,7 @@ async function findOrCreateUserFromOAuth({ provider, providerUserId, accessToken
data: { data: {
email: norm, email: norm,
displayName: norm.split('@')[0], displayName: norm.split('@')[0],
avatar: null, avatar: await generateAvatar(norm),
avatarStyle: 'avataaars', avatarStyle: 'avataaars',
}, },
}) })