302 lines
8.5 KiB
TypeScript
302 lines
8.5 KiB
TypeScript
/**
|
|
* Rutas para gestión de recursos multimedia
|
|
* TICKET-015: upload con validación Zod, sanitización de nombre, fileFilter tipado
|
|
*/
|
|
|
|
import express, { Response } from 'express';
|
|
import { authenticate, requirePermission, AuthRequest } from '../middleware/auth.js';
|
|
import { query } from '../../config/database.js';
|
|
import multer from 'multer';
|
|
import { join } from 'path';
|
|
import { mkdir, writeFile, unlink, stat, readFile } from 'fs/promises';
|
|
import { createHash } from 'crypto';
|
|
import { uploadMediaBodySchema } from '../shared/schemas/media.js';
|
|
import { sendServerError, sendNotFound } from '../utils/http-responses.js';
|
|
|
|
const router = express.Router();
|
|
|
|
router.use(authenticate);
|
|
|
|
// Configurar multer para upload
|
|
const storageDir = process.env.MEDIA_STORAGE_DIR || join(process.cwd(), 'storage', 'media');
|
|
const upload = multer({
|
|
dest: storageDir,
|
|
limits: {
|
|
fileSize: 50 * 1024 * 1024, // 50MB
|
|
},
|
|
fileFilter: (_req, file, cb) => {
|
|
const allowedMimes = [
|
|
'image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/svg+xml',
|
|
'video/mp4', 'video/webm', 'video/ogg',
|
|
];
|
|
if (allowedMimes.includes(file.mimetype)) {
|
|
cb(null, true);
|
|
} else {
|
|
cb(new Error('Tipo de archivo no permitido'));
|
|
}
|
|
},
|
|
});
|
|
|
|
/** Sanitiza extensión: solo alfanuméricos (evita path traversal y caracteres raros) */
|
|
function safeExtension(originalName: string): string {
|
|
const ext = originalName.split('.').pop()?.toLowerCase() ?? '';
|
|
return /^[a-z0-9]+$/.test(ext) ? ext : 'bin';
|
|
}
|
|
|
|
// Asegurar que existe el directorio
|
|
mkdir(storageDir, { recursive: true }).catch(console.error);
|
|
|
|
/**
|
|
* POST /api/media/upload
|
|
* Subir un recurso multimedia
|
|
*/
|
|
router.post('/upload', requirePermission('content:write'), upload.single('file'), async (req: AuthRequest, res: Response) => {
|
|
try {
|
|
if (!req.file) {
|
|
res.status(400).json({ error: 'No se proporcionó archivo' });
|
|
return;
|
|
}
|
|
|
|
const parsed = uploadMediaBodySchema.safeParse(req.body);
|
|
const meta = parsed.success ? parsed.data : {};
|
|
const title = meta.title ?? req.file.originalname;
|
|
const description = meta.description ?? null;
|
|
const alt_text = meta.alt_text ?? null;
|
|
const tags = Array.isArray(meta.tags) ? meta.tags : [];
|
|
const block = meta.block ?? null;
|
|
const chapter = meta.chapter ?? null;
|
|
const priority = meta.priority ?? 'media';
|
|
|
|
if (!parsed.success) {
|
|
res.status(400).json({
|
|
error: 'Error de validación',
|
|
details: parsed.error.issues.map((i) => ({ path: i.path.join('.'), message: i.message })),
|
|
});
|
|
return;
|
|
}
|
|
|
|
const file = req.file;
|
|
|
|
// Determinar tipo
|
|
const type = file.mimetype.startsWith('image/') ? 'image' : 'video';
|
|
|
|
// Generar nombre único y sanitizar extensión (TICKET-015)
|
|
const rawExt = file.originalname.split('.').pop() ?? '';
|
|
const ext = safeExtension(file.originalname);
|
|
const hash = createHash('md5').update(file.path + Date.now()).digest('hex').substring(0, 8);
|
|
const filename = `${hash}.${ext}`;
|
|
const finalPath = join(storageDir, filename);
|
|
|
|
// Mover archivo a nombre final
|
|
await writeFile(finalPath, await readFile(file.path));
|
|
await unlink(file.path);
|
|
|
|
// URL pública
|
|
const fileUrl = `/storage/media/${filename}`;
|
|
|
|
// Obtener metadata del archivo
|
|
const stats = await stat(finalPath);
|
|
// width, height, duration se pueden obtener con sharp o similar en el futuro
|
|
|
|
// Insertar en BD
|
|
const result = await query(`
|
|
INSERT INTO tes_content.media_resources (
|
|
type, path, filename, file_url, title, description, alt_text,
|
|
tags, block, chapter, priority, file_size, format, status
|
|
) VALUES (
|
|
$1::tes_content.media_type,
|
|
$2,
|
|
$3,
|
|
$4,
|
|
$5,
|
|
$6,
|
|
$7,
|
|
$8::text[],
|
|
$9,
|
|
$10,
|
|
$11::tes_content.priority,
|
|
$12,
|
|
$13,
|
|
'published'::tes_content.content_status
|
|
)
|
|
RETURNING id, type, file_url, title, created_at
|
|
`, [
|
|
type,
|
|
finalPath,
|
|
filename,
|
|
fileUrl,
|
|
title,
|
|
description,
|
|
alt_text,
|
|
tags,
|
|
block,
|
|
chapter,
|
|
priority,
|
|
stats.size,
|
|
ext,
|
|
]);
|
|
|
|
res.json({
|
|
success: true,
|
|
resource: result.rows[0],
|
|
});
|
|
} catch (error) {
|
|
console.error('Error subiendo recurso:', error);
|
|
const errorMessage = error instanceof Error ? error.message : 'Error desconocido';
|
|
res.status(500).json({
|
|
error: 'Error subiendo recurso',
|
|
message: errorMessage
|
|
});
|
|
}
|
|
});
|
|
|
|
/**
|
|
* GET /api/media
|
|
* Listar recursos multimedia
|
|
*/
|
|
router.get('/', requirePermission('content:read'), async (req: AuthRequest, res: Response) => {
|
|
try {
|
|
const { type, search, page = '1', pageSize = '20' } = req.query;
|
|
|
|
const pageNum = typeof page === 'string' ? parseInt(page) : 1;
|
|
const pageSizeNum = typeof pageSize === 'string' ? parseInt(pageSize) : 20;
|
|
|
|
let whereConditions: string[] = [];
|
|
let params: any[] = [];
|
|
let paramIndex = 1;
|
|
|
|
if (type) {
|
|
whereConditions.push(`type = $${paramIndex++}`);
|
|
params.push(type);
|
|
}
|
|
|
|
if (search) {
|
|
whereConditions.push(`(title ILIKE $${paramIndex} OR description ILIKE $${paramIndex} OR alt_text ILIKE $${paramIndex})`);
|
|
params.push(`%${search}%`);
|
|
paramIndex++;
|
|
}
|
|
|
|
const whereClause = whereConditions.length > 0
|
|
? `WHERE ${whereConditions.join(' AND ')}`
|
|
: '';
|
|
|
|
// Contar total
|
|
const countResult = await query(
|
|
`SELECT COUNT(*) as total FROM tes_content.media_resources ${whereClause}`,
|
|
params
|
|
);
|
|
const total = parseInt(countResult.rows[0].total);
|
|
|
|
// Obtener items
|
|
const offset = (pageNum - 1) * pageSizeNum;
|
|
params.push(pageSizeNum, offset);
|
|
const itemsResult = await query(
|
|
`SELECT id, type, file_url, title, description, alt_text, tags,
|
|
priority, status, file_size, format, created_at, updated_at
|
|
FROM tes_content.media_resources
|
|
${whereClause}
|
|
ORDER BY created_at DESC
|
|
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`,
|
|
params
|
|
);
|
|
|
|
res.json({
|
|
items: itemsResult.rows,
|
|
total,
|
|
page: pageNum,
|
|
pageSize: pageSizeNum,
|
|
});
|
|
} catch (error) {
|
|
sendServerError(res, error, undefined, { endpoint: '/api/media', method: 'GET' });
|
|
}
|
|
});
|
|
|
|
/**
|
|
* GET /api/media/:id
|
|
* Obtener un recurso específico
|
|
*/
|
|
router.get('/:id', requirePermission('content:read'), async (req: AuthRequest, res: Response) => {
|
|
try {
|
|
const { id } = req.params;
|
|
const result = await query(
|
|
`SELECT * FROM tes_content.media_resources WHERE id = $1`,
|
|
[id]
|
|
);
|
|
|
|
if (result.rows.length === 0) {
|
|
res.status(404).json({ error: 'Recurso no encontrado' });
|
|
return;
|
|
}
|
|
|
|
res.json(result.rows[0]);
|
|
return;
|
|
} catch (error) {
|
|
console.error('Error obteniendo recurso:', error);
|
|
res.status(500).json({ error: 'Error interno del servidor' });
|
|
}
|
|
});
|
|
|
|
/**
|
|
* DELETE /api/media/:id
|
|
* Eliminar un recurso
|
|
*/
|
|
router.delete('/:id', requirePermission('content:write'), async (req: AuthRequest, res: Response) => {
|
|
try {
|
|
const { id } = req.params;
|
|
|
|
// Obtener información del archivo
|
|
const result = await query(
|
|
`SELECT path FROM tes_content.media_resources WHERE id = $1`,
|
|
[id]
|
|
);
|
|
|
|
if (result.rows.length === 0) {
|
|
sendNotFound(res, 'Recurso no encontrado');
|
|
return;
|
|
}
|
|
|
|
// Eliminar archivo físico
|
|
try {
|
|
await unlink(result.rows[0].path);
|
|
} catch (error) {
|
|
console.warn('No se pudo eliminar archivo físico:', error);
|
|
}
|
|
|
|
// Eliminar de BD
|
|
await query(
|
|
`DELETE FROM tes_content.media_resources WHERE id = $1`,
|
|
[id]
|
|
);
|
|
|
|
res.json({ success: true });
|
|
return;
|
|
} catch (error) {
|
|
console.error('Error eliminando recurso:', error);
|
|
res.status(500).json({ error: 'Error interno del servidor' });
|
|
}
|
|
});
|
|
|
|
/**
|
|
* GET /api/media/orphaned
|
|
* Obtener recursos huérfanos (sin asociaciones)
|
|
*/
|
|
router.get('/orphaned/list', requirePermission('content:read'), async (_req: AuthRequest, res: Response) => {
|
|
try {
|
|
const result = await query(`
|
|
SELECT mr.*
|
|
FROM tes_content.media_resources mr
|
|
LEFT JOIN tes_content.content_resource_associations cra ON mr.id = cra.media_resource_id
|
|
WHERE cra.id IS NULL
|
|
AND mr.status = 'published'
|
|
ORDER BY mr.created_at DESC
|
|
`);
|
|
|
|
res.json({ items: result.rows, total: result.rows.length });
|
|
} catch (error) {
|
|
sendServerError(res, error, undefined, { endpoint: '/api/media/orphaned/list', method: 'GET' });
|
|
}
|
|
});
|
|
|
|
export default router;
|
|
|