Phase 1: lock cron, reload chaud, argon2, providers, IMDb lookup, cache LRU, /health, /metrics, rate limit, UI dark, biome
This commit is contained in:
@@ -1,10 +1,33 @@
|
||||
// Login + protected directory listing — replaces index.php.
|
||||
// Protected file listing (was the public root in the PHP version).
|
||||
// Now mounted at /admin so the new public UI can live at /.
|
||||
|
||||
import { readdir, stat } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { ROOT, TITLE, PASSWORD } from '../config.js';
|
||||
import { ADMIN_PASSWORD_HASH, ROOT, TITLE } from '../config.js';
|
||||
import { verifyPassword } from '../lib/password.js';
|
||||
|
||||
const HIDDEN = new Set(['index.php', '.htaccess', 'node_modules', 'package.json', 'package-lock.json', 'config.js', 'server.js', 'lib', 'routes', 'cron', 'test', '.git']);
|
||||
const HIDDEN = new Set([
|
||||
'node_modules',
|
||||
'package.json',
|
||||
'package-lock.json',
|
||||
'config.js',
|
||||
'server.js',
|
||||
'lib',
|
||||
'routes',
|
||||
'cron',
|
||||
'tools',
|
||||
'test',
|
||||
'.git',
|
||||
'.claude',
|
||||
'.specstory',
|
||||
'biome.json',
|
||||
'public',
|
||||
'.env',
|
||||
'.env.example',
|
||||
'.gitignore',
|
||||
'README.md',
|
||||
'.cron.lock',
|
||||
]);
|
||||
|
||||
function esc(s) {
|
||||
if (s == null) return '';
|
||||
@@ -20,10 +43,12 @@ function formatBytes(bytes) {
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'];
|
||||
let i = bytes > 0 ? Math.floor(Math.log(bytes) / Math.log(1024)) : 0;
|
||||
if (i >= units.length) i = units.length - 1;
|
||||
return `${(bytes / Math.pow(1024, i)) || 0} ${units[i]}`;
|
||||
return `${bytes / 1024 ** i || 0} ${units[i]}`;
|
||||
}
|
||||
|
||||
function pad(n) { return n < 10 ? `0${n}` : String(n); }
|
||||
function pad(n) {
|
||||
return n < 10 ? `0${n}` : String(n);
|
||||
}
|
||||
function fmtDate(ms) {
|
||||
const d = new Date(ms);
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||
@@ -35,7 +60,7 @@ function loginPage(error = '') {
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>${esc(TITLE)}</title>
|
||||
<title>Admin · ${esc(TITLE)}</title>
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; }
|
||||
body { font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif; background:#0b1220; color:#e5e7eb; display:flex; align-items:center; justify-content:center; min-height:100vh; margin:0; }
|
||||
@@ -51,7 +76,7 @@ input[type=password]:focus{ border-color:#60a5fa; }
|
||||
</head>
|
||||
<body>
|
||||
<form class="card" method="post" autocomplete="off">
|
||||
<h1>${esc(TITLE)}</h1>
|
||||
<h1>Admin · ${esc(TITLE)}</h1>
|
||||
<label for="pw">Mot de passe</label>
|
||||
<input id="pw" name="password" type="password" required autofocus>
|
||||
<button class="btn" type="submit">Entrer</button>
|
||||
@@ -68,7 +93,11 @@ async function listingPage() {
|
||||
if (HIDDEN.has(name)) continue;
|
||||
if (name.startsWith('.')) continue;
|
||||
let st;
|
||||
try { st = await stat(join(ROOT, name)); } catch { continue; }
|
||||
try {
|
||||
st = await stat(join(ROOT, name));
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
entries.push({
|
||||
name,
|
||||
isDir: st.isDirectory(),
|
||||
@@ -80,28 +109,24 @@ async function listingPage() {
|
||||
if (a.isDir !== b.isDir) return a.isDir ? -1 : 1;
|
||||
return a.name.toLowerCase().localeCompare(b.name.toLowerCase());
|
||||
});
|
||||
|
||||
const rows = entries.map((e) => {
|
||||
const href = encodeURIComponent(e.name);
|
||||
return `<tr>
|
||||
<td class="name"><a href="${href}${e.isDir ? '/' : ''}">${esc(e.name)}</a>${e.isDir ? '<span class="badge">dossier</span>' : ''}</td>
|
||||
const rows = entries
|
||||
.map((e) => {
|
||||
const href = encodeURIComponent(e.name);
|
||||
return `<tr>
|
||||
<td class="name"><a href="/admin/files/${href}${e.isDir ? '/' : ''}">${esc(e.name)}</a>${e.isDir ? '<span class="badge">dossier</span>' : ''}</td>
|
||||
<td>${e.isDir ? '—' : esc(formatBytes(e.size))}</td>
|
||||
<td>${esc(fmtDate(e.mtime))}</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
|
||||
})
|
||||
.join('');
|
||||
return `<!doctype html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>${esc(TITLE)}</title>
|
||||
<html lang="fr"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Admin · ${esc(TITLE)}</title>
|
||||
<style>
|
||||
body{ font-family: ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,sans-serif; background:#0b1220; color:#e5e7eb; margin:0; }
|
||||
header{ display:flex; gap:12px; align-items:center; justify-content:space-between; padding:16px 20px; background:#0f172a; position:sticky; top:0; }
|
||||
h1{ font-size:18px; margin:0; color:#f9fafb; }
|
||||
a.logout{ color:#93c5fd; text-decoration:none; font-size:14px; }
|
||||
a.logout:hover{ text-decoration:underline; }
|
||||
.wrap{ max-width:1100px; margin:20px auto; padding:0 16px; }
|
||||
table{ width:100%; border-collapse:collapse; background:#111827; border-radius:12px; overflow:hidden; }
|
||||
th,td{ padding:12px 14px; border-bottom:1px solid #1f2937; text-align:left; font-size:14px; }
|
||||
@@ -110,53 +135,31 @@ tr:hover td{ background:#0b1324; }
|
||||
.name a{ color:#93c5fd; text-decoration:none; }
|
||||
.name a:hover{ text-decoration:underline; }
|
||||
.badge{ font-size:11px; padding:2px 8px; border-radius:999px; background:#1f2937; color:#cbd5e1; margin-left:8px; }
|
||||
footer{ color:#94a3b8; font-size:12px; text-align:center; padding:16px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>${esc(TITLE)}</h1>
|
||||
<a class="logout" href="?logout=1">Se déconnecter</a>
|
||||
</header>
|
||||
<div class="wrap">
|
||||
<table>
|
||||
<thead><tr><th>Nom</th><th>Taille</th><th>Modifié</th></tr></thead>
|
||||
<tbody>${rows}</tbody>
|
||||
</table>
|
||||
<footer>Les liens ci-dessus pointent directement vers les fichiers/dossiers non protégés.</footer>
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
</style></head>
|
||||
<body><header><h1>Admin · ${esc(TITLE)}</h1><a class="logout" href="/admin?logout=1">Se déconnecter</a></header>
|
||||
<div class="wrap"><table><thead><tr><th>Nom</th><th>Taille</th><th>Modifié</th></tr></thead><tbody>${rows}</tbody></table></div>
|
||||
</body></html>`;
|
||||
}
|
||||
|
||||
export default async function indexRoutes(fastify) {
|
||||
fastify.get('/', async (req, reply) => {
|
||||
export default async function adminRoutes(fastify) {
|
||||
fastify.get('/admin', async (req, reply) => {
|
||||
reply.header('Content-Type', 'text/html; charset=utf-8');
|
||||
if (req.query?.logout != null) {
|
||||
req.session.delete();
|
||||
return reply.redirect('/');
|
||||
}
|
||||
if (!req.session.get('auth_ok')) {
|
||||
return loginPage();
|
||||
return reply.redirect('/admin');
|
||||
}
|
||||
if (!req.session.get('auth_ok')) return loginPage();
|
||||
return listingPage();
|
||||
});
|
||||
|
||||
fastify.post('/', async (req, reply) => {
|
||||
fastify.post('/admin', async (req, reply) => {
|
||||
reply.header('Content-Type', 'text/html; charset=utf-8');
|
||||
const submitted = req.body?.password ?? '';
|
||||
if (timingSafeEqual(submitted, PASSWORD)) {
|
||||
const ok = await verifyPassword(ADMIN_PASSWORD_HASH, submitted);
|
||||
if (ok) {
|
||||
req.session.set('auth_ok', true);
|
||||
return reply.redirect('/');
|
||||
return reply.redirect('/admin');
|
||||
}
|
||||
return loginPage('Mot de passe incorrect.');
|
||||
});
|
||||
}
|
||||
|
||||
function timingSafeEqual(a, b) {
|
||||
if (typeof a !== 'string' || typeof b !== 'string') return false;
|
||||
if (a.length !== b.length) return false;
|
||||
let diff = 0;
|
||||
for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
||||
return diff === 0;
|
||||
}
|
||||
@@ -1,18 +1,24 @@
|
||||
// JSON API — replaces api.php.
|
||||
// GET /api?t=movie&q=<id>
|
||||
// GET /api?t=tv&q=<id>
|
||||
// GET /api?t=search&q=<query>
|
||||
// JSON API.
|
||||
//
|
||||
// GET /api?t=movie&q=<id> -> TMDB detail (movie) + IMDb note
|
||||
// GET /api?t=tv&q=<id> -> TMDB detail (tv) + IMDb note
|
||||
// GET /api?t=imdb&q=<imdb_id> -> redirect-style: returns movie or tv detail
|
||||
// GET /api?t=providers&type=movie&q=<id> -> watch providers JSON
|
||||
// GET /api?t=search&q=<query> -> ranked search results
|
||||
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { entryPath } from '../lib/paths.js';
|
||||
import { getRatings, lookupRating } from '../lib/imdbRatings.js';
|
||||
import { parseQuery } from '../lib/queryParser.js';
|
||||
import { filterAndLower } from '../lib/titleFilter.js';
|
||||
import { search as runSearch } from '../lib/searchEngine.js';
|
||||
import { LRUCache } from 'lru-cache';
|
||||
import { IMDB_URL, MOVIE_API_URL, MOVIE_URL, POSTER_URL, TV_API_URL, TV_URL } from '../config.js';
|
||||
import { formatCurrency, formatRuntime, pad2 } from '../lib/format.js';
|
||||
import {
|
||||
POSTER_URL, MOVIE_URL, TV_URL, MOVIE_API_URL, TV_API_URL, IMDB_URL,
|
||||
} from '../config.js';
|
||||
import { lookupImdb } from '../lib/imdbMapping.js';
|
||||
import { getRatings, lookupRating } from '../lib/imdbRatings.js';
|
||||
import { searchCacheHits, searchCacheMisses } from '../lib/metrics.js';
|
||||
import { entryPath, justwatchPath } from '../lib/paths.js';
|
||||
import { parseQuery } from '../lib/queryParser.js';
|
||||
import { search as runSearch } from '../lib/searchEngine.js';
|
||||
import { filterAndLower } from '../lib/titleFilter.js';
|
||||
|
||||
const searchCache = new LRUCache({ max: 1000, ttl: 1000 * 60 * 60 });
|
||||
|
||||
async function getDetail(type, id) {
|
||||
try {
|
||||
@@ -23,10 +29,17 @@ async function getDetail(type, id) {
|
||||
}
|
||||
}
|
||||
|
||||
async function getProviders(type, id) {
|
||||
try {
|
||||
return JSON.parse(await readFile(justwatchPath(type, id), 'utf8'));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleEntry(type, id) {
|
||||
const obj = await getDetail(type, id);
|
||||
if (!obj) return { error: 'Not found' };
|
||||
|
||||
const imdb = type === 'movie' ? obj.imdb_id : obj?.external_ids?.imdb_id;
|
||||
if (imdb) {
|
||||
const ratings = await getRatings();
|
||||
@@ -39,7 +52,20 @@ async function handleEntry(type, id) {
|
||||
return obj;
|
||||
}
|
||||
|
||||
async function handleImdbLookup(imdbId) {
|
||||
const found = await lookupImdb(imdbId);
|
||||
if (!found) return { error: 'IMDb id not found in local mappings' };
|
||||
return handleEntry(found.type, found.tmdb);
|
||||
}
|
||||
|
||||
async function handleSearch(query) {
|
||||
const cached = searchCache.get(query);
|
||||
if (cached) {
|
||||
searchCacheHits.inc();
|
||||
return cached;
|
||||
}
|
||||
searchCacheMisses.inc();
|
||||
|
||||
const parsed = parseQuery(query);
|
||||
if (!parsed) return null;
|
||||
if (parsed.error) return { error: parsed.error };
|
||||
@@ -49,7 +75,9 @@ async function handleSearch(query) {
|
||||
|
||||
const matches = await runSearch(type, filteredTitleIn, yearin);
|
||||
if (!matches.length) {
|
||||
return { error: 'Not found in localized and original titles database' };
|
||||
const out = { error: 'Not found in localized and original titles database' };
|
||||
searchCache.set(query, out);
|
||||
return out;
|
||||
}
|
||||
|
||||
const ratings = await getRatings();
|
||||
@@ -108,8 +136,6 @@ async function handleSearch(query) {
|
||||
}
|
||||
|
||||
if (episodein && Array.isArray(detail.seasons)) {
|
||||
// PHP loops and overwrites $data['results'][$j]['season'] for each season,
|
||||
// so only the LAST season is kept. Reproduce that behaviour.
|
||||
let lastSeason;
|
||||
for (const s of detail.seasons) {
|
||||
const sn = pad2(s.season_number || 0);
|
||||
@@ -125,7 +151,9 @@ async function handleSearch(query) {
|
||||
results.push(item);
|
||||
}
|
||||
|
||||
return { results };
|
||||
const out = { results };
|
||||
searchCache.set(query, out);
|
||||
return out;
|
||||
}
|
||||
|
||||
async function handle(req, reply) {
|
||||
@@ -142,6 +170,22 @@ async function handle(req, reply) {
|
||||
return handleEntry(t, id);
|
||||
}
|
||||
|
||||
if (t === 'imdb') {
|
||||
if (!q) return {};
|
||||
return handleImdbLookup(String(q));
|
||||
}
|
||||
|
||||
if (t === 'providers') {
|
||||
const type = req.query?.type;
|
||||
if (!q || (type !== 'movie' && type !== 'tv')) {
|
||||
return { error: 'providers requires type=movie|tv and q=<id>' };
|
||||
}
|
||||
const id = parseInt(q, 10);
|
||||
if (!Number.isInteger(id)) return {};
|
||||
const data = await getProviders(type, id);
|
||||
return data ?? { error: 'Providers not found' };
|
||||
}
|
||||
|
||||
if (t === 'search') {
|
||||
if (!q) return reply.send('');
|
||||
return (await handleSearch(q)) ?? {};
|
||||
|
||||
35
routes/health.js
Normal file
35
routes/health.js
Normal file
@@ -0,0 +1,35 @@
|
||||
// /health (JSON liveness/readiness) and /metrics (Prometheus exposition).
|
||||
|
||||
import { getRatings } from '../lib/imdbRatings.js';
|
||||
import { imdbRatingsCount, registry } from '../lib/metrics.js';
|
||||
|
||||
export default async function healthRoutes(fastify) {
|
||||
fastify.get('/health', async (_req, reply) => {
|
||||
reply.header('Cache-Control', 'no-store');
|
||||
let ratings = 0;
|
||||
let ratingsOk = false;
|
||||
try {
|
||||
const map = await getRatings();
|
||||
ratings = map.size;
|
||||
imdbRatingsCount.set(ratings);
|
||||
ratingsOk = true;
|
||||
} catch {
|
||||
/* ignore — reported via ratingsOk */
|
||||
}
|
||||
const status = ratingsOk ? 'ok' : 'degraded';
|
||||
reply.code(ratingsOk ? 200 : 503);
|
||||
return {
|
||||
status,
|
||||
uptime: Math.round(process.uptime()),
|
||||
pid: process.pid,
|
||||
node: process.version,
|
||||
memory_mb: Math.round(process.memoryUsage().rss / 1024 / 1024),
|
||||
imdb_ratings: ratings,
|
||||
};
|
||||
});
|
||||
|
||||
fastify.get('/metrics', async (_req, reply) => {
|
||||
reply.header('Content-Type', registry.contentType);
|
||||
return registry.metrics();
|
||||
});
|
||||
}
|
||||
144
routes/search.js
144
routes/search.js
@@ -1,144 +0,0 @@
|
||||
// HTML search view — replaces search.php (the public, human-facing version).
|
||||
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { entryPath } from '../lib/paths.js';
|
||||
import { getRatings, lookupRating } from '../lib/imdbRatings.js';
|
||||
import { parseQuery } from '../lib/queryParser.js';
|
||||
import { filterAndLower } from '../lib/titleFilter.js';
|
||||
import { search as runSearch } from '../lib/searchEngine.js';
|
||||
import { formatCurrency, formatRuntime, pad2 } from '../lib/format.js';
|
||||
import {
|
||||
POSTER_URL, NO_POSTER_URL, MOVIE_URL, TV_URL, IMDB_URL,
|
||||
} from '../config.js';
|
||||
|
||||
function esc(s) {
|
||||
if (s == null) return '';
|
||||
return String(s)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
function center(msg) {
|
||||
return `<div style="text-align: center;">${esc(msg)}</div>`;
|
||||
}
|
||||
|
||||
async function getDetail(type, id) {
|
||||
try {
|
||||
return JSON.parse(await readFile(entryPath(type, id), 'utf8'));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function render(query) {
|
||||
if (!query) return '';
|
||||
|
||||
const parsed = parseQuery(query);
|
||||
if (!parsed) return '';
|
||||
if (parsed.error) return center(parsed.error);
|
||||
|
||||
const { type, titlein, yearin, episodein } = parsed;
|
||||
const filteredTitleIn = filterAndLower(titlein);
|
||||
|
||||
const matches = await runSearch(type, filteredTitleIn, yearin);
|
||||
if (!matches.length) {
|
||||
return center('Not found in localized and original titles database');
|
||||
}
|
||||
|
||||
const ratings = await getRatings();
|
||||
const movietvurl = type === 'movie' ? MOVIE_URL : TV_URL;
|
||||
|
||||
let html = '<div style="text-align: center; font-size: 14px; font-family: sans-serif;">';
|
||||
|
||||
for (const m of matches) {
|
||||
const detail = await getDetail(type, m.tmdb);
|
||||
if (!detail) continue;
|
||||
|
||||
const poster = detail.poster_path;
|
||||
const src = poster ? `${POSTER_URL}/${poster}` : NO_POSTER_URL;
|
||||
|
||||
let genres = '';
|
||||
if (Array.isArray(detail.genres)) {
|
||||
for (const g of detail.genres) genres += `${g.name} `;
|
||||
}
|
||||
|
||||
let countries = '';
|
||||
if (Array.isArray(detail.production_countries)) {
|
||||
for (const c of detail.production_countries) countries += `${c.iso_3166_1} `;
|
||||
}
|
||||
|
||||
const runtime = detail.runtime;
|
||||
const imdb = !episodein ? detail.imdb_id : detail?.external_ids?.imdb_id;
|
||||
const { rating: ivote, votes: ivoteCount } = lookupRating(ratings, imdb);
|
||||
const tvote = Math.round((parseFloat(detail.vote_average) || 0) * 10) / 10;
|
||||
const tvoteCount = parseInt(detail.vote_count, 10) || 0;
|
||||
const budget = detail.budget;
|
||||
const revenue = detail.revenue;
|
||||
|
||||
let seasons;
|
||||
if (episodein && Array.isArray(detail.seasons)) {
|
||||
seasons = detail.seasons.map((s) => `S${pad2(s.season_number || 0)}E${pad2(s.episode_count || 0)}`);
|
||||
}
|
||||
|
||||
html += '<span style="display: inline-block; margin: 10px; vertical-align: top;">';
|
||||
html += `<img src="${esc(src)}" width="200" height="300"/>`;
|
||||
html += '<div style="display: inline-block; white-space: normal; overflow: auto; vertical-align: top; width: 400px; height: 300px; background-color: #484848; color: #ffffff;">';
|
||||
|
||||
html += '<p style="margin: 10px;">';
|
||||
if (m.filteredTitle) html += `FR <b>${esc(m.title)}</b> ${esc(m.year)}<br />`;
|
||||
if (m.filteredEnglishTitle) html += `EN <b>${esc(m.englishTitle)}</b> ${esc(m.year)}<br />`;
|
||||
if (m.filteredOriginalTitle) html += `VO <b>${esc(m.originalTitle)}</b> ${esc(m.year)}<br />`;
|
||||
|
||||
html += '<p style="margin: 10px;">';
|
||||
if (genres) html += esc(genres);
|
||||
if (countries) html += `<b>${esc(countries)}</b>`;
|
||||
if (runtime) html += formatRuntime(runtime);
|
||||
html += '</p>';
|
||||
|
||||
html += '<p style="margin: 10px;">';
|
||||
if (imdb) {
|
||||
html += `<a href="${esc(IMDB_URL)}/${esc(imdb)}" style="background-color: #f3ce13; color: #000000; text-decoration: none;" onclick="this.target='_blank';">`;
|
||||
html += ` IMDb </a> ${esc(imdb)} <b>${esc(ivote)}</b> ${esc(ivoteCount)} `;
|
||||
}
|
||||
html += `<a href="${esc(movietvurl)}/${esc(m.tmdb)}" style="background-color: #01b4e4; color: #000000; text-decoration: none;" onclick="this.target='_blank';">`;
|
||||
html += ` TMDb </a> ${esc(m.tmdb)} <b>${esc(tvote)}</b> ${esc(tvoteCount)}<br />`;
|
||||
|
||||
if (budget || revenue) {
|
||||
html += `${esc(formatCurrency(budget))} ${esc(formatCurrency(revenue))}`;
|
||||
}
|
||||
html += '</p>';
|
||||
|
||||
html += '<p style="margin: 10px; text-align: left;">';
|
||||
if (seasons) {
|
||||
html += '<b>Episodes finaux</b> ';
|
||||
for (const s of seasons) html += `${esc(s)} `;
|
||||
}
|
||||
html += '</p>';
|
||||
|
||||
html += '<p style="margin: 10px; text-align: left;">';
|
||||
html += `<b>${esc(detail.tagline || '')}</b>`;
|
||||
html += '</p>';
|
||||
|
||||
html += '<p style="margin: 10px; text-align: justify;">';
|
||||
if (detail.overview) html += esc(detail.overview);
|
||||
html += '</p>';
|
||||
|
||||
html += '</div></span>';
|
||||
}
|
||||
|
||||
html += '</div>';
|
||||
return html;
|
||||
}
|
||||
|
||||
async function handle(req, reply) {
|
||||
reply.header('Content-Type', 'text/html; charset=utf-8');
|
||||
return render(req.query?.query || '');
|
||||
}
|
||||
|
||||
export default async function searchRoutes(fastify) {
|
||||
fastify.get('/search', handle);
|
||||
fastify.get('/search.php', handle);
|
||||
}
|
||||
Reference in New Issue
Block a user