feat: add admin dashboard and core API routes with Docker configuration
This commit is contained in:
@@ -3,8 +3,23 @@
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { revalidatePath } from "next/cache";
|
||||
|
||||
function escapeHtml(value: string): string {
|
||||
return value
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
export async function reactivateUserAccount(userId: string, reason: string) {
|
||||
try {
|
||||
if (!userId || typeof reason !== "string" || reason.trim().length === 0) {
|
||||
return { success: false, error: "Paramètres invalides" };
|
||||
}
|
||||
|
||||
const sanitizedReason = reason.trim().slice(0, 1000);
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { id: true, name: true, email: true, deletedAt: true }
|
||||
@@ -23,24 +38,24 @@ export async function reactivateUserAccount(userId: string, reason: string) {
|
||||
data: {
|
||||
deletedAt: null,
|
||||
deletionScheduledAt: null,
|
||||
reactivationReason: reason
|
||||
reactivationReason: sanitizedReason
|
||||
}
|
||||
});
|
||||
|
||||
// Optionnel : Envoyer un mail de confirmation
|
||||
const settings = await prisma.siteSetting.findUnique({ where: { id: "singleton" } });
|
||||
if (settings?.reactivateAccountTemplate) {
|
||||
const { sendEmail } = await import("@/lib/mail");
|
||||
const html = settings.reactivateAccountTemplate
|
||||
.replace(/{name}/g, user.name)
|
||||
.replace(/{siteName}/g, settings.siteName)
|
||||
.replace(/{reason}/g, reason);
|
||||
|
||||
await sendEmail({
|
||||
to: user.email,
|
||||
subject: settings.reactivateAccountSubject,
|
||||
html
|
||||
});
|
||||
const { sendEmail } = await import("@/lib/mail");
|
||||
const html = settings.reactivateAccountTemplate
|
||||
.replace(/{name}/g, escapeHtml(user.name || ""))
|
||||
.replace(/{siteName}/g, escapeHtml(settings.siteName || "Afrohub"))
|
||||
.replace(/{reason}/g, escapeHtml(sanitizedReason));
|
||||
|
||||
await sendEmail({
|
||||
to: user.email,
|
||||
subject: settings.reactivateAccountSubject,
|
||||
html
|
||||
});
|
||||
}
|
||||
|
||||
revalidatePath("/");
|
||||
|
||||
@@ -3,6 +3,9 @@
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { ContentStatus } from "@prisma/client";
|
||||
import { getAuthUser } from "@/lib/auth-utils";
|
||||
import { sanitizeHTML } from "@/lib/sanitize";
|
||||
import { safeUrl } from "@/lib/utils";
|
||||
|
||||
export async function createEvent(data: {
|
||||
title: string;
|
||||
@@ -14,20 +17,33 @@ export async function createEvent(data: {
|
||||
tags?: string[];
|
||||
}) {
|
||||
try {
|
||||
const authUser = await getAuthUser();
|
||||
if (!authUser) {
|
||||
return { success: false, error: "Authentification requise" };
|
||||
}
|
||||
|
||||
const slug = data.title
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/(^-|-$)/g, '');
|
||||
|
||||
let validatedLink: string | null = null;
|
||||
if (data.link && data.link.trim()) {
|
||||
const checked = safeUrl(data.link.trim());
|
||||
if (checked !== '#') {
|
||||
validatedLink = checked;
|
||||
}
|
||||
}
|
||||
|
||||
const event = await prisma.event.create({
|
||||
data: {
|
||||
title: data.title,
|
||||
description: data.description,
|
||||
title: data.title.trim().slice(0, 200),
|
||||
description: sanitizeHTML(data.description || ''),
|
||||
date: new Date(data.date),
|
||||
location: data.location,
|
||||
location: data.location.trim().slice(0, 200),
|
||||
thumbnailUrl: data.thumbnailUrl,
|
||||
link: data.link || null,
|
||||
tags: data.tags || [],
|
||||
link: validatedLink,
|
||||
tags: Array.isArray(data.tags) ? data.tags.slice(0, 20) : [],
|
||||
status: 'PENDING' as ContentStatus, // Always pending for user submission
|
||||
slug: `${slug}-${Math.random().toString(36).substring(2, 7)}`
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { ContentStatus } from "@prisma/client";
|
||||
import { getAuthUser } from "@/lib/auth-utils";
|
||||
import { sanitizeHTML } from "@/lib/sanitize";
|
||||
|
||||
export async function createNews(data: {
|
||||
title: string;
|
||||
@@ -14,6 +16,11 @@ export async function createNews(data: {
|
||||
sources?: any;
|
||||
}) {
|
||||
try {
|
||||
const authUser = await getAuthUser();
|
||||
if (!authUser) {
|
||||
return { success: false, error: "Authentification requise" };
|
||||
}
|
||||
|
||||
const slug = data.title
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
@@ -21,12 +28,12 @@ export async function createNews(data: {
|
||||
|
||||
const post = await prisma.blogPost.create({
|
||||
data: {
|
||||
title: data.title,
|
||||
excerpt: data.excerpt,
|
||||
content: data.content,
|
||||
author: data.author,
|
||||
title: data.title.trim().slice(0, 200),
|
||||
excerpt: data.excerpt.trim().slice(0, 500),
|
||||
content: sanitizeHTML(data.content || ''),
|
||||
author: data.author.trim().slice(0, 100),
|
||||
imageUrl: data.imageUrl,
|
||||
tags: data.tags || [],
|
||||
tags: Array.isArray(data.tags) ? data.tags.slice(0, 20) : [],
|
||||
sources: data.sources || [],
|
||||
status: 'PENDING' as ContentStatus, // Always pending for user submission
|
||||
slug: `${slug}-${Math.random().toString(36).substring(2, 7)}`
|
||||
|
||||
@@ -1,34 +1,104 @@
|
||||
'use server';
|
||||
|
||||
import { getAuthUser } from '@/lib/auth-utils';
|
||||
|
||||
const MAX_UPLOAD_SIZE_BYTES = 5 * 1024 * 1024; // 5 MB
|
||||
|
||||
function detectSafeImageMimeType(buffer: Buffer): string | null {
|
||||
if (buffer.length < 12) return null;
|
||||
|
||||
// JPEG: FF D8 FF
|
||||
if (buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff) {
|
||||
return 'image/jpeg';
|
||||
}
|
||||
|
||||
// PNG: 89 50 4E 47 0D 0A 1A 0A
|
||||
if (
|
||||
buffer[0] === 0x89 &&
|
||||
buffer[1] === 0x50 &&
|
||||
buffer[2] === 0x4e &&
|
||||
buffer[3] === 0x47 &&
|
||||
buffer[4] === 0x0d &&
|
||||
buffer[5] === 0x0a &&
|
||||
buffer[6] === 0x1a &&
|
||||
buffer[7] === 0x0a
|
||||
) {
|
||||
return 'image/png';
|
||||
}
|
||||
|
||||
// GIF: GIF87a or GIF89a
|
||||
if (
|
||||
buffer[0] === 0x47 &&
|
||||
buffer[1] === 0x49 &&
|
||||
buffer[2] === 0x46 &&
|
||||
buffer[3] === 0x38 &&
|
||||
(buffer[4] === 0x37 || buffer[4] === 0x39) &&
|
||||
buffer[5] === 0x61
|
||||
) {
|
||||
return 'image/gif';
|
||||
}
|
||||
|
||||
// WebP: RIFF....WEBP
|
||||
if (
|
||||
buffer[0] === 0x52 &&
|
||||
buffer[1] === 0x49 &&
|
||||
buffer[2] === 0x46 &&
|
||||
buffer[3] === 0x46 &&
|
||||
buffer[8] === 0x57 &&
|
||||
buffer[9] === 0x45 &&
|
||||
buffer[10] === 0x42 &&
|
||||
buffer[11] === 0x50
|
||||
) {
|
||||
return 'image/webp';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function uploadImage(formData: FormData) {
|
||||
try {
|
||||
const authUser = await getAuthUser();
|
||||
if (!authUser) {
|
||||
return { success: false, error: 'Authentification requise' };
|
||||
}
|
||||
|
||||
const file = formData.get('file') as File;
|
||||
if (!file) {
|
||||
return { success: false, error: 'Aucun fichier fourni' };
|
||||
}
|
||||
|
||||
if (file.size > MAX_UPLOAD_SIZE_BYTES) {
|
||||
return { success: false, error: 'Le fichier dépasse la taille maximale autorisée (5 Mo)' };
|
||||
}
|
||||
|
||||
// Convert file to buffer
|
||||
const bytes = await file.arrayBuffer();
|
||||
const buffer = Buffer.from(bytes);
|
||||
|
||||
if (buffer.length > MAX_UPLOAD_SIZE_BYTES) {
|
||||
return { success: false, error: 'Le fichier dépasse la taille maximale autorisée (5 Mo)' };
|
||||
}
|
||||
|
||||
// Derive MIME type strictly from binary magic bytes (rejects SVG/HTML/scripts)
|
||||
const mimeType = detectSafeImageMimeType(buffer);
|
||||
if (!mimeType) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Format de fichier non autorisé (seuls JPG, PNG, WebP et GIF sont acceptés)',
|
||||
};
|
||||
}
|
||||
|
||||
// Convert to Base64
|
||||
const base64 = buffer.toString('base64');
|
||||
let mimeType = file.type || 'image/jpeg';
|
||||
|
||||
// Normalize JPEG mime types
|
||||
if (mimeType === 'image/jpg' || mimeType === 'image/pjpeg') {
|
||||
mimeType = 'image/jpeg';
|
||||
}
|
||||
|
||||
const dataUrl = `data:${mimeType};base64,${base64}`;
|
||||
|
||||
// Return the data URL directly (it will be saved in the database)
|
||||
return {
|
||||
success: true,
|
||||
url: dataUrl
|
||||
return {
|
||||
success: true,
|
||||
url: dataUrl,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error during image conversion:', error);
|
||||
return { success: false, error: 'Erreur lors de la conversion de l\'image' };
|
||||
return { success: false, error: "Erreur lors de la conversion de l'image" };
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user