This commit is contained in:
2026-03-28 01:06:30 -05:00
parent 796c374d38
commit ecb708790d
35 changed files with 2347 additions and 37 deletions

49
backend/src/db.ts Normal file
View File

@@ -0,0 +1,49 @@
import Database from 'better-sqlite3';
import path from 'path';
import fs from 'fs';
const DATA_DIR = process.env.DATA_DIR ?? '/data';
const DB_DIR = path.join(DATA_DIR, 'db');
const DB_PATH = path.join(DB_DIR, 'memer.db');
fs.mkdirSync(DB_DIR, { recursive: true });
const db = new Database(DB_PATH);
// Enable WAL mode for better concurrent read performance
db.pragma('journal_mode = WAL');
db.pragma('foreign_keys = ON');
db.exec(`
CREATE TABLE IF NOT EXISTS memes (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
description TEXT,
file_path TEXT NOT NULL,
file_name TEXT NOT NULL,
file_size INTEGER NOT NULL,
mime_type TEXT NOT NULL,
width INTEGER NOT NULL,
height INTEGER NOT NULL,
parent_id TEXT REFERENCES memes(id) ON DELETE CASCADE,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS tags (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT UNIQUE NOT NULL COLLATE NOCASE
);
CREATE TABLE IF NOT EXISTS meme_tags (
meme_id TEXT NOT NULL REFERENCES memes(id) ON DELETE CASCADE,
tag_id INTEGER NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
PRIMARY KEY (meme_id, tag_id)
);
CREATE INDEX IF NOT EXISTS idx_memes_parent_id ON memes(parent_id);
CREATE INDEX IF NOT EXISTS idx_memes_created_at ON memes(created_at DESC);
CREATE INDEX IF NOT EXISTS idx_meme_tags_meme_id ON meme_tags(meme_id);
CREATE INDEX IF NOT EXISTS idx_meme_tags_tag_id ON meme_tags(tag_id);
`);
export default db;

58
backend/src/index.ts Normal file
View File

@@ -0,0 +1,58 @@
import Fastify from 'fastify';
import fastifyMultipart from '@fastify/multipart';
import fastifyStatic from '@fastify/static';
import path from 'path';
import { fileURLToPath } from 'url';
import { ensureImagesDir, IMAGES_DIR } from './services/storage.js';
import { memesRoutes } from './routes/memes.js';
import { tagsRoutes } from './routes/tags.js';
// Ensure data dirs exist
ensureImagesDir();
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const app = Fastify({ logger: { level: 'info' } });
// Multipart for file uploads (100 MB max)
await app.register(fastifyMultipart, {
limits: { fileSize: 100 * 1024 * 1024 },
});
// Serve uploaded image files at /images/*
await app.register(fastifyStatic, {
root: IMAGES_DIR,
prefix: '/images/',
decorateReply: false,
});
// Serve built React frontend at /*
const frontendDist = path.join(__dirname, '..', 'public');
await app.register(fastifyStatic, {
root: frontendDist,
prefix: '/',
wildcard: false,
});
// API routes
await app.register(memesRoutes);
await app.register(tagsRoutes);
// SPA fallback — serve index.html for all non-API, non-image routes
app.setNotFoundHandler(async (req, reply) => {
if (req.url.startsWith('/api/') || req.url.startsWith('/images/')) {
return reply.status(404).send({ error: 'Not found' });
}
return reply.sendFile('index.html', frontendDist);
});
const port = Number(process.env.PORT ?? 3000);
const host = process.env.HOST ?? '0.0.0.0';
try {
await app.listen({ port, host });
console.log(`Memer running at http://${host}:${port}`);
} catch (err) {
app.log.error(err);
process.exit(1);
}

235
backend/src/routes/memes.ts Normal file
View File

@@ -0,0 +1,235 @@
import type { FastifyInstance } from 'fastify';
import type { MultipartFile } from '@fastify/multipart';
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 type { ListQuery, UpdateBody, RescaleBody, Meme } from '../types.js';
const ALLOWED_MIMES = new Set(['image/jpeg', 'image/png', 'image/gif', 'image/webp']);
function getMemeTags(memeId: string): string[] {
return (
db
.prepare(
`SELECT t.name FROM tags t
JOIN meme_tags mt ON mt.tag_id = t.id
WHERE mt.meme_id = ?
ORDER BY t.name`
)
.all(memeId) as { name: string }[]
).map((r) => r.name);
}
function getMemeById(id: string): Meme | null {
const row = db.prepare('SELECT * FROM memes WHERE id = ?').get(id) as Meme | undefined;
if (!row) return null;
return { ...row, tags: getMemeTags(id) };
}
function setMemeTags(memeId: string, tagNames: string[]): void {
db.prepare('DELETE FROM meme_tags WHERE meme_id = ?').run(memeId);
for (const name of tagNames) {
const trimmed = name.trim().toLowerCase();
if (!trimmed) continue;
let tag = db.prepare('SELECT id FROM tags WHERE name = ?').get(trimmed) as
| { id: number }
| undefined;
if (!tag) {
const res = db.prepare('INSERT INTO tags (name) VALUES (?)').run(trimmed);
tag = { id: Number(res.lastInsertRowid) };
}
db.prepare('INSERT OR IGNORE INTO meme_tags (meme_id, tag_id) VALUES (?, ?)').run(
memeId,
tag.id
);
}
}
export async function memesRoutes(app: FastifyInstance) {
// List memes
app.get<{ Querystring: ListQuery }>('/api/memes', async (req) => {
const { tag, q, page = 1, limit = 50, parent_only = 'true' } = req.query;
const offset = (Number(page) - 1) * Number(limit);
let sql = `
SELECT DISTINCT m.*
FROM memes m
`;
const params: (string | number)[] = [];
const conditions: string[] = [];
if (parent_only === 'true') {
conditions.push('m.parent_id IS NULL');
}
if (tag) {
sql += `
JOIN meme_tags mt_filter ON mt_filter.meme_id = m.id
JOIN tags t_filter ON t_filter.id = mt_filter.tag_id AND t_filter.name = ?
`;
params.push(tag.toLowerCase());
}
if (q) {
conditions.push(`(m.title LIKE ? OR m.description LIKE ?)`);
params.push(`%${q}%`, `%${q}%`);
}
if (conditions.length) {
sql += ' WHERE ' + conditions.join(' AND ');
}
sql += ' ORDER BY m.created_at DESC LIMIT ? OFFSET ?';
params.push(Number(limit), offset);
const memes = db.prepare(sql).all(...params) as Meme[];
const total = (
db
.prepare(
`SELECT COUNT(DISTINCT m.id) as count FROM memes m
${tag ? `JOIN meme_tags mt_filter ON mt_filter.meme_id = m.id JOIN tags t_filter ON t_filter.id = mt_filter.tag_id AND t_filter.name = ?` : ''}
${conditions.length ? 'WHERE ' + conditions.join(' AND ') : ''}`
)
.get(...(tag ? [tag.toLowerCase(), ...params.slice(1, -2)] : params.slice(0, -2))) as {
count: number;
}
).count;
return {
memes: memes.map((m) => ({ ...m, tags: getMemeTags(m.id) })),
total,
page: Number(page),
limit: Number(limit),
};
});
// Get single meme + children
app.get<{ Params: { id: string } }>('/api/memes/:id', async (req, reply) => {
const meme = getMemeById(req.params.id);
if (!meme) return reply.status(404).send({ error: 'Not found' });
const children = (
db.prepare('SELECT * FROM memes WHERE parent_id = ? ORDER BY created_at ASC').all(meme.id) as Meme[]
).map((c) => ({ ...c, tags: getMemeTags(c.id) }));
return { ...meme, children };
});
// Upload meme
app.post('/api/memes', async (req, reply) => {
const data = await req.file();
if (!data) return reply.status(400).send({ error: 'No file uploaded' });
const file = data as MultipartFile;
const mimeType = file.mimetype;
if (!ALLOWED_MIMES.has(mimeType)) {
return reply.status(400).send({ error: `Unsupported file type: ${mimeType}` });
}
const buffer = await file.toBuffer();
const id = uuidv4();
const ext = getExtension(mimeType);
const filePath = buildFilePath(id, ext);
await saveBuffer(buffer, filePath);
const meta = await extractMeta(filePath);
const fields = file.fields as Record<string, { value: string }>;
const title = fields.title?.value ?? file.filename ?? 'Untitled';
const description = fields.description?.value ?? null;
const tagsRaw = fields.tags?.value ?? '';
db.prepare(
`INSERT INTO memes (id, title, description, file_path, file_name, file_size, mime_type, width, height)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
).run(id, title, description, filePath, file.filename, meta.size, meta.mimeType, meta.width, meta.height);
if (tagsRaw) {
setMemeTags(id, tagsRaw.split(','));
}
return reply.status(201).send(getMemeById(id));
});
// Update meme metadata
app.put<{ Params: { id: string }; Body: UpdateBody }>('/api/memes/:id', async (req, reply) => {
const meme = getMemeById(req.params.id);
if (!meme) return reply.status(404).send({ error: 'Not found' });
const { title, description, tags } = req.body;
db.prepare(
`UPDATE memes SET title = ?, description = ? WHERE id = ?`
).run(title ?? meme.title, description ?? meme.description, meme.id);
if (tags !== undefined) {
setMemeTags(meme.id, tags);
}
return getMemeById(meme.id);
});
// Delete meme (children cascade)
app.delete<{ Params: { id: string } }>('/api/memes/:id', async (req, reply) => {
const meme = getMemeById(req.params.id);
if (!meme) return reply.status(404).send({ error: 'Not found' });
// Delete child files first
const children = db
.prepare('SELECT file_path FROM memes WHERE parent_id = ?')
.all(meme.id) as { file_path: string }[];
for (const child of children) {
deleteFile(child.file_path);
}
deleteFile(meme.file_path);
db.prepare('DELETE FROM memes WHERE id = ?').run(meme.id);
return { ok: true };
});
// Non-destructive rescale
app.post<{ Params: { id: string }; Body: RescaleBody }>(
'/api/memes/:id/rescale',
async (req, reply) => {
const parent = getMemeById(req.params.id);
if (!parent) return reply.status(404).send({ error: 'Not found' });
if (parent.parent_id) {
return reply.status(400).send({ error: 'Cannot rescale a derived image. Rescale the original.' });
}
const { width, height, quality = 85, label } = req.body;
if (!width && !height) {
return reply.status(400).send({ error: 'width or height is required' });
}
const childId = uuidv4();
const ext = getExtension(parent.mime_type);
const autoLabel = label ?? (width ? `${width}w` : `${height}h`);
const childPath = buildFilePath(childId, ext, autoLabel);
const meta = await resizeImage(parent.file_path, childPath, { width, height, quality });
db.prepare(
`INSERT INTO memes (id, title, description, file_path, file_name, file_size, mime_type, width, height, parent_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
).run(
childId,
`${parent.title} (${autoLabel})`,
parent.description,
childPath,
parent.file_name,
meta.size,
meta.mimeType,
meta.width,
meta.height,
parent.id
);
return reply.status(201).send(getMemeById(childId));
}
);
}

View File

@@ -0,0 +1,43 @@
import type { FastifyInstance } from 'fastify';
import db from '../db.js';
export async function tagsRoutes(app: FastifyInstance) {
app.get('/api/tags', async () => {
const tags = db
.prepare(
`SELECT t.id, t.name, COUNT(mt.meme_id) as meme_count
FROM tags t
LEFT JOIN meme_tags mt ON mt.tag_id = t.id
GROUP BY t.id
ORDER BY meme_count DESC, t.name ASC`
)
.all() as { id: number; name: string; meme_count: number }[];
return tags;
});
app.post<{ Body: { name: string } }>('/api/tags', async (req, reply) => {
const { name } = req.body;
if (!name?.trim()) {
return reply.status(400).send({ error: 'Tag name is required' });
}
const trimmed = name.trim().toLowerCase();
try {
const result = db.prepare('INSERT INTO tags (name) VALUES (?)').run(trimmed);
return db
.prepare('SELECT id, name, 0 as meme_count FROM tags WHERE id = ?')
.get(result.lastInsertRowid);
} catch {
// Unique constraint — return existing
return db
.prepare('SELECT id, name FROM tags WHERE name = ?')
.get(trimmed);
}
});
app.delete<{ Params: { id: string } }>('/api/tags/:id', 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);
return { ok: true };
});
}

View File

@@ -0,0 +1,91 @@
import sharp from 'sharp';
import fs from 'fs';
import { absolutePath, ensureDir } from './storage.js';
export interface ImageMeta {
width: number;
height: number;
mimeType: string;
size: number;
}
export async function extractMeta(filePath: string): Promise<ImageMeta> {
const abs = absolutePath(filePath);
const meta = await sharp(abs).metadata();
const stat = fs.statSync(abs);
const mimeMap: Record<string, string> = {
jpeg: 'image/jpeg',
png: 'image/png',
gif: 'image/gif',
webp: 'image/webp',
};
return {
width: meta.width ?? 0,
height: meta.height ?? 0,
mimeType: mimeMap[meta.format ?? ''] ?? 'image/jpeg',
size: stat.size,
};
}
export async function saveBuffer(buffer: Buffer, destRelPath: string): Promise<void> {
ensureDir(destRelPath);
const abs = absolutePath(destRelPath);
fs.writeFileSync(abs, buffer);
}
export interface ResizeOptions {
width?: number;
height?: number;
quality?: number;
}
export async function resizeImage(
srcRelPath: string,
destRelPath: string,
options: ResizeOptions
): Promise<ImageMeta> {
const srcAbs = absolutePath(srcRelPath);
ensureDir(destRelPath);
const destAbs = absolutePath(destRelPath);
const src = await sharp(srcAbs).metadata();
const isGif = src.format === 'gif';
let pipeline = sharp(srcAbs, { animated: isGif });
if (options.width || options.height) {
pipeline = pipeline.resize({
width: options.width,
height: options.height,
fit: 'inside',
withoutEnlargement: true,
});
}
if (!isGif && options.quality) {
if (src.format === 'jpeg') pipeline = pipeline.jpeg({ quality: options.quality });
else if (src.format === 'png') pipeline = pipeline.png({ quality: options.quality });
else if (src.format === 'webp') pipeline = pipeline.webp({ quality: options.quality });
}
await pipeline.toFile(destAbs);
const resultMeta = await sharp(destAbs).metadata();
const stat = fs.statSync(destAbs);
const mimeMap: Record<string, string> = {
jpeg: 'image/jpeg',
png: 'image/png',
gif: 'image/gif',
webp: 'image/webp',
};
return {
width: resultMeta.width ?? 0,
height: resultMeta.height ?? 0,
mimeType: mimeMap[resultMeta.format ?? ''] ?? 'image/jpeg',
size: stat.size,
};
}

View File

@@ -0,0 +1,49 @@
import fs from 'fs';
import path from 'path';
const DATA_DIR = process.env.DATA_DIR ?? '/data';
export const IMAGES_DIR = path.join(DATA_DIR, 'images');
export function ensureImagesDir(): void {
fs.mkdirSync(IMAGES_DIR, { recursive: true });
}
export function getMonthDir(): string {
const now = new Date();
const yyyy = now.getFullYear();
const mm = String(now.getMonth() + 1).padStart(2, '0');
return `${yyyy}-${mm}`;
}
export function buildFilePath(id: string, ext: string, label?: string): string {
const monthDir = getMonthDir();
const suffix = label ? `-${label}` : '';
const filename = `${id}${suffix}.${ext}`;
return path.join(monthDir, filename);
}
export function absolutePath(relativePath: string): string {
return path.join(IMAGES_DIR, relativePath);
}
export function ensureDir(relativePath: string): void {
const dir = path.dirname(absolutePath(relativePath));
fs.mkdirSync(dir, { recursive: true });
}
export function deleteFile(relativePath: string): void {
const abs = absolutePath(relativePath);
if (fs.existsSync(abs)) {
fs.unlinkSync(abs);
}
}
export function getExtension(mimeType: string): string {
const map: Record<string, string> = {
'image/jpeg': 'jpg',
'image/png': 'png',
'image/gif': 'gif',
'image/webp': 'webp',
};
return map[mimeType] ?? 'jpg';
}

47
backend/src/types.ts Normal file
View File

@@ -0,0 +1,47 @@
export interface Meme {
id: string;
title: string;
description: string | null;
file_path: string;
file_name: string;
file_size: number;
mime_type: string;
width: number;
height: number;
parent_id: string | null;
created_at: string;
tags: string[];
}
export interface Tag {
id: number;
name: string;
meme_count: number;
}
export interface UploadBody {
title?: string;
description?: string;
tags?: string;
}
export interface UpdateBody {
title?: string;
description?: string;
tags?: string[];
}
export interface RescaleBody {
width?: number;
height?: number;
quality?: number;
label?: string;
}
export interface ListQuery {
tag?: string;
q?: string;
page?: number;
limit?: number;
parent_only?: string;
}