build auth modal

This commit is contained in:
2026-03-28 01:23:53 -05:00
parent 3761e2cf52
commit 2c128a404e
12 changed files with 360 additions and 41 deletions

73
backend/src/auth.ts Normal file
View File

@@ -0,0 +1,73 @@
import crypto from 'crypto';
import type { FastifyRequest, FastifyReply } from 'fastify';
const COOKIE_NAME = 'memer_session';
const TOKEN_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
function secret(): string {
const pass = process.env.ADMIN_PASS;
if (!pass) throw new Error('ADMIN_PASS environment variable is not set');
return pass;
}
function b64url(str: string): string {
return Buffer.from(str).toString('base64url');
}
function fromB64url(str: string): string {
return Buffer.from(str, 'base64url').toString('utf8');
}
export function createToken(user: string): string {
const payload = b64url(JSON.stringify({ user, exp: Date.now() + TOKEN_TTL_MS }));
const sig = crypto.createHmac('sha256', secret()).update(payload).digest('hex');
return `${payload}.${sig}`;
}
export function verifyToken(token: string): { user: string } | null {
try {
const dot = token.lastIndexOf('.');
if (dot === -1) return null;
const payload = token.slice(0, dot);
const sig = token.slice(dot + 1);
const expected = crypto.createHmac('sha256', secret()).update(payload).digest('hex');
if (!crypto.timingSafeEqual(Buffer.from(sig, 'hex'), Buffer.from(expected, 'hex'))) return null;
const data = JSON.parse(fromB64url(payload)) as { user: string; exp: number };
if (Date.now() > data.exp) return null;
return { user: data.user };
} catch {
return null;
}
}
export function getTokenFromRequest(req: FastifyRequest): string | null {
const raw = req.headers.cookie ?? '';
for (const part of raw.split(';')) {
const [key, ...rest] = part.trim().split('=');
if (key === COOKIE_NAME) return rest.join('=');
}
return null;
}
export function setSessionCookie(reply: FastifyReply, token: string): void {
const secure = (process.env.PUBLIC_URL ?? '').startsWith('https');
const maxAge = Math.floor(TOKEN_TTL_MS / 1000);
reply.header(
'Set-Cookie',
`${COOKIE_NAME}=${token}; HttpOnly; SameSite=Strict; Path=/; Max-Age=${maxAge}${secure ? '; Secure' : ''}`
);
}
export function clearSessionCookie(reply: FastifyReply): void {
reply.header(
'Set-Cookie',
`${COOKIE_NAME}=; HttpOnly; SameSite=Strict; Path=/; Max-Age=0`
);
}
export async function requireAuth(req: FastifyRequest, reply: FastifyReply): Promise<void> {
const token = getTokenFromRequest(req);
if (!token || !verifyToken(token)) {
await reply.status(401).send({ error: 'Unauthorized' });
}
}

View File

@@ -6,6 +6,7 @@ import { fileURLToPath } from 'url';
import { ensureImagesDir, IMAGES_DIR } from './services/storage.js';
import { memesRoutes } from './routes/memes.js';
import { tagsRoutes } from './routes/tags.js';
import { authRoutes } from './routes/auth.js';
// Ensure data dirs exist
ensureImagesDir();
@@ -35,6 +36,7 @@ await app.register(fastifyStatic, {
});
// API routes
await app.register(authRoutes);
await app.register(memesRoutes);
await app.register(tagsRoutes);

View File

@@ -0,0 +1,35 @@
import type { FastifyInstance } from 'fastify';
import { createToken, verifyToken, getTokenFromRequest, setSessionCookie, clearSessionCookie } from '../auth.js';
export async function authRoutes(app: FastifyInstance) {
app.post<{ Body: { username: string; password: string } }>('/api/auth/login', async (req, reply) => {
const { username, password } = req.body ?? {};
const adminUser = process.env.ADMIN_USER ?? 'admin';
const adminPass = process.env.ADMIN_PASS;
if (!adminPass) {
return reply.status(500).send({ error: 'Server auth is not configured (ADMIN_PASS missing)' });
}
if (username !== adminUser || password !== adminPass) {
return reply.status(401).send({ error: 'Invalid credentials' });
}
const token = createToken(username);
setSessionCookie(reply, token);
return { ok: true, user: username };
});
app.post('/api/auth/logout', async (_req, reply) => {
clearSessionCookie(reply);
return { ok: true };
});
app.get('/api/auth/me', async (req) => {
const token = getTokenFromRequest(req);
const payload = token ? verifyToken(token) : null;
if (!payload) return { authenticated: false };
return { authenticated: true, user: payload.user };
});
}

View File

@@ -4,6 +4,7 @@ import { v4 as uuidv4 } from 'uuid';
import db from '../db.js';
import { buildFilePath, deleteFile, getExtension } from '../services/storage.js';
import { extractMeta, resizeImage, saveBuffer } from '../services/image.js';
import { requireAuth } from '../auth.js';
import type { ListQuery, UpdateBody, RescaleBody, Meme } from '../types.js';
const ALLOWED_MIMES = new Set(['image/jpeg', 'image/png', 'image/gif', 'image/webp']);
@@ -117,7 +118,7 @@ export async function memesRoutes(app: FastifyInstance) {
});
// Upload meme
app.post('/api/memes', async (req, reply) => {
app.post('/api/memes', { preHandler: requireAuth }, async (req, reply) => {
const data = await req.file();
if (!data) return reply.status(400).send({ error: 'No file uploaded' });
@@ -155,7 +156,7 @@ export async function memesRoutes(app: FastifyInstance) {
});
// Update meme metadata
app.put<{ Params: { id: string }; Body: UpdateBody }>('/api/memes/:id', async (req, reply) => {
app.put<{ Params: { id: string }; Body: UpdateBody }>('/api/memes/:id', { preHandler: requireAuth }, async (req, reply) => {
const meme = getMemeById(req.params.id);
if (!meme) return reply.status(404).send({ error: 'Not found' });
@@ -173,7 +174,7 @@ export async function memesRoutes(app: FastifyInstance) {
});
// Delete meme (children cascade)
app.delete<{ Params: { id: string } }>('/api/memes/:id', async (req, reply) => {
app.delete<{ Params: { id: string } }>('/api/memes/:id', { preHandler: requireAuth }, async (req, reply) => {
const meme = getMemeById(req.params.id);
if (!meme) return reply.status(404).send({ error: 'Not found' });
@@ -194,6 +195,7 @@ export async function memesRoutes(app: FastifyInstance) {
// Non-destructive rescale
app.post<{ Params: { id: string }; Body: RescaleBody }>(
'/api/memes/:id/rescale',
{ preHandler: requireAuth },
async (req, reply) => {
const parent = getMemeById(req.params.id);
if (!parent) return reply.status(404).send({ error: 'Not found' });

View File

@@ -1,5 +1,6 @@
import type { FastifyInstance } from 'fastify';
import db from '../db.js';
import { requireAuth } from '../auth.js';
export async function tagsRoutes(app: FastifyInstance) {
app.get('/api/tags', async () => {
@@ -34,7 +35,7 @@ export async function tagsRoutes(app: FastifyInstance) {
}
});
app.delete<{ Params: { id: string } }>('/api/tags/:id', async (req, reply) => {
app.delete<{ Params: { id: string } }>('/api/tags/:id', { preHandler: requireAuth }, async (req, reply) => {
const id = Number(req.params.id);
if (!id) return reply.status(400).send({ error: 'Invalid tag id' });
db.prepare('DELETE FROM tags WHERE id = ?').run(id);