refactor(api): update README and refactor api route registration
Restructure the core API to separate infrastructure routes from feature routes. Key changes: - Add `runtime.js` for global state: session resolver and feature route registry - Add `file-response.js` for streaming file responses (storage endpoint) - Remove feature routes (auth/users) from `core-routes.js`, keeping only true infrastructure routes (health, storage) - Introduce `registerFeatureRoutes()` so features self-register during `initializeZen()` instead of being hardcoded in `core-routes.js` - Add `UserFacingError` class to safely surface client-facing errors without leaking internal details - Fix import path for `rateLimit.js` to use shared lib location - Update README to reflect new two-step registration flow and clarify the role of `core-routes.js`
This commit is contained in:
@@ -11,7 +11,20 @@ import { verifyEmailToken, verifyResetToken, sendVerificationEmail, sendPassword
|
||||
import { fail } from '../../../shared/lib/logger.js';
|
||||
import { cookies, headers } from 'next/headers';
|
||||
import { getSessionCookieName, getPublicBaseUrl } from '../../../shared/lib/appConfig.js';
|
||||
import { checkRateLimit, getIpFromHeaders, formatRetryAfter } from '../lib/rateLimit.js';
|
||||
import { checkRateLimit, getIpFromHeaders, formatRetryAfter } from '../../../shared/lib/rateLimit.js';
|
||||
|
||||
/**
|
||||
* Errors that are safe to surface verbatim to the client (e.g. "token expired").
|
||||
* All other errors — including library-layer and database errors — must be caught,
|
||||
* logged server-side only, and replaced with a generic message to prevent internal
|
||||
* detail disclosure.
|
||||
*/
|
||||
class UserFacingError extends Error {
|
||||
constructor(message) {
|
||||
super(message);
|
||||
this.name = 'UserFacingError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the client IP from the current server action context.
|
||||
@@ -21,10 +34,49 @@ async function getClientIp() {
|
||||
return getIpFromHeaders(h);
|
||||
}
|
||||
|
||||
// Emitted at most once per process lifetime to avoid log flooding while still
|
||||
// alerting operators that per-IP rate limiting is inactive for server actions.
|
||||
let _rateLimitUnavailableWarned = false;
|
||||
|
||||
/**
|
||||
* Apply per-IP rate limiting only when a real IP address is available.
|
||||
*
|
||||
* When IP resolves to 'unknown' (no trusted proxy configured), every caller
|
||||
* shares the single bucket keyed '<action>:unknown'. A single attacker can
|
||||
* exhaust that bucket in 5 requests and impose a 30-minute denial-of-service
|
||||
* on every legitimate user. Rate limiting is therefore suspended for the
|
||||
* 'unknown' case and a one-time operator warning is emitted instead,
|
||||
* mirroring the identical policy applied to API routes in router.js.
|
||||
*
|
||||
* Set ZEN_TRUST_PROXY=true only behind a verified reverse proxy (Nginx,
|
||||
* Cloudflare, AWS ALB, …) that strips and rewrites forwarding headers.
|
||||
*
|
||||
* @param {string} ip
|
||||
* @param {string} action
|
||||
* @returns {{ allowed: boolean, retryAfterMs?: number } | null} null = suspended
|
||||
*/
|
||||
function enforceRateLimit(ip, action) {
|
||||
if (ip === 'unknown') {
|
||||
if (!_rateLimitUnavailableWarned) {
|
||||
_rateLimitUnavailableWarned = true;
|
||||
fail(
|
||||
'Rate limiting inactive (server actions): client IP cannot be determined. ' +
|
||||
'Set ZEN_TRUST_PROXY=true behind a verified reverse proxy to enable per-IP rate limiting.'
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
return checkRateLimit(ip, action);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate anti-bot fields submitted with forms.
|
||||
* - _hp : honeypot field — must be empty
|
||||
* - _t : form load timestamp (ms) — submission must be at least 1.5 s after page load
|
||||
* - _t : form load timestamp (ms) — submission must be at least 1.5 s after page
|
||||
* load AND no more than MAX_FORM_AGE_MS in the past. Both a lower bound
|
||||
* (prevents instant automated submission) and an upper bound (prevents the
|
||||
* trivial bypass of supplying an arbitrary past timestamp such as _t=1) are
|
||||
* enforced. Future timestamps are also rejected.
|
||||
*
|
||||
* @param {FormData} formData
|
||||
* @returns {{ valid: boolean, error?: string }}
|
||||
@@ -35,8 +87,13 @@ function validateAntiBotFields(formData) {
|
||||
return { valid: false, error: 'Requête invalide' };
|
||||
}
|
||||
|
||||
const t = parseInt(formData.get('_t') || '0', 10);
|
||||
if (t === 0 || Date.now() - t < 1500) {
|
||||
const MIN_ELAPSED_MS = 1_500;
|
||||
const MAX_FORM_AGE_MS = 10 * 60 * 1_000; // 10 minutes — rejects epoch-era timestamps
|
||||
const now = Date.now();
|
||||
const t = parseInt(formData.get('_t') || '0', 10);
|
||||
const elapsed = now - t;
|
||||
|
||||
if (t === 0 || t > now || elapsed < MIN_ELAPSED_MS || elapsed > MAX_FORM_AGE_MS) {
|
||||
return { valid: false, error: 'Requête invalide' };
|
||||
}
|
||||
|
||||
@@ -57,8 +114,8 @@ export async function registerAction(formData) {
|
||||
if (!botCheck.valid) return { success: false, error: botCheck.error };
|
||||
|
||||
const ip = await getClientIp();
|
||||
const rl = checkRateLimit(ip, 'register');
|
||||
if (!rl.allowed) {
|
||||
const rl = enforceRateLimit(ip, 'register');
|
||||
if (rl && !rl.allowed) {
|
||||
return { success: false, error: `Trop de tentatives. Réessayez dans ${formatRetryAfter(rl.retryAfterMs)}.` };
|
||||
}
|
||||
|
||||
@@ -77,10 +134,10 @@ export async function registerAction(formData) {
|
||||
user: result.user
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error.message
|
||||
};
|
||||
// Never return raw error.message to the client — library and database errors
|
||||
// (e.g. unique-constraint violations) expose internal table names and schema.
|
||||
fail(`Auth: registerAction error: ${error.message}`);
|
||||
return { success: false, error: 'Une erreur interne est survenue. Veuillez réessayer.' };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,8 +152,8 @@ export async function loginAction(formData) {
|
||||
if (!botCheck.valid) return { success: false, error: botCheck.error };
|
||||
|
||||
const ip = await getClientIp();
|
||||
const rl = checkRateLimit(ip, 'login');
|
||||
if (!rl.allowed) {
|
||||
const rl = enforceRateLimit(ip, 'login');
|
||||
if (rl && !rl.allowed) {
|
||||
return { success: false, error: `Trop de tentatives. Réessayez dans ${formatRetryAfter(rl.retryAfterMs)}.` };
|
||||
}
|
||||
|
||||
@@ -125,20 +182,34 @@ export async function loginAction(formData) {
|
||||
// JavaScript-accessible response body.
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error.message
|
||||
};
|
||||
fail(`Auth: loginAction error: ${error.message}`);
|
||||
return { success: false, error: 'Identifiants invalides ou erreur interne. Veuillez réessayer.' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set session cookie (called by client after showing success message)
|
||||
* @param {string} token - Session token
|
||||
* Set session cookie after verifying the token is a genuine live session.
|
||||
*
|
||||
* This server action is client-callable. Without server-side token validation an
|
||||
* attacker could supply any arbitrary string (including a stolen token for another
|
||||
* user) and have it written as the HttpOnly session cookie, completely bypassing
|
||||
* the protection that HttpOnly is intended to provide. The token is therefore
|
||||
* validated against the session store before the cookie is written.
|
||||
*
|
||||
* @param {string} token - Session token obtained from a trusted server-side flow
|
||||
* @returns {Promise<Object>} Result object
|
||||
*/
|
||||
export async function setSessionCookie(token) {
|
||||
try {
|
||||
if (!token || typeof token !== 'string' || token.trim() === '') {
|
||||
return { success: false, error: 'Jeton de session invalide' };
|
||||
}
|
||||
|
||||
const session = await validateSession(token);
|
||||
if (!session) {
|
||||
return { success: false, error: 'Session invalide ou expirée' };
|
||||
}
|
||||
|
||||
const cookieStore = await cookies();
|
||||
cookieStore.set(COOKIE_NAME, token, {
|
||||
httpOnly: true,
|
||||
@@ -150,20 +221,32 @@ export async function setSessionCookie(token) {
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error.message
|
||||
};
|
||||
fail(`Auth: setSessionCookie error: ${error.message}`);
|
||||
return { success: false, error: 'Une erreur interne est survenue' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh session cookie (extend expiration)
|
||||
* Refresh session cookie (extend expiration).
|
||||
*
|
||||
* Re-validates the token before extending its cookie lifetime so that expired
|
||||
* or revoked tokens cannot have their cookie window reopened by replaying this
|
||||
* server action.
|
||||
*
|
||||
* @param {string} token - Session token
|
||||
* @returns {Promise<Object>} Result object
|
||||
*/
|
||||
export async function refreshSessionCookie(token) {
|
||||
try {
|
||||
if (!token || typeof token !== 'string' || token.trim() === '') {
|
||||
return { success: false, error: 'Jeton de session invalide' };
|
||||
}
|
||||
|
||||
const session = await validateSession(token);
|
||||
if (!session) {
|
||||
return { success: false, error: 'Session invalide ou expirée' };
|
||||
}
|
||||
|
||||
const cookieStore = await cookies();
|
||||
cookieStore.set(COOKIE_NAME, token, {
|
||||
httpOnly: true,
|
||||
@@ -175,10 +258,8 @@ export async function refreshSessionCookie(token) {
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error.message
|
||||
};
|
||||
fail(`Auth: refreshSessionCookie error: ${error.message}`);
|
||||
return { success: false, error: 'Une erreur interne est survenue' };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -202,10 +283,8 @@ export async function logoutAction() {
|
||||
message: 'Déconnexion réussie'
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error.message
|
||||
};
|
||||
fail(`Auth: logoutAction error: ${error.message}`);
|
||||
return { success: false, error: 'Une erreur interne est survenue. Veuillez réessayer.' };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -245,8 +324,8 @@ export async function forgotPasswordAction(formData) {
|
||||
if (!botCheck.valid) return { success: false, error: botCheck.error };
|
||||
|
||||
const ip = await getClientIp();
|
||||
const rl = checkRateLimit(ip, 'forgot_password');
|
||||
if (!rl.allowed) {
|
||||
const rl = enforceRateLimit(ip, 'forgot_password');
|
||||
if (rl && !rl.allowed) {
|
||||
return { success: false, error: `Trop de tentatives. Réessayez dans ${formatRetryAfter(rl.retryAfterMs)}.` };
|
||||
}
|
||||
|
||||
@@ -263,10 +342,8 @@ export async function forgotPasswordAction(formData) {
|
||||
message: 'Si un compte existe avec cet e-mail, vous recevrez un lien pour réinitialiser votre mot de passe.'
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error.message
|
||||
};
|
||||
fail(`Auth: forgotPasswordAction error: ${error.message}`);
|
||||
return { success: false, error: 'Une erreur interne est survenue. Veuillez réessayer.' };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -278,8 +355,8 @@ export async function forgotPasswordAction(formData) {
|
||||
export async function resetPasswordAction(formData) {
|
||||
try {
|
||||
const ip = await getClientIp();
|
||||
const rl = checkRateLimit(ip, 'reset_password');
|
||||
if (!rl.allowed) {
|
||||
const rl = enforceRateLimit(ip, 'reset_password');
|
||||
if (rl && !rl.allowed) {
|
||||
return { success: false, error: `Trop de tentatives. Réessayez dans ${formatRetryAfter(rl.retryAfterMs)}.` };
|
||||
}
|
||||
|
||||
@@ -287,10 +364,11 @@ export async function resetPasswordAction(formData) {
|
||||
const token = formData.get('token');
|
||||
const newPassword = formData.get('newPassword');
|
||||
|
||||
// Verify token first
|
||||
// Verify token first — throw UserFacingError so the specific message reaches
|
||||
// the client while unexpected system errors are sanitized in the catch below.
|
||||
const isValid = await verifyResetToken(email, token);
|
||||
if (!isValid) {
|
||||
throw new Error('Jeton de réinitialisation invalide ou expiré');
|
||||
throw new UserFacingError('Jeton de réinitialisation invalide ou expiré');
|
||||
}
|
||||
|
||||
await resetPassword({ email, token, newPassword });
|
||||
@@ -300,10 +378,11 @@ export async function resetPasswordAction(formData) {
|
||||
message: 'Mot de passe réinitialisé avec succès. Vous pouvez maintenant vous connecter avec votre nouveau mot de passe.'
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error.message
|
||||
};
|
||||
if (error instanceof UserFacingError) {
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
fail(`Auth: resetPasswordAction error: ${error.message}`);
|
||||
return { success: false, error: 'Une erreur interne est survenue. Veuillez réessayer.' };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -315,18 +394,19 @@ export async function resetPasswordAction(formData) {
|
||||
export async function verifyEmailAction(formData) {
|
||||
try {
|
||||
const ip = await getClientIp();
|
||||
const rl = checkRateLimit(ip, 'verify_email');
|
||||
if (!rl.allowed) {
|
||||
const rl = enforceRateLimit(ip, 'verify_email');
|
||||
if (rl && !rl.allowed) {
|
||||
return { success: false, error: `Trop de tentatives. Réessayez dans ${formatRetryAfter(rl.retryAfterMs)}.` };
|
||||
}
|
||||
|
||||
const email = formData.get('email');
|
||||
const token = formData.get('token');
|
||||
|
||||
// Verify token
|
||||
// Verify token — throw UserFacingError so validation messages surface to the
|
||||
// client while unexpected system errors remain sanitized in the catch below.
|
||||
const isValid = await verifyEmailToken(email, token);
|
||||
if (!isValid) {
|
||||
throw new Error('Jeton de vérification invalide ou expiré');
|
||||
throw new UserFacingError('Jeton de vérification invalide ou expiré');
|
||||
}
|
||||
|
||||
// Find user and verify
|
||||
@@ -334,7 +414,7 @@ export async function verifyEmailAction(formData) {
|
||||
const user = await findOne('zen_auth_users', { email });
|
||||
|
||||
if (!user) {
|
||||
throw new Error('Utilisateur introuvable');
|
||||
throw new UserFacingError('Utilisateur introuvable');
|
||||
}
|
||||
|
||||
await verifyUserEmail(user.id);
|
||||
@@ -344,10 +424,11 @@ export async function verifyEmailAction(formData) {
|
||||
message: 'E-mail vérifié avec succès. Vous pouvez maintenant vous connecter.'
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error.message
|
||||
};
|
||||
if (error instanceof UserFacingError) {
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
fail(`Auth: verifyEmailAction error: ${error.message}`);
|
||||
return { success: false, error: 'Une erreur interne est survenue. Veuillez réessayer.' };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,158 +0,0 @@
|
||||
/**
|
||||
* In-memory rate limiter
|
||||
* Stores counters in a Map — resets on server restart, no DB required.
|
||||
*/
|
||||
|
||||
// Persist the store on globalThis so that module-cache invalidation (e.g. during
|
||||
// Next.js hot reload) does not silently reset all counters within the same process.
|
||||
// CRITICAL LIMITATION: this Map is process-local. In serverless or multi-worker
|
||||
// deployments every instance maintains its own store and rate limits do not
|
||||
// distribute across instances. For production deployments with multiple workers
|
||||
// replace this Map with a shared atomic store (e.g. Redis / Upstash).
|
||||
const STORE_KEY = Symbol.for('__ZEN_RATE_LIMIT_STORE__');
|
||||
if (!globalThis[STORE_KEY]) globalThis[STORE_KEY] = new Map();
|
||||
/** @type {Map<string, { count: number, windowStart: number, windowMs: number, blockedUntil: number|null }>} */
|
||||
const store = globalThis[STORE_KEY];
|
||||
|
||||
// Purge expired entries every 10 minutes to avoid memory leak
|
||||
const cleanup = setInterval(() => {
|
||||
const now = Date.now();
|
||||
for (const [key, entry] of store.entries()) {
|
||||
const windowExpired = now > entry.windowStart + entry.windowMs;
|
||||
const blockExpired = !entry.blockedUntil || now > entry.blockedUntil;
|
||||
if (windowExpired && blockExpired) {
|
||||
store.delete(key);
|
||||
}
|
||||
}
|
||||
}, 10 * 60 * 1000);
|
||||
|
||||
// Allow garbage collection in test/serverless environments
|
||||
if (cleanup.unref) cleanup.unref();
|
||||
|
||||
/**
|
||||
* Rate limit presets per action.
|
||||
* maxAttempts : number of requests allowed in the window
|
||||
* windowMs : rolling window duration
|
||||
* blockMs : how long to block once the limit is exceeded
|
||||
*/
|
||||
export const RATE_LIMITS = {
|
||||
login: { maxAttempts: 5, windowMs: 15 * 60 * 1000, blockMs: 30 * 60 * 1000 },
|
||||
register: { maxAttempts: 5, windowMs: 60 * 60 * 1000, blockMs: 60 * 60 * 1000 },
|
||||
forgot_password: { maxAttempts: 3, windowMs: 60 * 60 * 1000, blockMs: 60 * 60 * 1000 },
|
||||
reset_password: { maxAttempts: 5, windowMs: 15 * 60 * 1000, blockMs: 30 * 60 * 1000 },
|
||||
verify_email: { maxAttempts: 10, windowMs: 60 * 60 * 1000, blockMs: 30 * 60 * 1000 },
|
||||
api: { maxAttempts: 120, windowMs: 60 * 1000, blockMs: 5 * 60 * 1000 },
|
||||
};
|
||||
|
||||
/**
|
||||
* Check whether a given identifier is allowed for an action, and record the attempt.
|
||||
*
|
||||
* @param {string} identifier - IP address or user ID
|
||||
* @param {string} action - Key from RATE_LIMITS (e.g. 'login')
|
||||
* @returns {{ allowed: boolean, retryAfterMs?: number, remaining?: number }}
|
||||
*/
|
||||
export function checkRateLimit(identifier, action) {
|
||||
const config = RATE_LIMITS[action];
|
||||
if (!config) return { allowed: true };
|
||||
|
||||
const key = `${action}:${identifier}`;
|
||||
const now = Date.now();
|
||||
let entry = store.get(key);
|
||||
|
||||
// Still blocked
|
||||
if (entry?.blockedUntil && now < entry.blockedUntil) {
|
||||
return { allowed: false, retryAfterMs: entry.blockedUntil - now };
|
||||
}
|
||||
|
||||
// Start a fresh window (first request, or previous window has expired)
|
||||
if (!entry || now > entry.windowStart + entry.windowMs) {
|
||||
store.set(key, { count: 1, windowStart: now, windowMs: config.windowMs, blockedUntil: null });
|
||||
return { allowed: true, remaining: config.maxAttempts - 1 };
|
||||
}
|
||||
|
||||
// Increment counter in the current window
|
||||
entry.count += 1;
|
||||
|
||||
if (entry.count > config.maxAttempts) {
|
||||
entry.blockedUntil = now + config.blockMs;
|
||||
store.set(key, entry);
|
||||
return { allowed: false, retryAfterMs: config.blockMs };
|
||||
}
|
||||
|
||||
store.set(key, entry);
|
||||
return { allowed: true, remaining: config.maxAttempts - entry.count };
|
||||
}
|
||||
|
||||
/**
|
||||
* Return true only when the string resembles a valid IPv4 or IPv6 address.
|
||||
* This prevents arbitrary attacker-supplied strings from being used as
|
||||
* rate-limit identifiers (which could allow bucket manipulation).
|
||||
* @param {string|null|undefined} ip
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isValidIp(ip) {
|
||||
if (!ip || typeof ip !== 'string') return false;
|
||||
// IPv4 — four decimal octets, each 0-255
|
||||
if (/^(\d{1,3}\.){3}\d{1,3}$/.test(ip)) {
|
||||
return ip.split('.').every(octet => parseInt(octet, 10) <= 255);
|
||||
}
|
||||
// IPv6 — simplified structural check (colons + hex groups)
|
||||
return /^[0-9a-fA-F:]+$/.test(ip) && ip.includes(':');
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the client IP from Next.js headers() (server actions).
|
||||
*
|
||||
* X-Forwarded-For and X-Real-IP are only trusted when ZEN_TRUST_PROXY=true is
|
||||
* explicitly set, confirming a trusted reverse proxy populates those headers.
|
||||
* Without this flag the headers are fully attacker-controlled and MUST NOT be
|
||||
* used as rate-limit keys — an attacker would trivially rotate identifiers.
|
||||
*
|
||||
* Set ZEN_TRUST_PROXY=true only when a verified reverse proxy (e.g. Nginx,
|
||||
* Cloudflare, AWS ALB) strips and rewrites forwarded headers before they reach
|
||||
* this application.
|
||||
*
|
||||
* @param {import('next/headers').ReadonlyHeaders} headersList
|
||||
* @returns {string}
|
||||
*/
|
||||
export function getIpFromHeaders(headersList) {
|
||||
if (process.env.ZEN_TRUST_PROXY === 'true') {
|
||||
const forwarded = headersList.get('x-forwarded-for')?.split(',')[0]?.trim();
|
||||
if (forwarded && isValidIp(forwarded)) return forwarded;
|
||||
const realIp = headersList.get('x-real-ip')?.trim();
|
||||
if (realIp && isValidIp(realIp)) return realIp;
|
||||
}
|
||||
// Safe fallback — all requests share the 'unknown' bucket.
|
||||
// Configure ZEN_TRUST_PROXY=true behind a verified reverse proxy.
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the client IP from a Next.js Request object (API routes).
|
||||
* See getIpFromHeaders for the full trust-proxy rationale.
|
||||
* @param {Request} request
|
||||
* @returns {string}
|
||||
*/
|
||||
export function getIpFromRequest(request) {
|
||||
if (process.env.ZEN_TRUST_PROXY === 'true') {
|
||||
const forwarded = request.headers.get('x-forwarded-for')?.split(',')[0]?.trim();
|
||||
if (forwarded && isValidIp(forwarded)) return forwarded;
|
||||
const realIp = request.headers.get('x-real-ip')?.trim();
|
||||
if (realIp && isValidIp(realIp)) return realIp;
|
||||
}
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a block duration in human-readable French.
|
||||
* @param {number} ms
|
||||
* @returns {string}
|
||||
*/
|
||||
export function formatRetryAfter(ms) {
|
||||
const seconds = Math.ceil(ms / 1000);
|
||||
if (seconds < 60) return `${seconds} secondes`;
|
||||
const minutes = Math.ceil(seconds / 60);
|
||||
if (minutes < 60) return `${minutes} minute${minutes > 1 ? 's' : ''}`;
|
||||
const hours = Math.ceil(minutes / 60);
|
||||
return `${hours} heure${hours > 1 ? 's' : ''}`;
|
||||
}
|
||||
Reference in New Issue
Block a user