278 lines
7.6 KiB
TypeScript
278 lines
7.6 KiB
TypeScript
|
|
/**
|
||
|
|
* 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;
|
||
|
|
|