Initial commit

This commit is contained in:
2026-02-07 11:03:53 -03:00
commit 84168f6f3c
64 changed files with 11402 additions and 0 deletions

27
qr-api/.dockerignore Normal file
View File

@@ -0,0 +1,27 @@
# Dependencies (reinstalled in image)
node_modules
.pnpm-store
# Build output (rebuilt in image)
dist
# Dev and test
coverage
*.test.ts
*.spec.ts
vitest.config.ts
.env
.env.*
!.env.example
# Git and IDE
.git
.gitignore
.dockerignore
*.md
Dockerfile
# Data (mounted at runtime)
*.sqlite
.data
uploads

17
qr-api/Dockerfile Normal file
View File

@@ -0,0 +1,17 @@
FROM node:20-alpine AS builder
RUN corepack enable && corepack prepare pnpm@latest --activate
WORKDIR /app
COPY package.json pnpm-lock.yaml* ./
RUN pnpm install
COPY . .
RUN pnpm run build
FROM node:20-alpine
RUN corepack enable && corepack prepare pnpm@latest --activate
WORKDIR /app
ENV NODE_ENV=production
COPY package.json pnpm-lock.yaml* ./
RUN pnpm install --prod
COPY --from=builder /app/dist ./dist
EXPOSE 8080
CMD ["node", "dist/index.js"]

40
qr-api/package.json Normal file
View File

@@ -0,0 +1,40 @@
{
"name": "qr-api",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"build": "tsc",
"start": "node dist/index.js",
"dev": "tsx watch src/index.ts",
"lint": "eslint src --ext .ts",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"better-sqlite3": "^11.6.0",
"cors": "^2.8.5",
"express": "^4.21.1",
"multer": "^1.4.5-lts.1",
"pino": "^9.5.0",
"pino-pretty": "^11.3.0",
"zod": "^3.23.8"
},
"devDependencies": {
"@types/better-sqlite3": "^7.6.11",
"@types/cors": "^2.8.17",
"@types/express": "^5.0.0",
"@types/multer": "^1.4.12",
"@types/node": "^22.9.0",
"@typescript-eslint/eslint-plugin": "^8.15.0",
"@typescript-eslint/parser": "^8.15.0",
"eslint": "^9.15.0",
"eslint-config-prettier": "^9.1.0",
"tsx": "^4.19.2",
"typescript": "^5.6.3",
"vitest": "^2.1.6"
},
"engines": {
"node": ">=20"
}
}

65
qr-api/src/db.test.ts Normal file
View 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
View 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
View 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
View 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');
});

View 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;
}

View 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;
}

View 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;
}

View 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;
}

View 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
View 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
View 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'));
}
},
});
}

17
qr-api/tsconfig.json Normal file
View File

@@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "dist",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true
},
"include": ["src/**/*.ts"],
"exclude": ["node_modules", "dist", "**/*.test.ts"]
}

8
qr-api/vitest.config.ts Normal file
View File

@@ -0,0 +1,8 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: false,
environment: 'node',
},
});