Initial commit
This commit is contained in:
65
qr-api/src/db.test.ts
Normal file
65
qr-api/src/db.test.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import Database from 'better-sqlite3';
|
||||
import {
|
||||
initDb,
|
||||
createProject,
|
||||
listProjects,
|
||||
getProject,
|
||||
updateProject,
|
||||
deleteProject,
|
||||
} from './db.js';
|
||||
|
||||
const testEnv = {
|
||||
DB_PATH: ':memory:',
|
||||
UPLOADS_PATH: '/tmp',
|
||||
PORT: 8080,
|
||||
KUTT_BASE_URL: 'http://kutt:3000',
|
||||
SHORT_DOMAIN: 'https://mifi.me',
|
||||
};
|
||||
|
||||
describe('db', () => {
|
||||
let db: Database.Database;
|
||||
|
||||
beforeEach(() => {
|
||||
db = initDb(testEnv as Parameters<typeof initDb>[0]);
|
||||
});
|
||||
|
||||
it('creates and gets a project', () => {
|
||||
const p = createProject(db, { name: 'Test', originalUrl: 'https://example.com' });
|
||||
expect(p.id).toBeDefined();
|
||||
expect(p.name).toBe('Test');
|
||||
expect(p.originalUrl).toBe('https://example.com');
|
||||
const got = getProject(db, p.id);
|
||||
expect(got?.name).toBe('Test');
|
||||
});
|
||||
|
||||
it('lists projects by updatedAt desc', async () => {
|
||||
createProject(db, { name: 'A' });
|
||||
await new Promise((r) => setTimeout(r, 2));
|
||||
createProject(db, { name: 'B' });
|
||||
const list = listProjects(db);
|
||||
expect(list.length).toBe(2);
|
||||
expect(list[0].name).toBe('B');
|
||||
expect(list[1].name).toBe('A');
|
||||
});
|
||||
|
||||
it('updates a project', () => {
|
||||
const p = createProject(db, { name: 'Old' });
|
||||
const updated = updateProject(db, p.id, { name: 'New', recipeJson: '{"x":1}' });
|
||||
expect(updated?.name).toBe('New');
|
||||
expect(updated?.recipeJson).toBe('{"x":1}');
|
||||
});
|
||||
|
||||
it('deletes a project', () => {
|
||||
const p = createProject(db, { name: 'Del' });
|
||||
const deleted = deleteProject(db, p.id);
|
||||
expect(deleted).toBe(true);
|
||||
expect(getProject(db, p.id)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for missing project', () => {
|
||||
expect(getProject(db, '00000000-0000-0000-0000-000000000000')).toBeNull();
|
||||
expect(updateProject(db, '00000000-0000-0000-0000-000000000000', { name: 'X' })).toBeNull();
|
||||
expect(deleteProject(db, '00000000-0000-0000-0000-000000000000')).toBe(false);
|
||||
});
|
||||
});
|
||||
160
qr-api/src/db.ts
Normal file
160
qr-api/src/db.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
import Database from 'better-sqlite3';
|
||||
import { randomUUID } from 'crypto';
|
||||
import type { Env } from './env.js';
|
||||
|
||||
export interface Project {
|
||||
id: string;
|
||||
name: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
originalUrl: string;
|
||||
shortenEnabled: number;
|
||||
shortUrl: string | null;
|
||||
recipeJson: string;
|
||||
logoFilename: string | null;
|
||||
folderId: string | null;
|
||||
}
|
||||
|
||||
export interface Folder {
|
||||
id: string;
|
||||
name: string;
|
||||
sortOrder: number;
|
||||
}
|
||||
|
||||
export function initDb(env: Env): Database.Database {
|
||||
const db = new Database(env.DB_PATH);
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS projects (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL DEFAULT 'Untitled QR',
|
||||
createdAt TEXT NOT NULL,
|
||||
updatedAt TEXT NOT NULL,
|
||||
originalUrl TEXT NOT NULL DEFAULT '',
|
||||
shortenEnabled INTEGER NOT NULL DEFAULT 0,
|
||||
shortUrl TEXT,
|
||||
recipeJson TEXT NOT NULL DEFAULT '{}',
|
||||
logoFilename TEXT,
|
||||
folderId TEXT
|
||||
)
|
||||
`);
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS folders (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL DEFAULT 'Folder',
|
||||
sortOrder INTEGER NOT NULL DEFAULT 0
|
||||
)
|
||||
`);
|
||||
try {
|
||||
db.exec(`ALTER TABLE projects ADD COLUMN folderId TEXT`);
|
||||
} catch {
|
||||
// column already exists (existing DB)
|
||||
}
|
||||
return db;
|
||||
}
|
||||
|
||||
export function createProject(
|
||||
db: Database.Database,
|
||||
data: Partial<Omit<Project, 'id' | 'createdAt' | 'updatedAt'>>,
|
||||
): Project {
|
||||
const id = randomUUID();
|
||||
const now = new Date().toISOString();
|
||||
const name = data.name ?? 'Untitled QR';
|
||||
const originalUrl = data.originalUrl ?? '';
|
||||
const shortenEnabled = data.shortenEnabled ?? 0;
|
||||
const shortUrl = data.shortUrl ?? null;
|
||||
const recipeJson = data.recipeJson ?? '{}';
|
||||
const logoFilename = data.logoFilename ?? null;
|
||||
const folderId = data.folderId ?? null;
|
||||
|
||||
db.prepare(
|
||||
`INSERT INTO projects (id, name, createdAt, updatedAt, originalUrl, shortenEnabled, shortUrl, recipeJson, logoFilename, folderId)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
).run(id, name, now, now, originalUrl, shortenEnabled, shortUrl, recipeJson, logoFilename, folderId);
|
||||
|
||||
return getProject(db, id)!;
|
||||
}
|
||||
|
||||
export function listProjects(db: Database.Database): Omit<Project, 'recipeJson' | 'originalUrl' | 'shortUrl' | 'shortenEnabled'>[] {
|
||||
const rows = db.prepare(
|
||||
'SELECT id, name, createdAt, updatedAt, logoFilename, folderId FROM projects ORDER BY updatedAt DESC, createdAt DESC, id DESC',
|
||||
).all() as Array<{ id: string; name: string; createdAt: string; updatedAt: string; logoFilename: string | null; folderId: string | null }>;
|
||||
return rows;
|
||||
}
|
||||
|
||||
export function getProject(db: Database.Database, id: string): Project | null {
|
||||
const row = db.prepare('SELECT * FROM projects WHERE id = ?').get(id) as Project | undefined;
|
||||
return row ?? null;
|
||||
}
|
||||
|
||||
export function updateProject(
|
||||
db: Database.Database,
|
||||
id: string,
|
||||
data: Partial<Omit<Project, 'id' | 'createdAt'>>,
|
||||
): Project | null {
|
||||
const existing = getProject(db, id);
|
||||
if (!existing) return null;
|
||||
|
||||
const updatedAt = new Date().toISOString();
|
||||
const name = data.name ?? existing.name;
|
||||
const originalUrl = data.originalUrl ?? existing.originalUrl;
|
||||
const shortenEnabled = data.shortenEnabled ?? existing.shortenEnabled;
|
||||
const shortUrl = data.shortUrl !== undefined ? data.shortUrl : existing.shortUrl;
|
||||
const recipeJson = data.recipeJson ?? existing.recipeJson;
|
||||
const logoFilename = data.logoFilename !== undefined ? data.logoFilename : existing.logoFilename;
|
||||
const folderId = data.folderId !== undefined ? data.folderId : existing.folderId;
|
||||
|
||||
db.prepare(
|
||||
`UPDATE projects SET name = ?, updatedAt = ?, originalUrl = ?, shortenEnabled = ?, shortUrl = ?, recipeJson = ?, logoFilename = ?, folderId = ? WHERE id = ?`,
|
||||
).run(name, updatedAt, originalUrl, shortenEnabled, shortUrl, recipeJson, logoFilename, folderId, id);
|
||||
|
||||
return getProject(db, id);
|
||||
}
|
||||
|
||||
export function deleteProject(db: Database.Database, id: string): boolean {
|
||||
const result = db.prepare('DELETE FROM projects WHERE id = ?').run(id);
|
||||
return result.changes > 0;
|
||||
}
|
||||
|
||||
export function listFolders(db: Database.Database): Folder[] {
|
||||
const rows = db.prepare(
|
||||
'SELECT id, name, sortOrder FROM folders ORDER BY sortOrder ASC, name ASC',
|
||||
).all() as Folder[];
|
||||
return rows;
|
||||
}
|
||||
|
||||
export function createFolder(
|
||||
db: Database.Database,
|
||||
data: { name?: string; sortOrder?: number },
|
||||
): Folder {
|
||||
const id = randomUUID();
|
||||
const name = data.name ?? 'Folder';
|
||||
const sortOrder = data.sortOrder ?? listFolders(db).length;
|
||||
db.prepare(
|
||||
'INSERT INTO folders (id, name, sortOrder) VALUES (?, ?, ?)',
|
||||
).run(id, name, sortOrder);
|
||||
return { id, name, sortOrder };
|
||||
}
|
||||
|
||||
export function getFolder(db: Database.Database, id: string): Folder | null {
|
||||
const row = db.prepare('SELECT id, name, sortOrder FROM folders WHERE id = ?').get(id) as Folder | undefined;
|
||||
return row ?? null;
|
||||
}
|
||||
|
||||
export function updateFolder(
|
||||
db: Database.Database,
|
||||
id: string,
|
||||
data: Partial<Pick<Folder, 'name' | 'sortOrder'>>,
|
||||
): Folder | null {
|
||||
const existing = getFolder(db, id);
|
||||
if (!existing) return null;
|
||||
const name = data.name ?? existing.name;
|
||||
const sortOrder = data.sortOrder ?? existing.sortOrder;
|
||||
db.prepare('UPDATE folders SET name = ?, sortOrder = ? WHERE id = ?').run(name, sortOrder, id);
|
||||
return getFolder(db, id);
|
||||
}
|
||||
|
||||
export function deleteFolder(db: Database.Database, id: string): boolean {
|
||||
db.prepare('UPDATE projects SET folderId = NULL WHERE folderId = ?').run(id);
|
||||
const result = db.prepare('DELETE FROM folders WHERE id = ?').run(id);
|
||||
return result.changes > 0;
|
||||
}
|
||||
20
qr-api/src/env.ts
Normal file
20
qr-api/src/env.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
const envSchema = z.object({
|
||||
PORT: z.coerce.number().default(8080),
|
||||
DB_PATH: z.string().default('/data/db.sqlite'),
|
||||
UPLOADS_PATH: z.string().default('/uploads'),
|
||||
KUTT_API_KEY: z.string().optional(),
|
||||
KUTT_BASE_URL: z.string().url().default('http://kutt:3000'),
|
||||
SHORT_DOMAIN: z.string().default('https://mifi.me'),
|
||||
});
|
||||
|
||||
export type Env = z.infer<typeof envSchema>;
|
||||
|
||||
export function loadEnv(): Env {
|
||||
const parsed = envSchema.safeParse(process.env);
|
||||
if (!parsed.success) {
|
||||
throw new Error('Invalid env: ' + parsed.error.message);
|
||||
}
|
||||
return parsed.data;
|
||||
}
|
||||
55
qr-api/src/index.ts
Normal file
55
qr-api/src/index.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import express from 'express';
|
||||
import cors from 'cors';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import pino from 'pino';
|
||||
import { loadEnv } from './env.js';
|
||||
import { initDb } from './db.js';
|
||||
import { projectsRouter } from './routes/projects.js';
|
||||
import { foldersRouter } from './routes/folders.js';
|
||||
import { uploadsRouter } from './routes/uploads.js';
|
||||
import { shortenRouter } from './routes/shorten.js';
|
||||
|
||||
const env = loadEnv();
|
||||
const logger = pino({ level: process.env.LOG_LEVEL ?? 'info' });
|
||||
|
||||
// Ensure data dirs exist
|
||||
const dataDir = path.dirname(env.DB_PATH);
|
||||
const uploadsDir = env.UPLOADS_PATH;
|
||||
for (const dir of [dataDir, uploadsDir]) {
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
logger.info({ dir }, 'Created directory');
|
||||
}
|
||||
}
|
||||
|
||||
const db = initDb(env);
|
||||
|
||||
const app = express();
|
||||
app.use(cors());
|
||||
app.use(express.json());
|
||||
|
||||
const baseUrl = ''; // relative; Next.js proxy will use same origin for /api
|
||||
|
||||
app.use('/projects', projectsRouter(db, baseUrl));
|
||||
app.use('/folders', foldersRouter(db));
|
||||
app.use('/uploads', uploadsRouter(env, baseUrl));
|
||||
app.use('/shorten', shortenRouter(env));
|
||||
|
||||
app.get('/health', (_req, res) => {
|
||||
res.json({ status: 'ok' });
|
||||
});
|
||||
|
||||
app.use((err: Error, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
|
||||
const msg = err.message ?? '';
|
||||
if (err.name === 'MulterError' || msg.includes('image files') || msg.includes('file size')) {
|
||||
return res.status(400).json({ error: msg || 'Invalid upload' });
|
||||
}
|
||||
logger.error({ err }, 'Unhandled error');
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
});
|
||||
|
||||
const port = env.PORT;
|
||||
app.listen(port, () => {
|
||||
logger.info({ port }, 'qr-api listening');
|
||||
});
|
||||
86
qr-api/src/routes/folders.ts
Normal file
86
qr-api/src/routes/folders.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import { Router, type Request, type Response } from 'express';
|
||||
import { z } from 'zod';
|
||||
import type { Database } from 'better-sqlite3';
|
||||
import {
|
||||
listFolders,
|
||||
createFolder,
|
||||
getFolder,
|
||||
updateFolder,
|
||||
deleteFolder,
|
||||
} from '../db.js';
|
||||
|
||||
const createBodySchema = z.object({
|
||||
name: z.string().optional(),
|
||||
sortOrder: z.number().optional(),
|
||||
});
|
||||
|
||||
const updateBodySchema = createBodySchema.partial();
|
||||
const idParamSchema = z.object({ id: z.string().uuid() });
|
||||
|
||||
export function foldersRouter(db: Database) {
|
||||
const router = Router();
|
||||
|
||||
router.get('/', (_req: Request, res: Response) => {
|
||||
try {
|
||||
const folders = listFolders(db);
|
||||
return res.json(folders);
|
||||
} catch (e) {
|
||||
return res.status(500).json({ error: String(e) });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/', (req: Request, res: Response) => {
|
||||
try {
|
||||
const parsed = createBodySchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
return res.status(400).json({ error: parsed.error.message });
|
||||
}
|
||||
const folder = createFolder(db, parsed.data);
|
||||
return res.status(201).json(folder);
|
||||
} catch (e) {
|
||||
return res.status(500).json({ error: String(e) });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/:id', (req: Request, res: Response) => {
|
||||
const parsed = idParamSchema.safeParse(req.params);
|
||||
if (!parsed.success) {
|
||||
return res.status(400).json({ error: 'Invalid id' });
|
||||
}
|
||||
const folder = getFolder(db, parsed.data.id);
|
||||
if (!folder) {
|
||||
return res.status(404).json({ error: 'Not found' });
|
||||
}
|
||||
return res.json(folder);
|
||||
});
|
||||
|
||||
router.put('/:id', (req: Request, res: Response) => {
|
||||
const paramParsed = idParamSchema.safeParse(req.params);
|
||||
if (!paramParsed.success) {
|
||||
return res.status(400).json({ error: 'Invalid id' });
|
||||
}
|
||||
const bodyParsed = updateBodySchema.safeParse(req.body);
|
||||
if (!bodyParsed.success) {
|
||||
return res.status(400).json({ error: bodyParsed.error.message });
|
||||
}
|
||||
const folder = updateFolder(db, paramParsed.data.id, bodyParsed.data);
|
||||
if (!folder) {
|
||||
return res.status(404).json({ error: 'Not found' });
|
||||
}
|
||||
return res.json(folder);
|
||||
});
|
||||
|
||||
router.delete('/:id', (req: Request, res: Response) => {
|
||||
const parsed = idParamSchema.safeParse(req.params);
|
||||
if (!parsed.success) {
|
||||
return res.status(400).json({ error: 'Invalid id' });
|
||||
}
|
||||
const deleted = deleteFolder(db, parsed.data.id);
|
||||
if (!deleted) {
|
||||
return res.status(404).json({ error: 'Not found' });
|
||||
}
|
||||
return res.status(204).send();
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
125
qr-api/src/routes/projects.ts
Normal file
125
qr-api/src/routes/projects.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
import { Router, type Request, type Response } from 'express';
|
||||
import { z } from 'zod';
|
||||
import type { Database } from 'better-sqlite3';
|
||||
import {
|
||||
createProject,
|
||||
getProject,
|
||||
listProjects,
|
||||
updateProject,
|
||||
deleteProject,
|
||||
} from '../db.js';
|
||||
|
||||
const createBodySchema = z.object({
|
||||
name: z.string().optional(),
|
||||
originalUrl: z.string().optional(),
|
||||
shortenEnabled: z.boolean().optional(),
|
||||
shortUrl: z.string().nullable().optional(),
|
||||
recipeJson: z.string().optional(),
|
||||
logoFilename: z.string().nullable().optional(),
|
||||
folderId: z.string().uuid().nullable().optional(),
|
||||
});
|
||||
|
||||
const updateBodySchema = createBodySchema.partial();
|
||||
|
||||
const idParamSchema = z.object({ id: z.string().uuid() });
|
||||
|
||||
export function projectsRouter(db: Database, baseUrl: string) {
|
||||
const router = Router();
|
||||
const toJson = (p: ReturnType<typeof getProject>) =>
|
||||
p
|
||||
? {
|
||||
...p,
|
||||
shortenEnabled: Boolean(p.shortenEnabled),
|
||||
logoUrl: p.logoFilename ? `${baseUrl}/uploads/${p.logoFilename}` : null,
|
||||
}
|
||||
: null;
|
||||
|
||||
router.post('/', (req: Request, res: Response) => {
|
||||
try {
|
||||
const parsed = createBodySchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
return res.status(400).json({ error: parsed.error.message });
|
||||
}
|
||||
const data = parsed.data;
|
||||
const project = createProject(db, {
|
||||
name: data.name,
|
||||
originalUrl: data.originalUrl,
|
||||
shortenEnabled: data.shortenEnabled ? 1 : 0,
|
||||
shortUrl: data.shortUrl ?? null,
|
||||
recipeJson: data.recipeJson ?? '{}',
|
||||
logoFilename: data.logoFilename ?? null,
|
||||
folderId: data.folderId ?? null,
|
||||
});
|
||||
return res.status(201).json(toJson(project));
|
||||
} catch (e) {
|
||||
return res.status(500).json({ error: String(e) });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/', (_req: Request, res: Response) => {
|
||||
try {
|
||||
const list = listProjects(db);
|
||||
const items = list.map((p) => ({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
updatedAt: p.updatedAt,
|
||||
logoUrl: p.logoFilename ? `${baseUrl}/uploads/${p.logoFilename}` : null,
|
||||
folderId: p.folderId ?? null,
|
||||
}));
|
||||
return res.json(items);
|
||||
} catch (e) {
|
||||
return res.status(500).json({ error: String(e) });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/:id', (req: Request, res: Response) => {
|
||||
const parsed = idParamSchema.safeParse(req.params);
|
||||
if (!parsed.success) {
|
||||
return res.status(400).json({ error: 'Invalid id' });
|
||||
}
|
||||
const project = getProject(db, parsed.data.id);
|
||||
if (!project) {
|
||||
return res.status(404).json({ error: 'Project not found' });
|
||||
}
|
||||
return res.json(toJson(project));
|
||||
});
|
||||
|
||||
router.put('/:id', (req: Request, res: Response) => {
|
||||
const paramParsed = idParamSchema.safeParse(req.params);
|
||||
if (!paramParsed.success) {
|
||||
return res.status(400).json({ error: 'Invalid id' });
|
||||
}
|
||||
const bodyParsed = updateBodySchema.safeParse(req.body);
|
||||
if (!bodyParsed.success) {
|
||||
return res.status(400).json({ error: bodyParsed.error.message });
|
||||
}
|
||||
const data = bodyParsed.data;
|
||||
const project = updateProject(db, paramParsed.data.id, {
|
||||
name: data.name,
|
||||
originalUrl: data.originalUrl,
|
||||
shortenEnabled: data.shortenEnabled !== undefined ? (data.shortenEnabled ? 1 : 0) : undefined,
|
||||
shortUrl: data.shortUrl,
|
||||
recipeJson: data.recipeJson,
|
||||
logoFilename: data.logoFilename,
|
||||
folderId: data.folderId,
|
||||
});
|
||||
if (!project) {
|
||||
return res.status(404).json({ error: 'Project not found' });
|
||||
}
|
||||
return res.json(toJson(project));
|
||||
});
|
||||
|
||||
router.delete('/:id', (req: Request, res: Response) => {
|
||||
const parsed = idParamSchema.safeParse(req.params);
|
||||
if (!parsed.success) {
|
||||
return res.status(400).json({ error: 'Invalid id' });
|
||||
}
|
||||
const deleted = deleteProject(db, parsed.data.id);
|
||||
if (!deleted) {
|
||||
return res.status(404).json({ error: 'Project not found' });
|
||||
}
|
||||
return res.status(204).send();
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
32
qr-api/src/routes/shorten.ts
Normal file
32
qr-api/src/routes/shorten.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { Router, type Request, type Response } from 'express';
|
||||
import { z } from 'zod';
|
||||
import type { Env } from '../env.js';
|
||||
import { shortenUrl } from '../shorten.js';
|
||||
|
||||
const bodySchema = z.object({
|
||||
targetUrl: z.string().url(),
|
||||
customSlug: z.string().optional(),
|
||||
});
|
||||
|
||||
export function shortenRouter(env: Env) {
|
||||
const router = Router();
|
||||
|
||||
router.post('/', async (req: Request, res: Response) => {
|
||||
const parsed = bodySchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
return res.status(400).json({ error: parsed.error.message });
|
||||
}
|
||||
try {
|
||||
const result = await shortenUrl(env, parsed.data);
|
||||
return res.json(result);
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
if (msg.includes('KUTT_API_KEY')) {
|
||||
return res.status(503).json({ error: msg });
|
||||
}
|
||||
return res.status(502).json({ error: msg });
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
35
qr-api/src/routes/uploads.ts
Normal file
35
qr-api/src/routes/uploads.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { Router, type Request, type Response } from 'express';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import type { Env } from '../env.js';
|
||||
import { createMulter } from '../upload.js';
|
||||
|
||||
export function uploadsRouter(env: Env, baseUrl: string) {
|
||||
const router = Router();
|
||||
const upload = createMulter(env);
|
||||
|
||||
router.post('/logo', upload.single('file'), (req: Request, res: Response) => {
|
||||
if (!req.file) {
|
||||
return res.status(400).json({ error: 'No file uploaded' });
|
||||
}
|
||||
const filename = req.file.filename;
|
||||
const url = `${baseUrl}/uploads/${filename}`;
|
||||
return res.json({ filename, url });
|
||||
});
|
||||
|
||||
router.get('/:filename', (req: Request, res: Response) => {
|
||||
const filename = req.params.filename;
|
||||
if (!filename || filename.includes('..') || path.isAbsolute(filename)) {
|
||||
return res.status(400).json({ error: 'Invalid filename' });
|
||||
}
|
||||
const filePath = path.join(env.UPLOADS_PATH, filename);
|
||||
if (!fs.existsSync(filePath) || !fs.statSync(filePath).isFile()) {
|
||||
return res.status(404).json({ error: 'Not found' });
|
||||
}
|
||||
return res.sendFile(path.resolve(filePath), (err) => {
|
||||
if (err) res.status(500).json({ error: String(err) });
|
||||
});
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
72
qr-api/src/shorten.test.ts
Normal file
72
qr-api/src/shorten.test.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { shortenUrl } from './shorten.js';
|
||||
|
||||
const env = {
|
||||
KUTT_API_KEY: 'test-key',
|
||||
KUTT_BASE_URL: 'http://kutt:3000',
|
||||
SHORT_DOMAIN: 'https://mifi.me',
|
||||
DB_PATH: '/data/db.sqlite',
|
||||
UPLOADS_PATH: '/uploads',
|
||||
PORT: 8080,
|
||||
};
|
||||
|
||||
describe('shortenUrl', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('fetch', vi.fn());
|
||||
});
|
||||
|
||||
it('calls Kutt API and returns shortUrl', async () => {
|
||||
(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ link: 'https://mifi.me/abc' }),
|
||||
});
|
||||
const result = await shortenUrl(env as Parameters<typeof shortenUrl>[0], {
|
||||
targetUrl: 'https://example.com',
|
||||
});
|
||||
expect(result.shortUrl).toBe('https://mifi.me/abc');
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
'http://kutt:3000/api/v2/links',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-API-Key': 'test-key' },
|
||||
body: JSON.stringify({ target: 'https://example.com' }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('sends customurl when customSlug provided', async () => {
|
||||
(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ link: 'https://mifi.me/myslug' }),
|
||||
});
|
||||
await shortenUrl(env as Parameters<typeof shortenUrl>[0], {
|
||||
targetUrl: 'https://example.com',
|
||||
customSlug: 'myslug',
|
||||
});
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.objectContaining({
|
||||
body: JSON.stringify({ target: 'https://example.com', customurl: 'myslug' }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('throws when KUTT_API_KEY is missing', async () => {
|
||||
await expect(
|
||||
shortenUrl({ ...env, KUTT_API_KEY: undefined } as Parameters<typeof shortenUrl>[0], {
|
||||
targetUrl: 'https://example.com',
|
||||
}),
|
||||
).rejects.toThrow('KUTT_API_KEY');
|
||||
});
|
||||
|
||||
it('throws when Kutt returns non-ok', async () => {
|
||||
(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 400,
|
||||
text: () => Promise.resolve('Bad request'),
|
||||
});
|
||||
await expect(
|
||||
shortenUrl(env as Parameters<typeof shortenUrl>[0], { targetUrl: 'https://example.com' }),
|
||||
).rejects.toThrow(/400/);
|
||||
});
|
||||
});
|
||||
43
qr-api/src/shorten.ts
Normal file
43
qr-api/src/shorten.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import type { Env } from './env.js';
|
||||
|
||||
export interface ShortenBody {
|
||||
targetUrl: string;
|
||||
customSlug?: string;
|
||||
}
|
||||
|
||||
export interface ShortenResult {
|
||||
shortUrl: string;
|
||||
}
|
||||
|
||||
export async function shortenUrl(env: Env, body: ShortenBody): Promise<ShortenResult> {
|
||||
if (!env.KUTT_API_KEY) {
|
||||
throw new Error('KUTT_API_KEY is not configured');
|
||||
}
|
||||
|
||||
const base = env.KUTT_BASE_URL.replace(/\/$/, '');
|
||||
const res = await fetch(`${base}/api/v2/links`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-API-Key': env.KUTT_API_KEY,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
target: body.targetUrl,
|
||||
...(body.customSlug && { customurl: body.customSlug }),
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(`Kutt API error ${res.status}: ${text}`);
|
||||
}
|
||||
|
||||
const data = (await res.json()) as { link?: string; id?: string };
|
||||
const link = data.link ?? (data.id ? `${env.SHORT_DOMAIN.replace(/\/$/, '')}/${data.id}` : null);
|
||||
if (!link) {
|
||||
throw new Error('Kutt API did not return a short URL');
|
||||
}
|
||||
|
||||
const shortUrl = link.startsWith('http') ? link : `${env.SHORT_DOMAIN.replace(/\/$/, '')}/${link}`;
|
||||
return { shortUrl };
|
||||
}
|
||||
27
qr-api/src/upload.ts
Normal file
27
qr-api/src/upload.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import multer from 'multer';
|
||||
import path from 'path';
|
||||
import { randomUUID } from 'crypto';
|
||||
import type { Env } from './env.js';
|
||||
|
||||
const IMAGE_MIME = ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/svg+xml'];
|
||||
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10 MB
|
||||
|
||||
export function createMulter(env: Env) {
|
||||
return multer({
|
||||
storage: multer.diskStorage({
|
||||
destination: (_req, _file, cb) => cb(null, env.UPLOADS_PATH),
|
||||
filename: (_req, file, cb) => {
|
||||
const ext = path.extname(file.originalname) || '.bin';
|
||||
cb(null, `${randomUUID()}${ext}`);
|
||||
},
|
||||
}),
|
||||
limits: { fileSize: MAX_FILE_SIZE },
|
||||
fileFilter: (_req, file, cb) => {
|
||||
if (IMAGE_MIME.includes(file.mimetype)) {
|
||||
cb(null, true);
|
||||
} else {
|
||||
cb(new Error('Only image files (jpeg, png, gif, webp) are allowed'));
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user