init commit
This commit is contained in:
+21
@@ -0,0 +1,21 @@
|
||||
import express from 'express';
|
||||
import cors from 'cors';
|
||||
import helmet from 'helmet';
|
||||
import morgan from 'morgan';
|
||||
import { router as apiRouter } from './routes';
|
||||
import { errorHandler } from './middlewares/errorHandler';
|
||||
|
||||
export function createApp() {
|
||||
const app = express();
|
||||
app.disable('x-powered-by');
|
||||
app.use(helmet());
|
||||
app.use(cors());
|
||||
app.use(express.json({ limit: '10mb' }));
|
||||
app.use(express.urlencoded({ extended: true }));
|
||||
app.use(morgan('dev'));
|
||||
|
||||
app.get('/health', (_req: express.Request, res: express.Response) => res.json({ ok: true }));
|
||||
app.use('/api', apiRouter);
|
||||
app.use(errorHandler);
|
||||
return app;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export function decodeBase64ToBuffer(b64?: string): Buffer | undefined {
|
||||
if (!b64) return undefined;
|
||||
// Allow both pure base64 and data URLs
|
||||
const parts = b64.split(',');
|
||||
const payload = parts.length > 1 ? parts[1]! : parts[0]!;
|
||||
return Buffer.from(payload as string, 'base64');
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
// Ensure single instance in dev with Bun hot reload
|
||||
const globalForPrisma = globalThis as unknown as { prisma?: PrismaClient };
|
||||
|
||||
export const prisma = globalForPrisma.prisma ?? new PrismaClient();
|
||||
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
globalForPrisma.prisma = prisma;
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
const base64Regex = /^(data:[^;]+;base64,)?[A-Za-z0-9+/]+={0,2}$/;
|
||||
|
||||
export const RegisterSchema = z.object({
|
||||
username: z.string().min(3).max(32),
|
||||
password: z.string().min(6).max(128),
|
||||
});
|
||||
|
||||
export const UpdateProfileSchema = z.object({
|
||||
username: z.string().min(3).max(32).optional(),
|
||||
avatarBase64: z.string().regex(base64Regex, 'Invalid base64').optional(),
|
||||
avatarMime: z.string().optional(),
|
||||
});
|
||||
|
||||
export const CreateModelSchema = z.object({
|
||||
title: z.string().min(1).max(200),
|
||||
desc: z.string().min(1),
|
||||
model: z.any(),
|
||||
previewBase64: z.string().regex(base64Regex, 'Invalid base64').optional(),
|
||||
previewMime: z.string().optional(),
|
||||
});
|
||||
|
||||
export const UpdateModelSchema = CreateModelSchema.partial();
|
||||
|
||||
export const ListQuerySchema = z.object({
|
||||
q: z.string().optional(),
|
||||
authorId: z.string().optional(),
|
||||
page: z.coerce.number().int().min(1).default(1),
|
||||
pageSize: z.coerce.number().int().min(1).max(100).default(20),
|
||||
sort: z.enum(['new', 'old']).default('new'),
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { Request, Response, NextFunction } from 'express';
|
||||
import { prisma } from '../lib/prisma';
|
||||
import bcrypt from 'bcryptjs';
|
||||
|
||||
export async function basicAuth(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const auth = req.headers['authorization'];
|
||||
if (!auth || !auth.startsWith('Basic ')) {
|
||||
res.setHeader('WWW-Authenticate', 'Basic realm="User Visible Realm"');
|
||||
return res.status(401).json({ error: 'Unauthorized' });
|
||||
}
|
||||
const b64 = auth.slice('Basic '.length);
|
||||
let decoded: string;
|
||||
try {
|
||||
decoded = Buffer.from(b64, 'base64').toString('utf8');
|
||||
} catch (e) {
|
||||
return res.status(400).json({ error: 'Invalid Authorization header' });
|
||||
}
|
||||
const idx = decoded.indexOf(':');
|
||||
if (idx === -1) return res.status(400).json({ error: 'Invalid Authorization payload' });
|
||||
const username = decoded.slice(0, idx);
|
||||
const password = decoded.slice(idx + 1);
|
||||
const user = await prisma.user.findUnique({ where: { username } });
|
||||
if (!user) return res.status(401).json({ error: 'Invalid credentials' });
|
||||
const ok = await bcrypt.compare(password, user.password);
|
||||
if (!ok) return res.status(401).json({ error: 'Invalid credentials' });
|
||||
(req as any).user = { id: user.id, username: user.username };
|
||||
next();
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import type { Request, Response, NextFunction } from 'express';
|
||||
|
||||
export function errorHandler(err: any, _req: Request, res: Response, _next: NextFunction) {
|
||||
console.error(err);
|
||||
const status = typeof err?.status === 'number' ? err.status : 500;
|
||||
const message = err?.message || 'Internal Server Error';
|
||||
res.status(status).json({ error: message });
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { Router } from 'express';
|
||||
import { router as users } from './users';
|
||||
import { router as models } from './models';
|
||||
|
||||
export const router = Router();
|
||||
router.use('/users', users);
|
||||
router.use('/models', models);
|
||||
@@ -0,0 +1,122 @@
|
||||
import { Router, type Request, type Response } from 'express';
|
||||
import { prisma } from '../lib/prisma';
|
||||
import { basicAuth } from '../middlewares/basicAuth';
|
||||
import { CreateModelSchema, UpdateModelSchema, ListQuerySchema } from '../lib/validators';
|
||||
import { decodeBase64ToBuffer } from '../lib/base64';
|
||||
|
||||
export const router = Router();
|
||||
|
||||
// Create model
|
||||
router.post('/', basicAuth, async (req: Request, res: Response) => {
|
||||
const parsed = CreateModelSchema.safeParse(req.body);
|
||||
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
|
||||
const { title, desc, model, previewBase64, previewMime } = parsed.data;
|
||||
const created = await prisma.circuitModel.create({
|
||||
data: {
|
||||
title,
|
||||
desc,
|
||||
model,
|
||||
preview: decodeBase64ToBuffer(previewBase64),
|
||||
previewMime: previewBase64 ? (previewMime ?? 'image/png') : null,
|
||||
authorId: (req as any).user.id,
|
||||
},
|
||||
});
|
||||
res.status(201).json({ id: created.id });
|
||||
});
|
||||
|
||||
// Update model
|
||||
router.put('/:id', basicAuth, async (req: Request, res: Response) => {
|
||||
const id = req.params.id;
|
||||
const owned = await prisma.circuitModel.findUnique({ where: { id }, select: { authorId: true } });
|
||||
if (!owned) return res.status(404).json({ error: 'Not found' });
|
||||
if (owned.authorId !== (req as any).user.id) return res.status(403).json({ error: 'Forbidden' });
|
||||
const parsed = UpdateModelSchema.safeParse(req.body);
|
||||
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
|
||||
const { title, desc, model, previewBase64, previewMime } = parsed.data;
|
||||
const data: any = {};
|
||||
if (title !== undefined) data.title = title;
|
||||
if (desc !== undefined) data.desc = desc;
|
||||
if (model !== undefined) data.model = model;
|
||||
if (previewBase64 !== undefined) {
|
||||
data.preview = decodeBase64ToBuffer(previewBase64);
|
||||
data.previewMime = previewBase64 ? (previewMime ?? 'image/png') : null;
|
||||
}
|
||||
const updated = await prisma.circuitModel.update({ where: { id }, data });
|
||||
res.json({ id: updated.id });
|
||||
});
|
||||
|
||||
// Delete model
|
||||
router.delete('/:id', basicAuth, async (req: Request, res: Response) => {
|
||||
const id = req.params.id;
|
||||
const owned = await prisma.circuitModel.findUnique({ where: { id }, select: { authorId: true } });
|
||||
if (!owned) return res.status(404).json({ error: 'Not found' });
|
||||
if (owned.authorId !== (req as any).user.id) return res.status(403).json({ error: 'Forbidden' });
|
||||
await prisma.circuitModel.delete({ where: { id } });
|
||||
res.status(204).end();
|
||||
});
|
||||
|
||||
// Get by id
|
||||
router.get('/:id', async (req: Request, res: Response) => {
|
||||
const id = req.params.id;
|
||||
const m = await prisma.circuitModel.findUnique({
|
||||
where: { id },
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
desc: true,
|
||||
model: true,
|
||||
authorId: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
});
|
||||
if (!m) return res.status(404).json({ error: 'Not found' });
|
||||
res.json(m);
|
||||
});
|
||||
|
||||
// List models with pagination and simple search
|
||||
router.get('/', async (req: Request, res: Response) => {
|
||||
const parsed = ListQuerySchema.safeParse(req.query);
|
||||
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
|
||||
const { page, pageSize, authorId, sort, q } = parsed.data;
|
||||
const where: any = {};
|
||||
if (authorId) where.authorId = authorId;
|
||||
if (q) where.OR = [
|
||||
{ title: { contains: q, mode: 'insensitive' } },
|
||||
{ desc: { contains: q, mode: 'insensitive' } },
|
||||
];
|
||||
const total = await prisma.circuitModel.count({ where });
|
||||
const items = await prisma.circuitModel.findMany({
|
||||
where,
|
||||
orderBy: { createdAt: sort === 'new' ? 'desc' : 'asc' },
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
authorId: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
});
|
||||
res.json({ page, pageSize, total, items });
|
||||
});
|
||||
|
||||
// Download model JSON
|
||||
router.get('/:id/model.json', async (req: Request, res: Response) => {
|
||||
const id = req.params.id;
|
||||
const m = await prisma.circuitModel.findUnique({ where: { id }, select: { model: true, title: true } });
|
||||
if (!m) return res.status(404).end();
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${encodeURIComponent(m.title)}.json"`);
|
||||
res.end(JSON.stringify(m.model));
|
||||
});
|
||||
|
||||
// Get preview image
|
||||
router.get('/:id/preview', async (req: Request, res: Response) => {
|
||||
const id = req.params.id;
|
||||
const m = await prisma.circuitModel.findUnique({ where: { id }, select: { preview: true, previewMime: true } });
|
||||
if (!m || !m.preview) return res.status(404).end();
|
||||
res.setHeader('Content-Type', m.previewMime ?? 'image/png');
|
||||
res.end(Buffer.from(m.preview));
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
import { Router, type Request, type Response } from 'express';
|
||||
import { prisma } from '../lib/prisma';
|
||||
import { RegisterSchema, UpdateProfileSchema } from '../lib/validators';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import { basicAuth } from '../middlewares/basicAuth';
|
||||
import { decodeBase64ToBuffer } from '../lib/base64';
|
||||
|
||||
export const router = Router();
|
||||
|
||||
// Register new user
|
||||
router.post('/register', async (req: Request, res: Response) => {
|
||||
const parsed = RegisterSchema.safeParse(req.body);
|
||||
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
|
||||
const { username, password } = parsed.data;
|
||||
const exists = await prisma.user.findUnique({ where: { username } });
|
||||
if (exists) return res.status(409).json({ error: 'Username already exists' });
|
||||
const hash = await bcrypt.hash(password, 10);
|
||||
const user = await prisma.user.create({ data: { username, password: hash } });
|
||||
return res.status(201).json({ id: user.id, username: user.username, createdAt: user.createdAt });
|
||||
});
|
||||
|
||||
// Get current user
|
||||
router.get('/me', basicAuth, async (req: Request, res: Response) => {
|
||||
const me = await prisma.user.findUnique({ where: { id: (req as any).user.id } });
|
||||
if (!me) return res.status(404).json({ error: 'User not found' });
|
||||
res.json({ id: me.id, username: me.username, createdAt: me.createdAt, updatedAt: me.updatedAt });
|
||||
});
|
||||
|
||||
// Update profile (username, avatar)
|
||||
router.put('/me', basicAuth, async (req: Request, res: Response) => {
|
||||
const parsed = UpdateProfileSchema.safeParse(req.body);
|
||||
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
|
||||
const { username, avatarBase64, avatarMime } = parsed.data;
|
||||
const data: any = {};
|
||||
if (username) data.username = username;
|
||||
if (avatarBase64) {
|
||||
data.avatar = decodeBase64ToBuffer(avatarBase64);
|
||||
data.avatarMime = avatarMime ?? 'image/png';
|
||||
}
|
||||
const user = await prisma.user.update({ where: { id: (req as any).user.id }, data });
|
||||
res.json({ id: user.id, username: user.username, updatedAt: user.updatedAt });
|
||||
});
|
||||
|
||||
// Get avatar binary
|
||||
router.get('/:id/avatar', async (req: Request, res: Response) => {
|
||||
const u = await prisma.user.findUnique({ where: { id: req.params.id }, select: { avatar: true, avatarMime: true } });
|
||||
if (!u || !u.avatar) return res.status(404).end();
|
||||
res.setHeader('Content-Type', u.avatarMime ?? 'image/png');
|
||||
res.end(Buffer.from(u.avatar));
|
||||
});
|
||||
Reference in New Issue
Block a user