refactor: add SubNav layout and per-subsite nav placeholders; switch Base to use SubNav

This commit is contained in:
2025-11-28 09:07:55 -05:00
parent de50889b2c
commit d8d6c5ec21
26 changed files with 1227 additions and 122 deletions

112
src/pages/api/search.js Normal file
View 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
View 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
View 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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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);
---

View 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>

View File

@@ -0,0 +1,8 @@
---
import Base from "../../../layouts/Base.astro";
import ReqForm from "../../../components/req/ReqForm.jsx";
---
<Base>
<ReqForm client:load />
</Base>