codigo0/backend/src/routes/media.ts

278 lines
7.6 KiB
TypeScript
Raw Normal View History

2026-01-19 08:10:16 +00:00
/**
* Rutas para gestión de recursos multimedia
*/
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';
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) => {
// Permitir imágenes y vídeos
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') as any, false);
}
},
});
// 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 { title, description, alt_text, tags, block, chapter, priority } = req.body;
const file = req.file;
// Determinar tipo
const type = file.mimetype.startsWith('image/') ? 'image' : 'video';
// Generar nombre único
const ext = file.originalname.split('.').pop();
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 || file.originalname,
description || null,
alt_text || null,
tags ? (Array.isArray(tags) ? tags : String(tags).split(',').map((t: string) => t.trim())) : [],
block || null,
chapter || null,
priority || 'media',
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) {
console.error('Error listando recursos:', error);
res.status(500).json({ error: 'Error interno del servidor' });
}
});
/**
* 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) {
res.status(404).json({ error: '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) {
console.error('Error obteniendo recursos huérfanos:', error);
res.status(500).json({ error: 'Error interno del servidor' });
}
});
export default router;