init commit

This commit is contained in:
2025-09-13 13:02:17 +08:00
commit c082a30d62
19 changed files with 952 additions and 0 deletions
+21
View File
@@ -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;
}
+7
View File
@@ -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');
}
+10
View File
@@ -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;
}
+32
View File
@@ -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'),
});
+32
View File
@@ -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);
}
}
+8
View File
@@ -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 });
}
+7
View File
@@ -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);
+122
View File
@@ -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));
});
+50
View File
@@ -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));
});