feat: add admin dashboard and core API routes with Docker configuration
This commit is contained in:
@@ -1,6 +1,18 @@
|
||||
import { NextRequest } from 'next/server';
|
||||
import { cookies } from 'next/headers';
|
||||
import prisma from '@/lib/prisma';
|
||||
|
||||
export const USER_SESSION_COOKIE_NAME = 'afro_session';
|
||||
export const DEFAULT_USER_SESSION_DURATION = 7 * 24 * 60 * 60; // 7 days in seconds
|
||||
|
||||
export interface UserSessionPayload {
|
||||
userId: string;
|
||||
email: string;
|
||||
role: string;
|
||||
exp: number;
|
||||
iat: number;
|
||||
}
|
||||
|
||||
export const USER_SAFE_SELECT = {
|
||||
id: true,
|
||||
name: true,
|
||||
@@ -10,25 +22,163 @@ export const USER_SAFE_SELECT = {
|
||||
bio: true,
|
||||
location: true,
|
||||
phone: true,
|
||||
countryId: true,
|
||||
isSuspended: true,
|
||||
suspensionReason: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
emailVerified: true,
|
||||
};
|
||||
|
||||
export async function getAuthUser(request: NextRequest) {
|
||||
const userId = request.headers.get('x-user-id');
|
||||
if (!userId) return null;
|
||||
function getJwtSecret(): string {
|
||||
const secret = process.env.JWT_SECRET || process.env.NEXTAUTH_SECRET || process.env.ADMIN_JWT_SECRET;
|
||||
if (!secret) {
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
throw new Error('CRITICAL: JWT_SECRET or NEXTAUTH_SECRET environment variable must be set in production.');
|
||||
}
|
||||
return 'dev_only_afrohub_user_jwt_secret_key_change_in_prod';
|
||||
}
|
||||
return secret;
|
||||
}
|
||||
|
||||
function base64UrlEncode(str: string): string {
|
||||
return Buffer.from(str, 'utf-8').toString('base64url');
|
||||
}
|
||||
|
||||
function base64UrlDecode(str: string): string {
|
||||
return Buffer.from(str, 'base64url').toString('utf-8');
|
||||
}
|
||||
|
||||
async function getCryptoKey(): Promise<CryptoKey> {
|
||||
const enc = new TextEncoder();
|
||||
return await crypto.subtle.importKey(
|
||||
'raw',
|
||||
enc.encode(getJwtSecret()),
|
||||
{ name: 'HMAC', hash: 'SHA-256' },
|
||||
false,
|
||||
['sign', 'verify']
|
||||
);
|
||||
}
|
||||
|
||||
export async function signUserToken(
|
||||
payload: Omit<UserSessionPayload, 'exp' | 'iat'>,
|
||||
expiresInSeconds: number = DEFAULT_USER_SESSION_DURATION
|
||||
): Promise<string> {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const fullPayload: UserSessionPayload = {
|
||||
...payload,
|
||||
iat: now,
|
||||
exp: now + expiresInSeconds,
|
||||
};
|
||||
|
||||
const header = { alg: 'HS256', typ: 'JWT' };
|
||||
const headerB64 = base64UrlEncode(JSON.stringify(header));
|
||||
const payloadB64 = base64UrlEncode(JSON.stringify(fullPayload));
|
||||
const dataToSign = `${headerB64}.${payloadB64}`;
|
||||
|
||||
const key = await getCryptoKey();
|
||||
const signatureBuffer = await crypto.subtle.sign(
|
||||
'HMAC',
|
||||
key,
|
||||
new TextEncoder().encode(dataToSign)
|
||||
);
|
||||
|
||||
const signatureB64 = Buffer.from(signatureBuffer).toString('base64url');
|
||||
return `${dataToSign}.${signatureB64}`;
|
||||
}
|
||||
|
||||
export async function verifyUserToken(token?: string | null): Promise<UserSessionPayload | null> {
|
||||
if (!token || typeof token !== 'string') return null;
|
||||
const parts = token.split('.');
|
||||
if (parts.length !== 3) return null;
|
||||
|
||||
const [headerB64, payloadB64, signatureB64] = parts;
|
||||
const dataToSign = `${headerB64}.${payloadB64}`;
|
||||
|
||||
try {
|
||||
const key = await getCryptoKey();
|
||||
const signatureBytes = Buffer.from(signatureB64, 'base64url');
|
||||
|
||||
const isValid = await crypto.subtle.verify(
|
||||
'HMAC',
|
||||
key,
|
||||
signatureBytes,
|
||||
new TextEncoder().encode(dataToSign)
|
||||
);
|
||||
|
||||
if (!isValid) return null;
|
||||
|
||||
const payloadJson = base64UrlDecode(payloadB64);
|
||||
const payload = JSON.parse(payloadJson) as UserSessionPayload;
|
||||
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
if (!payload.exp || payload.exp < now || !payload.userId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return payload;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function setUserSessionCookie(
|
||||
token: string,
|
||||
maxAgeSeconds: number = DEFAULT_USER_SESSION_DURATION
|
||||
): Promise<void> {
|
||||
const cookieStore = await cookies();
|
||||
cookieStore.set(USER_SESSION_COOKIE_NAME, token, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'lax',
|
||||
path: '/',
|
||||
maxAge: maxAgeSeconds,
|
||||
});
|
||||
}
|
||||
|
||||
export async function clearUserSessionCookie(): Promise<void> {
|
||||
const cookieStore = await cookies();
|
||||
cookieStore.set(USER_SESSION_COOKIE_NAME, '', {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'lax',
|
||||
path: '/',
|
||||
maxAge: 0,
|
||||
});
|
||||
}
|
||||
|
||||
export async function getAuthUser(request?: NextRequest) {
|
||||
try {
|
||||
let token: string | undefined;
|
||||
|
||||
if (request) {
|
||||
token = request.cookies.get(USER_SESSION_COOKIE_NAME)?.value;
|
||||
}
|
||||
if (!token) {
|
||||
const cookieStore = await cookies();
|
||||
token = cookieStore.get(USER_SESSION_COOKIE_NAME)?.value;
|
||||
}
|
||||
|
||||
const payload = await verifyUserToken(token);
|
||||
if (!payload?.userId) return null;
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
where: { id: payload.userId },
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
name: true,
|
||||
role: true,
|
||||
emailVerified: true,
|
||||
isSuspended: true
|
||||
isSuspended: true,
|
||||
deletedAt: true,
|
||||
}
|
||||
});
|
||||
|
||||
if (!user || user.deletedAt) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return user;
|
||||
} catch (error) {
|
||||
console.error('Error fetching auth user:', error);
|
||||
@@ -36,7 +186,7 @@ export async function getAuthUser(request: NextRequest) {
|
||||
}
|
||||
}
|
||||
|
||||
export async function isAdmin(request: NextRequest) {
|
||||
export async function isAdmin(request?: NextRequest) {
|
||||
const user = await getAuthUser(request);
|
||||
return user?.role === 'ADMIN';
|
||||
return user?.role === 'ADMIN' && !user.isSuspended;
|
||||
}
|
||||
|
||||
18
lib/geo.ts
18
lib/geo.ts
@@ -1,3 +1,5 @@
|
||||
import { isIP } from 'node:net';
|
||||
|
||||
export async function getGeoInfo(ip: string, headers: Headers) {
|
||||
// 1. Try platform-specific headers first (Vercel, Cloudflare, etc.)
|
||||
const headerCountry =
|
||||
@@ -6,25 +8,33 @@ export async function getGeoInfo(ip: string, headers: Headers) {
|
||||
headers.get('x-country-code');
|
||||
|
||||
if (headerCountry && headerCountry !== 'XX') {
|
||||
// If we have a country code, we might still want to get the name
|
||||
// For now, let's keep it simple or use a lookup map
|
||||
return {
|
||||
country: headerCountry,
|
||||
city: headers.get('x-vercel-ip-city') || 'Unknown'
|
||||
};
|
||||
}
|
||||
|
||||
const cleanIp = ip.trim();
|
||||
|
||||
// 2. Handle localhost
|
||||
if (ip === '127.0.0.1' || ip === '::1' || ip.includes('127.0.0.1')) {
|
||||
if (cleanIp === '127.0.0.1' || cleanIp === '::1' || cleanIp.includes('127.0.0.1')) {
|
||||
return { country: 'Local / Dev', city: 'Localhost' };
|
||||
}
|
||||
|
||||
// Validate IPv4/IPv6 syntax to prevent SSRF / path traversal via spoofed X-Forwarded-For
|
||||
if (isIP(cleanIp) === 0) {
|
||||
return { country: 'Unknown', city: 'Unknown' };
|
||||
}
|
||||
|
||||
// 3. Fallback to external API
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 2000); // 2s timeout
|
||||
|
||||
const response = await fetch(`http://ip-api.com/json/${ip}`, { signal: controller.signal });
|
||||
const response = await fetch(`http://ip-api.com/json/${encodeURIComponent(cleanIp)}`, {
|
||||
signal: controller.signal,
|
||||
headers: { 'Accept-Encoding': 'gzip, deflate' },
|
||||
});
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (response.ok) {
|
||||
|
||||
@@ -1,11 +1,5 @@
|
||||
import nodemailer from 'nodemailer';
|
||||
|
||||
console.log('SMTP Config:', {
|
||||
host: process.env.SMTP_HOST,
|
||||
port: process.env.SMTP_PORT,
|
||||
secure: process.env.SMTP_SECURE,
|
||||
user: process.env.SMTP_USER
|
||||
});
|
||||
const transporter = nodemailer.createTransport({
|
||||
host: process.env.SMTP_HOST,
|
||||
port: parseInt(process.env.SMTP_PORT || '587'),
|
||||
@@ -15,7 +9,7 @@ const transporter = nodemailer.createTransport({
|
||||
pass: process.env.SMTP_PASS,
|
||||
},
|
||||
tls: {
|
||||
rejectUnauthorized: false
|
||||
rejectUnauthorized: process.env.NODE_ENV === 'production'
|
||||
}
|
||||
});
|
||||
|
||||
@@ -29,7 +23,6 @@ export const sendEmail = async ({ to, subject, html }: { to: string; subject: st
|
||||
|
||||
try {
|
||||
const info = await transporter.sendMail(mailOptions);
|
||||
console.log('Email sent: ' + info.response);
|
||||
return info;
|
||||
} catch (error) {
|
||||
console.error('Error sending email:', error);
|
||||
|
||||
@@ -20,10 +20,10 @@ export function sanitizeHTML(html: string): string {
|
||||
allowedAttributes: {
|
||||
'a': ['href', 'target', 'rel'],
|
||||
'img': ['src', 'alt'],
|
||||
'*': ['class', 'style']
|
||||
'*': ['class']
|
||||
},
|
||||
selfClosing: ['img', 'br', 'hr', 'area', 'base', 'basefont', 'input', 'link', 'meta'],
|
||||
// Standard URLs only
|
||||
allowedSchemes: ['http', 'https', 'ftp', 'mailto', 'tel'],
|
||||
allowedSchemes: ['http', 'https', 'mailto', 'tel'],
|
||||
});
|
||||
}
|
||||
|
||||
25
lib/utils.ts
25
lib/utils.ts
@@ -11,3 +11,28 @@ export function generateSlug(text: string): string {
|
||||
.replace(/^-+/, '') // trim - from start of text
|
||||
.replace(/-+$/, ''); // trim - from end of text
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates external or relative URLs to prevent javascript:/data:/vbscript: XSS in href attributes.
|
||||
*/
|
||||
export function safeUrl(url?: string | null): string {
|
||||
if (!url || typeof url !== 'string') return '#';
|
||||
const trimmed = url.trim();
|
||||
if (!trimmed) return '#';
|
||||
|
||||
// Allow safe relative paths (starting with single `/` and not `//` or `/\`)
|
||||
if (trimmed.startsWith('/') && !trimmed.startsWith('//') && !trimmed.startsWith('/\\')) {
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = new URL(trimmed);
|
||||
if (parsed.protocol === 'http:' || parsed.protocol === 'https:') {
|
||||
return parsed.toString();
|
||||
}
|
||||
} catch {
|
||||
// Invalid URL
|
||||
}
|
||||
|
||||
return '#';
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user