refactor: add SubNav layout and per-subsite nav placeholders; switch Base to use SubNav
This commit is contained in:
112
src/pages/api/search.js
Normal file
112
src/pages/api/search.js
Normal file
@@ -0,0 +1,112 @@
|
||||
const rateLimitMap = new Map();
|
||||
|
||||
function checkRateLimit(userId, limit = 5, windowMs = 1000) {
|
||||
const now = Date.now();
|
||||
const key = userId;
|
||||
const entry = rateLimitMap.get(key);
|
||||
|
||||
if (!entry || now > entry.resetTime) {
|
||||
rateLimitMap.set(key, { count: 1, resetTime: now + windowMs });
|
||||
return true;
|
||||
}
|
||||
|
||||
if (entry.count >= limit) {
|
||||
return false;
|
||||
}
|
||||
|
||||
entry.count++;
|
||||
return true;
|
||||
}
|
||||
|
||||
import { getSubsiteByHost } from '../../utils/subsites.js';
|
||||
|
||||
export async function GET({ request }) {
|
||||
const host = request.headers.get('host');
|
||||
const subsite = getSubsiteByHost(host);
|
||||
|
||||
if (!subsite || subsite.short !== 'req') {
|
||||
return new Response('Not found', { status: 404 });
|
||||
}
|
||||
|
||||
let userId = request.headers.get('cookie')?.split(';').find(c => c.trim().startsWith('nonce='))?.split('=')[1];
|
||||
const hadCookie = !!userId;
|
||||
if (!userId) {
|
||||
userId = crypto.randomUUID();
|
||||
}
|
||||
|
||||
if (!checkRateLimit(userId)) {
|
||||
const response = new Response(JSON.stringify({ error: 'Rate limit exceeded' }), { status: 429, headers: { 'Content-Type': 'application/json' } });
|
||||
if (!hadCookie) {
|
||||
response.headers.set('Set-Cookie', `nonce=${userId}; HttpOnly; Path=/; Max-Age=31536000`);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
const TMDB_API_KEY = import.meta.env.TMDB_API_KEY;
|
||||
|
||||
if (!TMDB_API_KEY) {
|
||||
console.error('TMDB_API_KEY not set');
|
||||
const response = new Response(JSON.stringify([]), { status: 500, headers: { 'Content-Type': 'application/json' } });
|
||||
if (!hadCookie) {
|
||||
response.headers.set('Set-Cookie', `nonce=${userId}; HttpOnly; Path=/; Max-Age=31536000`);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
const url = new URL(request.url);
|
||||
const q = url.searchParams.get('q');
|
||||
|
||||
if (!q || typeof q !== 'string' || !q.trim()) {
|
||||
const response = new Response(JSON.stringify([]), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
||||
if (!hadCookie) {
|
||||
response.headers.set('Set-Cookie', `nonce=${userId}; HttpOnly; Path=/; Max-Age=31536000`);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
if (q.length > 100) {
|
||||
const response = new Response(JSON.stringify([]), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
||||
if (!hadCookie) {
|
||||
response.headers.set('Set-Cookie', `nonce=${userId}; HttpOnly; Path=/; Max-Age=31536000`);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
try {
|
||||
const apiResponse = await fetch(`https://api.themoviedb.org/3/search/multi?api_key=${TMDB_API_KEY}&query=${encodeURIComponent(q)}`);
|
||||
if (!apiResponse.ok) {
|
||||
throw new Error(`TMDB API error: ${apiResponse.status}`);
|
||||
}
|
||||
const data = await apiResponse.json();
|
||||
const seen = new Set();
|
||||
const filtered = data.results
|
||||
.filter(item => {
|
||||
if (item.media_type !== 'movie' && item.media_type !== 'tv') return false;
|
||||
const key = `${item.media_type}-${item.title || item.name}-${item.release_date?.split('-')[0] || item.first_air_date?.split('-')[0] || ''}`;
|
||||
if (seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
return true;
|
||||
})
|
||||
.slice(0, 10) // Limit to 10 suggestions
|
||||
.map(item => ({
|
||||
label: item.title || item.name,
|
||||
value: item.title || item.name,
|
||||
year: item.release_date?.split('-')[0] || item.first_air_date?.split('-')[0],
|
||||
mediaType: item.media_type,
|
||||
overview: item.overview,
|
||||
poster_path: item.poster_path,
|
||||
}));
|
||||
const response = new Response(JSON.stringify(filtered), { headers: { 'Content-Type': 'application/json' } });
|
||||
if (!hadCookie) {
|
||||
response.headers.set('Set-Cookie', `nonce=${userId}; HttpOnly; Path=/; Max-Age=31536000`);
|
||||
}
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.error('Error fetching suggestions:', error);
|
||||
const response = new Response(JSON.stringify([]), { status: 500, headers: { 'Content-Type': 'application/json' } });
|
||||
if (!hadCookie) {
|
||||
response.headers.set('Set-Cookie', `nonce=${userId}; HttpOnly; Path=/; Max-Age=31536000`);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
}
|
||||
234
src/pages/api/submit.js
Normal file
234
src/pages/api/submit.js
Normal file
@@ -0,0 +1,234 @@
|
||||
const rateLimitMap = new Map();
|
||||
|
||||
function checkRateLimit(userId, limit = 1, windowMs = 15000) { // 1 per 15 seconds for submits
|
||||
const now = Date.now();
|
||||
const key = userId;
|
||||
const entry = rateLimitMap.get(key);
|
||||
|
||||
if (!entry || now > entry.resetTime) {
|
||||
return true; // Allow, will be recorded later
|
||||
}
|
||||
|
||||
if (entry.count >= limit) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true; // Allow, will be recorded later
|
||||
}
|
||||
|
||||
function recordRequest(userId, windowMs = 15000) {
|
||||
const now = Date.now();
|
||||
const key = userId;
|
||||
const entry = rateLimitMap.get(key);
|
||||
|
||||
if (!entry || now > entry.resetTime) {
|
||||
rateLimitMap.set(key, { count: 1, resetTime: now + windowMs });
|
||||
} else {
|
||||
entry.count++;
|
||||
}
|
||||
}
|
||||
|
||||
import { getSubsiteByHost } from '../../utils/subsites.js';
|
||||
|
||||
export async function POST({ request }) {
|
||||
const host = request.headers.get('host');
|
||||
const subsite = getSubsiteByHost(host);
|
||||
if (!subsite || subsite.short !== 'req') {
|
||||
return new Response('Not found', { status: 404 });
|
||||
}
|
||||
|
||||
let userId = request.headers.get('cookie')?.split(';').find(c => c.trim().startsWith('nonce='))?.split('=')[1];
|
||||
const hadCookie = !!userId;
|
||||
if (!userId) {
|
||||
userId = crypto.randomUUID();
|
||||
}
|
||||
|
||||
if (!checkRateLimit(userId)) {
|
||||
const response = new Response(JSON.stringify({ error: 'Rate limit exceeded' }), { status: 429, headers: { 'Content-Type': 'application/json' } });
|
||||
if (!hadCookie) {
|
||||
response.headers.set('Set-Cookie', `nonce=${userId}; HttpOnly; Path=/; Max-Age=31536000`);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
const DISCORD_WEBHOOK_URL = import.meta.env.BUDO_REQ_DISCORD_WEBHOOK_URL;
|
||||
|
||||
if (!DISCORD_WEBHOOK_URL) {
|
||||
console.error('DISCORD_WEBHOOK_URL not set');
|
||||
const response = new Response(JSON.stringify({ error: 'Webhook not configured' }), { status: 500, headers: { 'Content-Type': 'application/json' } });
|
||||
if (!hadCookie) {
|
||||
response.headers.set('Set-Cookie', `nonce=${userId}; HttpOnly; Path=/; Max-Age=31536000`);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
try {
|
||||
const { title, year, type, requester } = await request.json();
|
||||
|
||||
// Input validation
|
||||
if (!title || typeof title !== 'string' || !title.trim()) {
|
||||
const response = new Response(JSON.stringify({ error: 'Title is required and must be a non-empty string' }), { status: 400, headers: { 'Content-Type': 'application/json' } });
|
||||
if (!hadCookie) {
|
||||
response.headers.set('Set-Cookie', `nonce=${userId}; HttpOnly; Path=/; Max-Age=31536000`);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
if (title.length > 200) {
|
||||
const response = new Response(JSON.stringify({ error: 'Title must be 200 characters or less' }), { status: 400, headers: { 'Content-Type': 'application/json' } });
|
||||
if (!hadCookie) {
|
||||
response.headers.set('Set-Cookie', `nonce=${userId}; HttpOnly; Path=/; Max-Age=31536000`);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
if (!['movie', 'tv'].includes(type)) {
|
||||
const response = new Response(JSON.stringify({ error: 'Type must be either "movie" or "tv"' }), { status: 400, headers: { 'Content-Type': 'application/json' } });
|
||||
if (!hadCookie) {
|
||||
response.headers.set('Set-Cookie', `nonce=${userId}; HttpOnly; Path=/; Max-Age=31536000`);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
if (year && (typeof year !== 'string' || !/^\d{4}$/.test(year))) {
|
||||
const response = new Response(JSON.stringify({ error: 'Year must be a 4-digit number if provided' }), { status: 400, headers: { 'Content-Type': 'application/json' } });
|
||||
if (!hadCookie) {
|
||||
response.headers.set('Set-Cookie', `nonce=${userId}; HttpOnly; Path=/; Max-Age=31536000`);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
if (requester && (typeof requester !== 'string' || requester.length > 500)) {
|
||||
const response = new Response(JSON.stringify({ error: 'Requester name must be 500 characters or less if provided' }), { status: 400, headers: { 'Content-Type': 'application/json' } });
|
||||
if (!hadCookie) {
|
||||
response.headers.set('Set-Cookie', `nonce=${userId}; HttpOnly; Path=/; Max-Age=31536000`);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
// Fetch synopsis and IMDb ID from TMDB
|
||||
let synopsis = '';
|
||||
let imdbId = '';
|
||||
let matchingItem = null;
|
||||
try {
|
||||
const searchResponse = await fetch(`https://api.themoviedb.org/3/search/multi?api_key=${import.meta.env.TMDB_API_KEY}&query=${encodeURIComponent(title)}`);
|
||||
if (searchResponse.ok) {
|
||||
const searchData = await searchResponse.json();
|
||||
matchingItem = searchData.results.find(item =>
|
||||
item.media_type === type &&
|
||||
(item.title || item.name) === title
|
||||
);
|
||||
if (matchingItem) {
|
||||
synopsis = matchingItem.overview || '';
|
||||
|
||||
// Get detailed info for IMDb ID
|
||||
const detailEndpoint = type === 'movie' ? `movie/${matchingItem.id}` : `tv/${matchingItem.id}`;
|
||||
const detailResponse = await fetch(`https://api.themoviedb.org/3/${detailEndpoint}?api_key=${import.meta.env.TMDB_API_KEY}`);
|
||||
if (detailResponse.ok) {
|
||||
const detailData = await detailResponse.json();
|
||||
imdbId = detailData.imdb_id;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching synopsis and IMDb:', error);
|
||||
// Continue without synopsis
|
||||
}
|
||||
|
||||
const fields = [
|
||||
{
|
||||
name: "Title",
|
||||
value: title,
|
||||
inline: false
|
||||
},
|
||||
{
|
||||
name: "Type",
|
||||
value: type === 'tv' ? 'TV Show' : 'Movie',
|
||||
inline: false
|
||||
}
|
||||
];
|
||||
|
||||
if (year) {
|
||||
fields.push({
|
||||
name: "Year",
|
||||
value: year,
|
||||
inline: false
|
||||
});
|
||||
}
|
||||
|
||||
if (synopsis) {
|
||||
fields.push({
|
||||
name: "Synopsis",
|
||||
value: synopsis,
|
||||
inline: false
|
||||
});
|
||||
}
|
||||
|
||||
if (imdbId) {
|
||||
fields.push({
|
||||
name: "IMDb",
|
||||
value: `[View on IMDb](https://www.imdb.com/title/${imdbId}/)`,
|
||||
inline: false
|
||||
});
|
||||
} else if (matchingItem) {
|
||||
const tmdbUrl = `https://www.themoviedb.org/${type}/${matchingItem.id}`;
|
||||
fields.push({
|
||||
name: "TMDB",
|
||||
value: `[View on TMDB](${tmdbUrl})`,
|
||||
inline: false
|
||||
});
|
||||
}
|
||||
|
||||
if (requester && requester.trim()) {
|
||||
fields.push({
|
||||
name: "Requested by",
|
||||
value: requester.trim(),
|
||||
inline: false
|
||||
});
|
||||
}
|
||||
|
||||
const embed = {
|
||||
title: type === 'tv' ? "📺 New TV Show Request" : "🎥 New Movie Request",
|
||||
color: type === 'tv' ? 0x4ecdc4 : 0xff6b6b,
|
||||
fields: fields,
|
||||
timestamp: new Date().toISOString(),
|
||||
footer: {
|
||||
text: subsite.host || 'req.boatson.boats'
|
||||
}
|
||||
};
|
||||
|
||||
if (matchingItem && matchingItem.poster_path) {
|
||||
embed.image = { url: `https://image.tmdb.org/t/p/w780${matchingItem.poster_path}` };
|
||||
} else {
|
||||
// Test image
|
||||
embed.image = { url: 'https://image.tmdb.org/t/p/w780/9O7gLzmreU0nGkIB6K3BsJbzvNv.jpg' };
|
||||
}
|
||||
|
||||
const apiResponse = await fetch(DISCORD_WEBHOOK_URL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ embeds: [embed] }),
|
||||
});
|
||||
|
||||
if (!apiResponse.ok) {
|
||||
throw new Error(`Discord webhook error: ${apiResponse.status}`);
|
||||
}
|
||||
|
||||
recordRequest(userId);
|
||||
|
||||
const response = new Response(JSON.stringify({ success: true }), { headers: { 'Content-Type': 'application/json' } });
|
||||
if (!hadCookie) {
|
||||
response.headers.set('Set-Cookie', `nonce=${userId}; HttpOnly; Path=/; Max-Age=31536000`);
|
||||
}
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.error('Webhook submission error:', error);
|
||||
const response = new Response(JSON.stringify({ error: 'Failed to submit' }), { status: 500, headers: { 'Content-Type': 'application/json' } });
|
||||
if (!hadCookie) {
|
||||
response.headers.set('Set-Cookie', `nonce=${userId}; HttpOnly; Path=/; Max-Age=31536000`);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
}
|
||||
24
src/pages/debug.astro
Normal file
24
src/pages/debug.astro
Normal file
@@ -0,0 +1,24 @@
|
||||
---
|
||||
---
|
||||
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Debug</title>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Debug</h1>
|
||||
<ul>
|
||||
<li>Host: {Astro.request.headers.get('host')}</li>
|
||||
<li>Path: {Astro.url.pathname}</li>
|
||||
<li>Is dev: {String(import.meta.env.DEV)}</li>
|
||||
</ul>
|
||||
<h2>Headers</h2>
|
||||
<ul>
|
||||
{Array.from(Astro.request.headers.entries()).map(([k, v]) => (
|
||||
<li>{k}: {v}</li>
|
||||
))}
|
||||
</ul>
|
||||
</body>
|
||||
</html>
|
||||
@@ -2,15 +2,35 @@
|
||||
import Base from "../layouts/Base.astro";
|
||||
import Root from "../components/AppLayout.jsx";
|
||||
import LyricSearch from '../components/LyricSearch.jsx';
|
||||
|
||||
const hostHeader = Astro.request?.headers?.get('host') || '';
|
||||
const host = hostHeader.split(':')[0];
|
||||
import { getSubsiteByHost } from '../utils/subsites.js';
|
||||
import { getSubsiteByPath } from '../utils/subsites.js';
|
||||
const detected = getSubsiteByHost(host) ?? getSubsiteByPath(Astro.url.pathname) ?? null;
|
||||
const isReq = detected?.short === 'req' || getSubsiteByPath(Astro.url.pathname)?.short === 'req';
|
||||
|
||||
import { WHITELABELS } from "../config";
|
||||
const whitelabel = WHITELABELS[host] ?? (detected ? WHITELABELS[detected.host] : null);
|
||||
---
|
||||
|
||||
<Base>
|
||||
<section>
|
||||
<div class="prose prose-neutral dark:prose-invert">
|
||||
<Root
|
||||
child="LyricSearch"
|
||||
client:only="react"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
{whitelabel ? (
|
||||
<section>
|
||||
<div class="prose prose-neutral dark:prose-invert">
|
||||
<Root child="ReqForm" client:only="react">
|
||||
</Root>
|
||||
</div>
|
||||
</section>
|
||||
) : (
|
||||
<section>
|
||||
<div class="prose prose-neutral dark:prose-invert">
|
||||
<Root
|
||||
child="LyricSearch"
|
||||
client:only="react"
|
||||
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</Base>
|
||||
|
||||
@@ -10,7 +10,7 @@ const isLoggedIn = Boolean(user);
|
||||
<Base>
|
||||
<section>
|
||||
<div class="prose prose-neutral dark:prose-invert">
|
||||
<Root child="LoginPage" loggedIn={isLoggedIn} client:only="react" >
|
||||
<Root child="LoginPage" loggedIn={isLoggedIn} client:only="react">
|
||||
</Root>
|
||||
</section>
|
||||
</Base>
|
||||
@@ -1,6 +1,7 @@
|
||||
---
|
||||
import Base from "../layouts/Base.astro";
|
||||
import Root from "../components/AppLayout.jsx";
|
||||
// The Base layout exposes runtime subsite state — no per-page detection needed
|
||||
import { requireAuthHook } from "@/hooks/requireAuthHook";
|
||||
const user = await requireAuthHook(Astro);
|
||||
---
|
||||
|
||||
8
src/pages/subsites/req/debug.astro
Normal file
8
src/pages/subsites/req/debug.astro
Normal file
@@ -0,0 +1,8 @@
|
||||
---
|
||||
import Base from "../../../layouts/Base.astro";
|
||||
---
|
||||
|
||||
<Base>
|
||||
<h2>Req subsite debug</h2>
|
||||
<pre>{JSON.stringify({ headers: Object.fromEntries(Astro.request.headers.entries()), pathname: Astro.url.pathname }, null, 2)}</pre>
|
||||
</Base>
|
||||
8
src/pages/subsites/req/index.astro
Normal file
8
src/pages/subsites/req/index.astro
Normal file
@@ -0,0 +1,8 @@
|
||||
---
|
||||
import Base from "../../../layouts/Base.astro";
|
||||
import ReqForm from "../../../components/req/ReqForm.jsx";
|
||||
---
|
||||
|
||||
<Base>
|
||||
<ReqForm client:load />
|
||||
</Base>
|
||||
Reference in New Issue
Block a user