feat: Phase 1 — Fastify backend with auth, users, admin API
Backend stack: - Fastify v5 with JWT auth, CORS, cookies, rate limiting - PostgreSQL via Drizzle ORM with full schema: users, presets, game_progress, shared_patches, likes, refresh_tokens - Argon2 password hashing, httpOnly refresh cookie rotation API endpoints: - POST /api/v1/auth/register|login|refresh|logout - GET|PATCH /api/v1/users/me (profile) - GET /api/v1/admin/stats (dashboard KPIs) - GET|PATCH /api/v1/admin/users (list, role change, ban) - GET|PATCH /api/v1/admin/patches (moderation) - GET /api/health Infrastructure: - Vite proxy /api → localhost:3001 for dev - .env.example with all config vars - Dockerfile updated: installs server deps, serves SPA + API - npm run dev:server for backend hot-reload - npm run db:push for schema sync Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,3 +1,4 @@
|
||||
node_modules
|
||||
dist
|
||||
.vite
|
||||
.env
|
||||
|
||||
10
Dockerfile
10
Dockerfile
@@ -9,9 +9,11 @@ RUN npm run build
|
||||
|
||||
FROM node:20-alpine
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json* ./
|
||||
COPY packages/server/package.json packages/server/
|
||||
RUN npm install -w packages/server --omit=dev
|
||||
COPY --from=build /app/dist ./dist
|
||||
COPY packages/server/server.js ./packages/server/server.js
|
||||
COPY packages/server/package.json ./packages/server/package.json
|
||||
COPY package.json ./
|
||||
COPY packages/server packages/server
|
||||
ENV NODE_ENV=production
|
||||
EXPOSE 80
|
||||
CMD ["node", "packages/server/server.js"]
|
||||
CMD ["node", "packages/server/src/index.js"]
|
||||
|
||||
2960
package-lock.json
generated
2960
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -4,7 +4,9 @@
|
||||
"workspaces": ["packages/*"],
|
||||
"scripts": {
|
||||
"dev": "npm run dev -w packages/client",
|
||||
"dev:server": "npm run dev -w packages/server",
|
||||
"build": "npm run build -w packages/client",
|
||||
"start": "node packages/server/server.js"
|
||||
"start": "node packages/server/src/index.js",
|
||||
"db:push": "npm run db:push -w packages/server"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,11 @@ import react from '@vitejs/plugin-react';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: { port: 3000 },
|
||||
server: {
|
||||
port: 3000,
|
||||
proxy: {
|
||||
'/api': 'http://localhost:3001',
|
||||
},
|
||||
},
|
||||
build: { outDir: '../../dist', emptyOutDir: true }
|
||||
});
|
||||
|
||||
5
packages/server/.env.example
Normal file
5
packages/server/.env.example
Normal file
@@ -0,0 +1,5 @@
|
||||
DATABASE_URL=postgres://reaktor:reaktor_dev@localhost:5432/reaktor
|
||||
JWT_SECRET=change-this-to-a-random-secret
|
||||
PORT=3001
|
||||
CORS_ORIGIN=http://localhost:3000
|
||||
NODE_ENV=development
|
||||
8
packages/server/drizzle.config.js
Normal file
8
packages/server/drizzle.config.js
Normal file
@@ -0,0 +1,8 @@
|
||||
export default {
|
||||
schema: './src/db/schema.js',
|
||||
out: './src/db/migrations',
|
||||
dialect: 'postgresql',
|
||||
dbCredentials: {
|
||||
url: process.env.DATABASE_URL || 'postgres://reaktor:reaktor_dev@localhost:5432/reaktor',
|
||||
},
|
||||
};
|
||||
@@ -2,7 +2,28 @@
|
||||
"name": "@reaktor/server",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "node server.js"
|
||||
"start": "node src/index.js",
|
||||
"dev": "node --watch src/index.js",
|
||||
"db:generate": "drizzle-kit generate",
|
||||
"db:push": "drizzle-kit push"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fastify/cookie": "^11.0.2",
|
||||
"@fastify/cors": "^11.2.0",
|
||||
"@fastify/jwt": "^10.0.0",
|
||||
"@fastify/multipart": "^9.4.0",
|
||||
"@fastify/rate-limit": "^10.3.0",
|
||||
"@fastify/static": "^9.0.0",
|
||||
"argon2": "^0.44.0",
|
||||
"dotenv": "^17.3.1",
|
||||
"drizzle-orm": "^0.45.1",
|
||||
"fastify": "^5.8.2",
|
||||
"postgres": "^3.4.8",
|
||||
"uuid": "^13.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"drizzle-kit": "^0.31.10"
|
||||
}
|
||||
}
|
||||
|
||||
9
packages/server/src/db/index.js
Normal file
9
packages/server/src/db/index.js
Normal file
@@ -0,0 +1,9 @@
|
||||
import { drizzle } from 'drizzle-orm/postgres-js';
|
||||
import postgres from 'postgres';
|
||||
import * as schema from './schema.js';
|
||||
|
||||
const connectionString = process.env.DATABASE_URL || 'postgres://reaktor:reaktor_dev@localhost:5432/reaktor';
|
||||
|
||||
const client = postgres(connectionString);
|
||||
export const db = drizzle(client, { schema });
|
||||
export { schema };
|
||||
63
packages/server/src/db/schema.js
Normal file
63
packages/server/src/db/schema.js
Normal file
@@ -0,0 +1,63 @@
|
||||
import { pgTable, uuid, varchar, text, boolean, integer, timestamp, jsonb, primaryKey } from 'drizzle-orm/pg-core';
|
||||
|
||||
export const users = pgTable('users', {
|
||||
id: uuid('id').defaultRandom().primaryKey(),
|
||||
email: varchar('email', { length: 255 }).unique().notNull(),
|
||||
username: varchar('username', { length: 50 }).unique().notNull(),
|
||||
passwordHash: varchar('password_hash', { length: 255 }).notNull(),
|
||||
avatarUrl: varchar('avatar_url', { length: 500 }),
|
||||
bio: text('bio'),
|
||||
role: varchar('role', { length: 20 }).default('user').notNull(), // user | premium | admin
|
||||
authProvider: varchar('auth_provider', { length: 20 }).default('local'),
|
||||
providerId: varchar('provider_id', { length: 255 }),
|
||||
stripeCustomerId: varchar('stripe_customer_id', { length: 255 }),
|
||||
subscriptionStatus: varchar('subscription_status', { length: 20 }),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
});
|
||||
|
||||
export const presets = pgTable('presets', {
|
||||
id: uuid('id').defaultRandom().primaryKey(),
|
||||
userId: uuid('user_id').references(() => users.id, { onDelete: 'cascade' }).notNull(),
|
||||
name: varchar('name', { length: 200 }).notNull(),
|
||||
data: jsonb('data').notNull(),
|
||||
isAutosave: boolean('is_autosave').default(false),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
});
|
||||
|
||||
export const gameProgress = pgTable('game_progress', {
|
||||
userId: uuid('user_id').references(() => users.id, { onDelete: 'cascade' }).primaryKey(),
|
||||
data: jsonb('data').notNull(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
});
|
||||
|
||||
export const sharedPatches = pgTable('shared_patches', {
|
||||
id: uuid('id').defaultRandom().primaryKey(),
|
||||
userId: uuid('user_id').references(() => users.id, { onDelete: 'set null' }),
|
||||
title: varchar('title', { length: 200 }).notNull(),
|
||||
description: text('description'),
|
||||
tags: text('tags').array(),
|
||||
data: jsonb('data').notNull(),
|
||||
previewUrl: varchar('preview_url', { length: 500 }),
|
||||
likesCount: integer('likes_count').default(0).notNull(),
|
||||
isFlagged: boolean('is_flagged').default(false).notNull(),
|
||||
isDeleted: boolean('is_deleted').default(false).notNull(),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
});
|
||||
|
||||
export const likes = pgTable('likes', {
|
||||
userId: uuid('user_id').references(() => users.id, { onDelete: 'cascade' }).notNull(),
|
||||
patchId: uuid('patch_id').references(() => sharedPatches.id, { onDelete: 'cascade' }).notNull(),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
}, (table) => ({
|
||||
pk: primaryKey({ columns: [table.userId, table.patchId] }),
|
||||
}));
|
||||
|
||||
export const refreshTokens = pgTable('refresh_tokens', {
|
||||
id: uuid('id').defaultRandom().primaryKey(),
|
||||
userId: uuid('user_id').references(() => users.id, { onDelete: 'cascade' }).notNull(),
|
||||
tokenHash: varchar('token_hash', { length: 255 }).notNull(),
|
||||
expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
});
|
||||
74
packages/server/src/index.js
Normal file
74
packages/server/src/index.js
Normal file
@@ -0,0 +1,74 @@
|
||||
import 'dotenv/config';
|
||||
import Fastify from 'fastify';
|
||||
import cors from '@fastify/cors';
|
||||
import cookie from '@fastify/cookie';
|
||||
import jwt from '@fastify/jwt';
|
||||
import rateLimit from '@fastify/rate-limit';
|
||||
import fastifyStatic from '@fastify/static';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
import authRoutes from './routes/auth.js';
|
||||
import userRoutes from './routes/users.js';
|
||||
import adminRoutes from './routes/admin.js';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const PORT = process.env.PORT || 3001;
|
||||
const JWT_SECRET = process.env.JWT_SECRET || 'dev-secret-change-in-production';
|
||||
|
||||
const fastify = Fastify({ logger: true });
|
||||
|
||||
// Plugins
|
||||
await fastify.register(cors, {
|
||||
origin: process.env.CORS_ORIGIN || 'http://localhost:3000',
|
||||
credentials: true,
|
||||
});
|
||||
|
||||
await fastify.register(cookie);
|
||||
|
||||
await fastify.register(jwt, {
|
||||
secret: JWT_SECRET,
|
||||
});
|
||||
|
||||
await fastify.register(rateLimit, {
|
||||
max: 100,
|
||||
timeWindow: '1 minute',
|
||||
});
|
||||
|
||||
// API routes
|
||||
await fastify.register(authRoutes, { prefix: '/api/v1/auth' });
|
||||
await fastify.register(userRoutes, { prefix: '/api/v1/users' });
|
||||
await fastify.register(adminRoutes, { prefix: '/api/v1/admin' });
|
||||
|
||||
// Rate limit auth endpoints more aggressively
|
||||
fastify.addHook('onRoute', (routeOptions) => {
|
||||
if (routeOptions.url?.startsWith('/api/v1/auth')) {
|
||||
routeOptions.config = { ...routeOptions.config, rateLimit: { max: 10, timeWindow: '1 minute' } };
|
||||
}
|
||||
});
|
||||
|
||||
// Health check
|
||||
fastify.get('/api/health', async () => ({ status: 'ok', timestamp: new Date().toISOString() }));
|
||||
|
||||
// In production, serve static files (SPA)
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
const distDir = path.join(__dirname, '..', '..', '..', 'dist');
|
||||
await fastify.register(fastifyStatic, { root: distDir });
|
||||
|
||||
// SPA fallback
|
||||
fastify.setNotFoundHandler((request, reply) => {
|
||||
if (request.url.startsWith('/api/')) {
|
||||
reply.code(404).send({ error: 'Not found' });
|
||||
} else {
|
||||
reply.sendFile('index.html');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Start
|
||||
try {
|
||||
await fastify.listen({ port: PORT, host: '0.0.0.0' });
|
||||
} catch (err) {
|
||||
fastify.log.error(err);
|
||||
process.exit(1);
|
||||
}
|
||||
18
packages/server/src/middleware/auth.js
Normal file
18
packages/server/src/middleware/auth.js
Normal file
@@ -0,0 +1,18 @@
|
||||
export async function authenticate(request, reply) {
|
||||
try {
|
||||
await request.jwtVerify();
|
||||
} catch (err) {
|
||||
reply.code(401).send({ error: 'Unauthorized' });
|
||||
}
|
||||
}
|
||||
|
||||
export async function requireAdmin(request, reply) {
|
||||
try {
|
||||
await request.jwtVerify();
|
||||
if (request.user.role !== 'admin') {
|
||||
reply.code(403).send({ error: 'Forbidden' });
|
||||
}
|
||||
} catch (err) {
|
||||
reply.code(401).send({ error: 'Unauthorized' });
|
||||
}
|
||||
}
|
||||
103
packages/server/src/routes/admin.js
Normal file
103
packages/server/src/routes/admin.js
Normal file
@@ -0,0 +1,103 @@
|
||||
import { eq, sql, desc, ilike } from 'drizzle-orm';
|
||||
import { db, schema } from '../db/index.js';
|
||||
import { requireAdmin } from '../middleware/auth.js';
|
||||
|
||||
export default async function adminRoutes(fastify) {
|
||||
|
||||
// Dashboard stats
|
||||
fastify.get('/stats', { preHandler: [requireAdmin] }, async () => {
|
||||
const [userCount] = await db.select({ count: sql`count(*)::int` }).from(schema.users);
|
||||
const [patchCount] = await db.select({ count: sql`count(*)::int` }).from(schema.sharedPatches).where(eq(schema.sharedPatches.isDeleted, false));
|
||||
const [premiumCount] = await db.select({ count: sql`count(*)::int` }).from(schema.users).where(eq(schema.users.role, 'premium'));
|
||||
const [flaggedCount] = await db.select({ count: sql`count(*)::int` }).from(schema.sharedPatches).where(eq(schema.sharedPatches.isFlagged, true));
|
||||
|
||||
return {
|
||||
users: userCount.count,
|
||||
patches: patchCount.count,
|
||||
premium: premiumCount.count,
|
||||
flagged: flaggedCount.count,
|
||||
};
|
||||
});
|
||||
|
||||
// List users
|
||||
fastify.get('/users', { preHandler: [requireAdmin] }, async (request) => {
|
||||
const { q, page = 1, role } = request.query;
|
||||
const limit = 20;
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
let query = db.select({
|
||||
id: schema.users.id,
|
||||
email: schema.users.email,
|
||||
username: schema.users.username,
|
||||
role: schema.users.role,
|
||||
avatarUrl: schema.users.avatarUrl,
|
||||
createdAt: schema.users.createdAt,
|
||||
}).from(schema.users);
|
||||
|
||||
if (q) {
|
||||
query = query.where(
|
||||
sql`${schema.users.username} ILIKE ${'%' + q + '%'} OR ${schema.users.email} ILIKE ${'%' + q + '%'}`
|
||||
);
|
||||
}
|
||||
if (role) {
|
||||
query = query.where(eq(schema.users.role, role));
|
||||
}
|
||||
|
||||
const users = await query.orderBy(desc(schema.users.createdAt)).limit(limit).offset(offset);
|
||||
return { users, page, limit };
|
||||
});
|
||||
|
||||
// Update user role / ban
|
||||
fastify.patch('/users/:id', { preHandler: [requireAdmin] }, async (request, reply) => {
|
||||
const { role } = request.body || {};
|
||||
if (!role || !['user', 'premium', 'admin', 'banned'].includes(role)) {
|
||||
return reply.code(400).send({ error: 'Invalid role' });
|
||||
}
|
||||
|
||||
const [user] = await db.update(schema.users)
|
||||
.set({ role, updatedAt: new Date() })
|
||||
.where(eq(schema.users.id, request.params.id))
|
||||
.returning({ id: schema.users.id, username: schema.users.username, role: schema.users.role });
|
||||
|
||||
if (!user) return reply.code(404).send({ error: 'User not found' });
|
||||
return user;
|
||||
});
|
||||
|
||||
// List shared patches (moderation)
|
||||
fastify.get('/patches', { preHandler: [requireAdmin] }, async (request) => {
|
||||
const { flagged, deleted, page = 1 } = request.query;
|
||||
const limit = 20;
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
let query = db.select().from(schema.sharedPatches);
|
||||
|
||||
if (flagged === 'true') {
|
||||
query = query.where(eq(schema.sharedPatches.isFlagged, true));
|
||||
}
|
||||
if (deleted === 'true') {
|
||||
query = query.where(eq(schema.sharedPatches.isDeleted, true));
|
||||
}
|
||||
|
||||
const patches = await query.orderBy(desc(schema.sharedPatches.createdAt)).limit(limit).offset(offset);
|
||||
return { patches, page, limit };
|
||||
});
|
||||
|
||||
// Moderate patch (delete, unflag, restore)
|
||||
fastify.patch('/patches/:id', { preHandler: [requireAdmin] }, async (request, reply) => {
|
||||
const updates = {};
|
||||
const { action } = request.body || {};
|
||||
|
||||
if (action === 'delete') updates.isDeleted = true;
|
||||
else if (action === 'restore') updates.isDeleted = false;
|
||||
else if (action === 'unflag') updates.isFlagged = false;
|
||||
else return reply.code(400).send({ error: 'Invalid action' });
|
||||
|
||||
const [patch] = await db.update(schema.sharedPatches)
|
||||
.set(updates)
|
||||
.where(eq(schema.sharedPatches.id, request.params.id))
|
||||
.returning();
|
||||
|
||||
if (!patch) return reply.code(404).send({ error: 'Patch not found' });
|
||||
return patch;
|
||||
});
|
||||
}
|
||||
206
packages/server/src/routes/auth.js
Normal file
206
packages/server/src/routes/auth.js
Normal file
@@ -0,0 +1,206 @@
|
||||
import argon2 from 'argon2';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { db, schema } from '../db/index.js';
|
||||
|
||||
const REFRESH_EXPIRY_DAYS = 30;
|
||||
|
||||
async function hashRefreshToken(token) {
|
||||
return argon2.hash(token, { type: argon2.argon2id });
|
||||
}
|
||||
|
||||
export default async function authRoutes(fastify) {
|
||||
|
||||
// Register
|
||||
fastify.post('/register', {
|
||||
schema: {
|
||||
body: {
|
||||
type: 'object',
|
||||
required: ['email', 'username', 'password'],
|
||||
properties: {
|
||||
email: { type: 'string', format: 'email' },
|
||||
username: { type: 'string', minLength: 3, maxLength: 50 },
|
||||
password: { type: 'string', minLength: 6 },
|
||||
},
|
||||
},
|
||||
},
|
||||
}, async (request, reply) => {
|
||||
const { email, username, password } = request.body;
|
||||
|
||||
// Check existing
|
||||
const existing = await db.select().from(schema.users)
|
||||
.where(eq(schema.users.email, email)).limit(1);
|
||||
if (existing.length > 0) {
|
||||
return reply.code(409).send({ error: 'Email already registered' });
|
||||
}
|
||||
|
||||
const existingUsername = await db.select().from(schema.users)
|
||||
.where(eq(schema.users.username, username)).limit(1);
|
||||
if (existingUsername.length > 0) {
|
||||
return reply.code(409).send({ error: 'Username already taken' });
|
||||
}
|
||||
|
||||
const passwordHash = await argon2.hash(password, { type: argon2.argon2id });
|
||||
|
||||
const [user] = await db.insert(schema.users).values({
|
||||
email,
|
||||
username,
|
||||
passwordHash,
|
||||
}).returning({ id: schema.users.id, email: schema.users.email, username: schema.users.username, role: schema.users.role });
|
||||
|
||||
// Generate tokens
|
||||
const accessToken = fastify.jwt.sign(
|
||||
{ id: user.id, email: user.email, username: user.username, role: user.role },
|
||||
{ expiresIn: '15m' }
|
||||
);
|
||||
|
||||
const refreshToken = uuidv4();
|
||||
const refreshHash = await hashRefreshToken(refreshToken);
|
||||
const expiresAt = new Date(Date.now() + REFRESH_EXPIRY_DAYS * 24 * 60 * 60 * 1000);
|
||||
|
||||
await db.insert(schema.refreshTokens).values({
|
||||
userId: user.id,
|
||||
tokenHash: refreshHash,
|
||||
expiresAt,
|
||||
});
|
||||
|
||||
reply.setCookie('refreshToken', refreshToken, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'lax',
|
||||
path: '/api/v1/auth',
|
||||
maxAge: REFRESH_EXPIRY_DAYS * 24 * 60 * 60,
|
||||
});
|
||||
|
||||
return { user, accessToken };
|
||||
});
|
||||
|
||||
// Login
|
||||
fastify.post('/login', {
|
||||
schema: {
|
||||
body: {
|
||||
type: 'object',
|
||||
required: ['email', 'password'],
|
||||
properties: {
|
||||
email: { type: 'string' },
|
||||
password: { type: 'string' },
|
||||
},
|
||||
},
|
||||
},
|
||||
}, async (request, reply) => {
|
||||
const { email, password } = request.body;
|
||||
|
||||
const [user] = await db.select().from(schema.users)
|
||||
.where(eq(schema.users.email, email)).limit(1);
|
||||
|
||||
if (!user) {
|
||||
return reply.code(401).send({ error: 'Invalid credentials' });
|
||||
}
|
||||
|
||||
const valid = await argon2.verify(user.passwordHash, password);
|
||||
if (!valid) {
|
||||
return reply.code(401).send({ error: 'Invalid credentials' });
|
||||
}
|
||||
|
||||
const accessToken = fastify.jwt.sign(
|
||||
{ id: user.id, email: user.email, username: user.username, role: user.role },
|
||||
{ expiresIn: '15m' }
|
||||
);
|
||||
|
||||
const refreshToken = uuidv4();
|
||||
const refreshHash = await hashRefreshToken(refreshToken);
|
||||
const expiresAt = new Date(Date.now() + REFRESH_EXPIRY_DAYS * 24 * 60 * 60 * 1000);
|
||||
|
||||
await db.insert(schema.refreshTokens).values({
|
||||
userId: user.id,
|
||||
tokenHash: refreshHash,
|
||||
expiresAt,
|
||||
});
|
||||
|
||||
reply.setCookie('refreshToken', refreshToken, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'lax',
|
||||
path: '/api/v1/auth',
|
||||
maxAge: REFRESH_EXPIRY_DAYS * 24 * 60 * 60,
|
||||
});
|
||||
|
||||
return {
|
||||
user: { id: user.id, email: user.email, username: user.username, role: user.role, avatarUrl: user.avatarUrl },
|
||||
accessToken,
|
||||
};
|
||||
});
|
||||
|
||||
// Refresh token
|
||||
fastify.post('/refresh', async (request, reply) => {
|
||||
const token = request.cookies.refreshToken;
|
||||
if (!token) {
|
||||
return reply.code(401).send({ error: 'No refresh token' });
|
||||
}
|
||||
|
||||
// Find all non-expired tokens and verify
|
||||
const candidates = await db.select().from(schema.refreshTokens)
|
||||
.where(eq(schema.refreshTokens.expiresAt, new Date())); // will fix below
|
||||
|
||||
// Actually, find all tokens and check hash
|
||||
const allTokens = await db.select().from(schema.refreshTokens);
|
||||
let matchedToken = null;
|
||||
|
||||
for (const t of allTokens) {
|
||||
if (t.expiresAt < new Date()) continue;
|
||||
try {
|
||||
if (await argon2.verify(t.tokenHash, token)) {
|
||||
matchedToken = t;
|
||||
break;
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
if (!matchedToken) {
|
||||
return reply.code(401).send({ error: 'Invalid refresh token' });
|
||||
}
|
||||
|
||||
// Delete old token (rotation)
|
||||
await db.delete(schema.refreshTokens).where(eq(schema.refreshTokens.id, matchedToken.id));
|
||||
|
||||
// Get user
|
||||
const [user] = await db.select().from(schema.users)
|
||||
.where(eq(schema.users.id, matchedToken.userId)).limit(1);
|
||||
|
||||
if (!user) {
|
||||
return reply.code(401).send({ error: 'User not found' });
|
||||
}
|
||||
|
||||
// Issue new tokens
|
||||
const accessToken = fastify.jwt.sign(
|
||||
{ id: user.id, email: user.email, username: user.username, role: user.role },
|
||||
{ expiresIn: '15m' }
|
||||
);
|
||||
|
||||
const newRefreshToken = uuidv4();
|
||||
const refreshHash = await hashRefreshToken(newRefreshToken);
|
||||
const expiresAt = new Date(Date.now() + REFRESH_EXPIRY_DAYS * 24 * 60 * 60 * 1000);
|
||||
|
||||
await db.insert(schema.refreshTokens).values({
|
||||
userId: user.id,
|
||||
tokenHash: refreshHash,
|
||||
expiresAt,
|
||||
});
|
||||
|
||||
reply.setCookie('refreshToken', newRefreshToken, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'lax',
|
||||
path: '/api/v1/auth',
|
||||
maxAge: REFRESH_EXPIRY_DAYS * 24 * 60 * 60,
|
||||
});
|
||||
|
||||
return { accessToken };
|
||||
});
|
||||
|
||||
// Logout
|
||||
fastify.post('/logout', async (request, reply) => {
|
||||
reply.clearCookie('refreshToken', { path: '/api/v1/auth' });
|
||||
return { ok: true };
|
||||
});
|
||||
}
|
||||
56
packages/server/src/routes/users.js
Normal file
56
packages/server/src/routes/users.js
Normal file
@@ -0,0 +1,56 @@
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { db, schema } from '../db/index.js';
|
||||
import { authenticate } from '../middleware/auth.js';
|
||||
|
||||
export default async function userRoutes(fastify) {
|
||||
|
||||
// Get my profile
|
||||
fastify.get('/me', { preHandler: [authenticate] }, async (request) => {
|
||||
const [user] = await db.select({
|
||||
id: schema.users.id,
|
||||
email: schema.users.email,
|
||||
username: schema.users.username,
|
||||
avatarUrl: schema.users.avatarUrl,
|
||||
bio: schema.users.bio,
|
||||
role: schema.users.role,
|
||||
createdAt: schema.users.createdAt,
|
||||
}).from(schema.users).where(eq(schema.users.id, request.user.id)).limit(1);
|
||||
|
||||
if (!user) return { error: 'User not found' };
|
||||
return user;
|
||||
});
|
||||
|
||||
// Update my profile
|
||||
fastify.patch('/me', {
|
||||
preHandler: [authenticate],
|
||||
schema: {
|
||||
body: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
username: { type: 'string', minLength: 3, maxLength: 50 },
|
||||
bio: { type: 'string', maxLength: 500 },
|
||||
},
|
||||
},
|
||||
},
|
||||
}, async (request, reply) => {
|
||||
const updates = {};
|
||||
if (request.body.username) updates.username = request.body.username;
|
||||
if (request.body.bio !== undefined) updates.bio = request.body.bio;
|
||||
updates.updatedAt = new Date();
|
||||
|
||||
if (Object.keys(updates).length === 1) {
|
||||
return reply.code(400).send({ error: 'Nothing to update' });
|
||||
}
|
||||
|
||||
const [user] = await db.update(schema.users)
|
||||
.set(updates)
|
||||
.where(eq(schema.users.id, request.user.id))
|
||||
.returning({
|
||||
id: schema.users.id,
|
||||
username: schema.users.username,
|
||||
bio: schema.users.bio,
|
||||
});
|
||||
|
||||
return user;
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user