Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9006b208af | ||
|
|
20c8ad7eaf | ||
|
|
03129a5611 | ||
|
|
fd8f4adcc0 | ||
|
|
23997ccc3a | ||
|
|
85bdbf795c |
@@ -49,8 +49,6 @@ RUN node_modules/.bin/prisma generate
|
|||||||
ARG NEXT_PUBLIC_APP_NAME
|
ARG NEXT_PUBLIC_APP_NAME
|
||||||
ARG NEXT_PUBLIC_APP_DESCRIPTION
|
ARG NEXT_PUBLIC_APP_DESCRIPTION
|
||||||
ARG NEXT_PUBLIC_DOMAIN
|
ARG NEXT_PUBLIC_DOMAIN
|
||||||
ARG NEXT_PUBLIC_TWITTER_HANDLE
|
|
||||||
ARG NEXT_PUBLIC_PLAUSIBLE_DOMAIN
|
|
||||||
ARG NEXT_PUBLIC_PLAUSIBLE_SCRIPT_SRC
|
ARG NEXT_PUBLIC_PLAUSIBLE_SCRIPT_SRC
|
||||||
ARG NEXT_PUBLIC_THEME_COLOR
|
ARG NEXT_PUBLIC_THEME_COLOR
|
||||||
ARG NEXT_PUBLIC_BACKGROUND_COLOR
|
ARG NEXT_PUBLIC_BACKGROUND_COLOR
|
||||||
@@ -63,8 +61,6 @@ ARG NEXT_PUBLIC_CREDITS_LINK_URL
|
|||||||
ENV NEXT_PUBLIC_APP_NAME=$NEXT_PUBLIC_APP_NAME
|
ENV NEXT_PUBLIC_APP_NAME=$NEXT_PUBLIC_APP_NAME
|
||||||
ENV NEXT_PUBLIC_APP_DESCRIPTION=$NEXT_PUBLIC_APP_DESCRIPTION
|
ENV NEXT_PUBLIC_APP_DESCRIPTION=$NEXT_PUBLIC_APP_DESCRIPTION
|
||||||
ENV NEXT_PUBLIC_DOMAIN=$NEXT_PUBLIC_DOMAIN
|
ENV NEXT_PUBLIC_DOMAIN=$NEXT_PUBLIC_DOMAIN
|
||||||
ENV NEXT_PUBLIC_TWITTER_HANDLE=$NEXT_PUBLIC_TWITTER_HANDLE
|
|
||||||
ENV NEXT_PUBLIC_PLAUSIBLE_DOMAIN=$NEXT_PUBLIC_PLAUSIBLE_DOMAIN
|
|
||||||
ENV NEXT_PUBLIC_PLAUSIBLE_SCRIPT_SRC=$NEXT_PUBLIC_PLAUSIBLE_SCRIPT_SRC
|
ENV NEXT_PUBLIC_PLAUSIBLE_SCRIPT_SRC=$NEXT_PUBLIC_PLAUSIBLE_SCRIPT_SRC
|
||||||
ENV NEXT_PUBLIC_THEME_COLOR=$NEXT_PUBLIC_THEME_COLOR
|
ENV NEXT_PUBLIC_THEME_COLOR=$NEXT_PUBLIC_THEME_COLOR
|
||||||
ENV NEXT_PUBLIC_BACKGROUND_COLOR=$NEXT_PUBLIC_BACKGROUND_COLOR
|
ENV NEXT_PUBLIC_BACKGROUND_COLOR=$NEXT_PUBLIC_BACKGROUND_COLOR
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import { PrismaClient } from '@prisma/client';
|
|||||||
import { notFound } from 'next/navigation';
|
import { notFound } from 'next/navigation';
|
||||||
import { getLocalizedValue } from '@/lib/i18n';
|
import { getLocalizedValue } from '@/lib/i18n';
|
||||||
import { getTranslations } from 'next-intl/server';
|
import { getTranslations } from 'next-intl/server';
|
||||||
|
import { generateBaseMetadata } from '@/lib/metadata';
|
||||||
|
import type { Metadata } from 'next';
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
@@ -15,6 +17,32 @@ interface PageProps {
|
|||||||
params: Promise<{ locale: string; genre: string }>;
|
params: Promise<{ locale: string; genre: string }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
|
||||||
|
const { locale, genre } = await params;
|
||||||
|
const decodedGenre = decodeURIComponent(genre);
|
||||||
|
|
||||||
|
// Fetch genre to get localized name
|
||||||
|
const allGenres = await prisma.genre.findMany();
|
||||||
|
const currentGenre = allGenres.find(g => getLocalizedValue(g.name, locale) === decodedGenre);
|
||||||
|
|
||||||
|
if (!currentGenre || !currentGenre.active) {
|
||||||
|
return await generateBaseMetadata(locale, genre);
|
||||||
|
}
|
||||||
|
|
||||||
|
const genreName = getLocalizedValue(currentGenre.name, locale);
|
||||||
|
const genreSubtitle = getLocalizedValue(currentGenre.subtitle, locale);
|
||||||
|
|
||||||
|
const title = locale === 'de'
|
||||||
|
? `${genreName} - Hördle`
|
||||||
|
: `${genreName} - Hördle`;
|
||||||
|
|
||||||
|
const description = genreSubtitle || (locale === 'de'
|
||||||
|
? `Spiele Hördle im Genre ${genreName} und errate Songs aus kurzen Audio-Clips!`
|
||||||
|
: `Play Hördle in the ${genreName} genre and guess songs from short audio clips!`);
|
||||||
|
|
||||||
|
return await generateBaseMetadata(locale, genre, title, description);
|
||||||
|
}
|
||||||
|
|
||||||
export default async function GenrePage({ params }: PageProps) {
|
export default async function GenrePage({ params }: PageProps) {
|
||||||
const { locale, genre } = await params;
|
const { locale, genre } = await params;
|
||||||
const decodedGenre = decodeURIComponent(genre);
|
const decodedGenre = decodeURIComponent(genre);
|
||||||
|
|||||||
@@ -1,10 +1,22 @@
|
|||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
import { Link } from "@/lib/navigation";
|
import { Link } from "@/lib/navigation";
|
||||||
|
import { generateBaseMetadata } from "@/lib/metadata";
|
||||||
|
import type { Metadata } from "next";
|
||||||
|
|
||||||
interface AboutPageProps {
|
interface AboutPageProps {
|
||||||
params: Promise<{ locale: string }>;
|
params: Promise<{ locale: string }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function generateMetadata({ params }: AboutPageProps): Promise<Metadata> {
|
||||||
|
const { locale } = await params;
|
||||||
|
const t = await getTranslations({ locale, namespace: "About" });
|
||||||
|
|
||||||
|
const title = t("title");
|
||||||
|
const description = t("intro");
|
||||||
|
|
||||||
|
return await generateBaseMetadata(locale, "about", title, description);
|
||||||
|
}
|
||||||
|
|
||||||
export default async function AboutPage({ params }: AboutPageProps) {
|
export default async function AboutPage({ params }: AboutPageProps) {
|
||||||
const { locale } = await params;
|
const { locale } = await params;
|
||||||
const t = await getTranslations({ locale, namespace: "About" });
|
const t = await getTranslations({ locale, namespace: "About" });
|
||||||
@@ -51,11 +63,6 @@ export default async function AboutPage({ params }: AboutPageProps) {
|
|||||||
{t("imprintEmailLabel")}{" "}
|
{t("imprintEmailLabel")}{" "}
|
||||||
<a href="mailto:markus@hoerdle.de">markus@hoerdle.de</a>
|
<a href="mailto:markus@hoerdle.de">markus@hoerdle.de</a>
|
||||||
</p>
|
</p>
|
||||||
<p
|
|
||||||
style={{ marginTop: "0.5rem", fontSize: "0.9rem", color: "#6b7280" }}
|
|
||||||
>
|
|
||||||
{t("imprintDisclaimer")}
|
|
||||||
</p>
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section style={{ marginBottom: "2rem" }}>
|
<section style={{ marginBottom: "2rem" }}>
|
||||||
|
|||||||
@@ -5,8 +5,10 @@ import "../globals.css"; // Adjusted path
|
|||||||
import { NextIntlClientProvider } from 'next-intl';
|
import { NextIntlClientProvider } from 'next-intl';
|
||||||
import { getMessages } from 'next-intl/server';
|
import { getMessages } from 'next-intl/server';
|
||||||
import { notFound } from 'next/navigation';
|
import { notFound } from 'next/navigation';
|
||||||
|
import { headers } from 'next/headers';
|
||||||
|
|
||||||
import { config } from "@/lib/config";
|
import { config } from "@/lib/config";
|
||||||
|
import { generateBaseMetadata } from "@/lib/metadata";
|
||||||
import InstallPrompt from "@/components/InstallPrompt";
|
import InstallPrompt from "@/components/InstallPrompt";
|
||||||
import AppFooter from "@/components/AppFooter";
|
import AppFooter from "@/components/AppFooter";
|
||||||
|
|
||||||
@@ -20,10 +22,10 @@ const geistMono = Geist_Mono({
|
|||||||
subsets: ["latin"],
|
subsets: ["latin"],
|
||||||
});
|
});
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }): Promise<Metadata> {
|
||||||
title: config.appName,
|
const { locale } = await params;
|
||||||
description: config.appDescription,
|
return await generateBaseMetadata(locale);
|
||||||
};
|
}
|
||||||
|
|
||||||
export const viewport: Viewport = {
|
export const viewport: Viewport = {
|
||||||
themeColor: config.colors.themeColor,
|
themeColor: config.colors.themeColor,
|
||||||
@@ -52,12 +54,32 @@ export default async function LocaleLayout({
|
|||||||
// Providing all messages to the client
|
// Providing all messages to the client
|
||||||
const messages = await getMessages();
|
const messages = await getMessages();
|
||||||
|
|
||||||
|
// Get current domain from request headers for dynamic Plausible tracking
|
||||||
|
// This automatically tracks the correct domain (hoerdle.de or hördle.de)
|
||||||
|
const headersList = await headers();
|
||||||
|
const host = headersList.get('host') || headersList.get('x-forwarded-host') || '';
|
||||||
|
|
||||||
|
// Automatically detect which domain to track in Plausible based on the request
|
||||||
|
let plausibleDomain = 'hoerdle.de'; // Default fallback
|
||||||
|
|
||||||
|
if (host) {
|
||||||
|
// Extract domain from host (remove port if present)
|
||||||
|
const domain = host.split(':')[0].toLowerCase();
|
||||||
|
|
||||||
|
// Map domains: automatically track the current domain
|
||||||
|
if (domain === 'hoerdle.de') {
|
||||||
|
plausibleDomain = 'hoerdle.de';
|
||||||
|
} else if (domain === 'hördle.de' || domain === 'xn--hrdle-jua.de') {
|
||||||
|
plausibleDomain = 'hördle.de';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<html lang={locale}>
|
<html lang={locale}>
|
||||||
<head>
|
<head>
|
||||||
<Script
|
<Script
|
||||||
defer
|
defer
|
||||||
data-domain={config.plausibleDomain}
|
data-domain={plausibleDomain}
|
||||||
src={config.plausibleScriptSrc}
|
src={config.plausibleScriptSrc}
|
||||||
strategy="beforeInteractive"
|
strategy="beforeInteractive"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -7,15 +7,33 @@ import { Link } from '@/lib/navigation';
|
|||||||
import { PrismaClient } from '@prisma/client';
|
import { PrismaClient } from '@prisma/client';
|
||||||
import { getTranslations } from 'next-intl/server';
|
import { getTranslations } from 'next-intl/server';
|
||||||
import { getLocalizedValue } from '@/lib/i18n';
|
import { getLocalizedValue } from '@/lib/i18n';
|
||||||
|
import { generateBaseMetadata } from '@/lib/metadata';
|
||||||
|
import type { Metadata } from 'next';
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }): Promise<Metadata> {
|
||||||
|
const { locale } = await params;
|
||||||
|
const t = await getTranslations('Home');
|
||||||
|
|
||||||
|
// Get localized title and description
|
||||||
|
const title = locale === 'de'
|
||||||
|
? 'Hördle - Tägliches Musik-Erraten'
|
||||||
|
: 'Hördle - Daily Music Guessing Game';
|
||||||
|
|
||||||
|
const description = locale === 'de'
|
||||||
|
? 'Spiele Hördle und errate Songs aus kurzen Audio-Clips! Täglich neue Rätsel aus verschiedenen Genres. Inspiriert von Wordle, aber für Musikfans.'
|
||||||
|
: 'Play Hördle and guess songs from short audio clips! Daily new puzzles from various genres. Inspired by Wordle, but for music lovers.';
|
||||||
|
|
||||||
|
return await generateBaseMetadata(locale, '', title, description);
|
||||||
|
}
|
||||||
|
|
||||||
export default async function Home({
|
export default async function Home({
|
||||||
params
|
params
|
||||||
}: {
|
}: {
|
||||||
params: { locale: string };
|
params: Promise<{ locale: string }>;
|
||||||
}) {
|
}) {
|
||||||
const { locale } = await params;
|
const { locale } = await params;
|
||||||
const t = await getTranslations('Home');
|
const t = await getTranslations('Home');
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import { Link } from '@/lib/navigation';
|
|||||||
import { PrismaClient } from '@prisma/client';
|
import { PrismaClient } from '@prisma/client';
|
||||||
import { getLocalizedValue } from '@/lib/i18n';
|
import { getLocalizedValue } from '@/lib/i18n';
|
||||||
import { getTranslations } from 'next-intl/server';
|
import { getTranslations } from 'next-intl/server';
|
||||||
|
import { generateBaseMetadata } from '@/lib/metadata';
|
||||||
|
import type { Metadata } from 'next';
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
@@ -14,6 +16,30 @@ interface PageProps {
|
|||||||
params: Promise<{ locale: string; name: string }>;
|
params: Promise<{ locale: string; name: string }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
|
||||||
|
const { locale, name } = await params;
|
||||||
|
const decodedName = decodeURIComponent(name);
|
||||||
|
|
||||||
|
// Fetch special to get localized name
|
||||||
|
const allSpecials = await prisma.special.findMany();
|
||||||
|
const currentSpecial = allSpecials.find(s => getLocalizedValue(s.name, locale) === decodedName);
|
||||||
|
|
||||||
|
if (!currentSpecial) {
|
||||||
|
return await generateBaseMetadata(locale, `special/${name}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const specialName = getLocalizedValue(currentSpecial.name, locale);
|
||||||
|
const specialSubtitle = getLocalizedValue(currentSpecial.subtitle, locale);
|
||||||
|
|
||||||
|
const title = `★ ${specialName} - Hördle`;
|
||||||
|
|
||||||
|
const description = specialSubtitle || (locale === 'de'
|
||||||
|
? `Spiele das Hördle-Special "${specialName}" und errate Songs aus kurzen Audio-Clips!`
|
||||||
|
: `Play the Hördle special "${specialName}" and guess songs from short audio clips!`);
|
||||||
|
|
||||||
|
return await generateBaseMetadata(locale, `special/${name}`, title, description);
|
||||||
|
}
|
||||||
|
|
||||||
export default async function SpecialPage({ params }: PageProps) {
|
export default async function SpecialPage({ params }: PageProps) {
|
||||||
const { locale, name } = await params;
|
const { locale, name } = await params;
|
||||||
const decodedName = decodeURIComponent(name);
|
const decodedName = decodeURIComponent(name);
|
||||||
|
|||||||
20
app/robots.ts
Normal file
20
app/robots.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { MetadataRoute } from 'next';
|
||||||
|
import { config } from '@/lib/config';
|
||||||
|
|
||||||
|
export default function robots(): MetadataRoute.Robots {
|
||||||
|
const baseUrl = process.env.NEXT_PUBLIC_DOMAIN || config.domain;
|
||||||
|
const protocol = process.env.NODE_ENV === 'production' ? 'https' : 'http';
|
||||||
|
const siteUrl = `${protocol}://${baseUrl}`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
rules: [
|
||||||
|
{
|
||||||
|
userAgent: '*',
|
||||||
|
allow: '/',
|
||||||
|
disallow: ['/admin/', '/api/'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
sitemap: `${siteUrl}/sitemap.xml`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
128
app/sitemap.ts
Normal file
128
app/sitemap.ts
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
import { MetadataRoute } from 'next';
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
import { getLocalizedValue } from '@/lib/i18n';
|
||||||
|
import { config } from '@/lib/config';
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
||||||
|
const baseUrl = process.env.NEXT_PUBLIC_DOMAIN || config.domain;
|
||||||
|
const protocol = process.env.NODE_ENV === 'production' ? 'https' : 'http';
|
||||||
|
const siteUrl = `${protocol}://${baseUrl}`;
|
||||||
|
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
|
||||||
|
// Static pages
|
||||||
|
const staticPages: MetadataRoute.Sitemap = [
|
||||||
|
{
|
||||||
|
url: `${siteUrl}/en`,
|
||||||
|
lastModified: now,
|
||||||
|
changeFrequency: 'daily',
|
||||||
|
priority: 1.0,
|
||||||
|
alternates: {
|
||||||
|
languages: {
|
||||||
|
'de': `${siteUrl}/de`,
|
||||||
|
'en': `${siteUrl}/en`,
|
||||||
|
'x-default': `${siteUrl}/en`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
url: `${siteUrl}/de`,
|
||||||
|
lastModified: now,
|
||||||
|
changeFrequency: 'monthly',
|
||||||
|
priority: 0.8,
|
||||||
|
alternates: {
|
||||||
|
languages: {
|
||||||
|
'de': `${siteUrl}/de`,
|
||||||
|
'en': `${siteUrl}/en`,
|
||||||
|
'x-default': `${siteUrl}/en`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
url: `${siteUrl}/en/about`,
|
||||||
|
lastModified: now,
|
||||||
|
changeFrequency: 'monthly',
|
||||||
|
priority: 0.7,
|
||||||
|
alternates: {
|
||||||
|
languages: {
|
||||||
|
'de': `${siteUrl}/de/about`,
|
||||||
|
'en': `${siteUrl}/en/about`,
|
||||||
|
'x-default': `${siteUrl}/en/about`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
url: `${siteUrl}/de/about`,
|
||||||
|
lastModified: now,
|
||||||
|
changeFrequency: 'monthly',
|
||||||
|
priority: 0.7,
|
||||||
|
alternates: {
|
||||||
|
languages: {
|
||||||
|
'de': `${siteUrl}/de/about`,
|
||||||
|
'en': `${siteUrl}/en/about`,
|
||||||
|
'x-default': `${siteUrl}/en/about`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// Dynamic genre pages
|
||||||
|
try {
|
||||||
|
const genres = await prisma.genre.findMany({
|
||||||
|
where: { active: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const genrePages: MetadataRoute.Sitemap = [];
|
||||||
|
|
||||||
|
for (const genre of genres) {
|
||||||
|
const genreNameEn = getLocalizedValue(genre.name, 'en');
|
||||||
|
const genreNameDe = getLocalizedValue(genre.name, 'de');
|
||||||
|
|
||||||
|
// Only add if genre name is valid
|
||||||
|
if (genreNameEn && genreNameDe) {
|
||||||
|
const encodedEn = encodeURIComponent(genreNameEn);
|
||||||
|
const encodedDe = encodeURIComponent(genreNameDe);
|
||||||
|
|
||||||
|
genrePages.push(
|
||||||
|
{
|
||||||
|
url: `${siteUrl}/en/${encodedEn}`,
|
||||||
|
lastModified: now,
|
||||||
|
changeFrequency: 'daily',
|
||||||
|
priority: 0.9,
|
||||||
|
alternates: {
|
||||||
|
languages: {
|
||||||
|
'de': `${siteUrl}/de/${encodedDe}`,
|
||||||
|
'en': `${siteUrl}/en/${encodedEn}`,
|
||||||
|
'x-default': `${siteUrl}/en/${encodedEn}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
url: `${siteUrl}/de/${encodedDe}`,
|
||||||
|
lastModified: now,
|
||||||
|
changeFrequency: 'daily',
|
||||||
|
priority: 0.9,
|
||||||
|
alternates: {
|
||||||
|
languages: {
|
||||||
|
'de': `${siteUrl}/de/${encodedDe}`,
|
||||||
|
'en': `${siteUrl}/en/${encodedEn}`,
|
||||||
|
'x-default': `${siteUrl}/en/${encodedEn}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...staticPages, ...genrePages];
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error generating sitemap:', error);
|
||||||
|
// Return static pages only if database query fails
|
||||||
|
return staticPages;
|
||||||
|
} finally {
|
||||||
|
await prisma.$disconnect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -8,8 +8,6 @@ services:
|
|||||||
NEXT_PUBLIC_APP_NAME: ${NEXT_PUBLIC_APP_NAME}
|
NEXT_PUBLIC_APP_NAME: ${NEXT_PUBLIC_APP_NAME}
|
||||||
NEXT_PUBLIC_APP_DESCRIPTION: ${NEXT_PUBLIC_APP_DESCRIPTION}
|
NEXT_PUBLIC_APP_DESCRIPTION: ${NEXT_PUBLIC_APP_DESCRIPTION}
|
||||||
NEXT_PUBLIC_DOMAIN: ${NEXT_PUBLIC_DOMAIN}
|
NEXT_PUBLIC_DOMAIN: ${NEXT_PUBLIC_DOMAIN}
|
||||||
NEXT_PUBLIC_TWITTER_HANDLE: ${NEXT_PUBLIC_TWITTER_HANDLE}
|
|
||||||
NEXT_PUBLIC_PLAUSIBLE_DOMAIN: ${NEXT_PUBLIC_PLAUSIBLE_DOMAIN}
|
|
||||||
NEXT_PUBLIC_PLAUSIBLE_SCRIPT_SRC: ${NEXT_PUBLIC_PLAUSIBLE_SCRIPT_SRC}
|
NEXT_PUBLIC_PLAUSIBLE_SCRIPT_SRC: ${NEXT_PUBLIC_PLAUSIBLE_SCRIPT_SRC}
|
||||||
NEXT_PUBLIC_THEME_COLOR: ${NEXT_PUBLIC_THEME_COLOR}
|
NEXT_PUBLIC_THEME_COLOR: ${NEXT_PUBLIC_THEME_COLOR}
|
||||||
NEXT_PUBLIC_BACKGROUND_COLOR: ${NEXT_PUBLIC_BACKGROUND_COLOR}
|
NEXT_PUBLIC_BACKGROUND_COLOR: ${NEXT_PUBLIC_BACKGROUND_COLOR}
|
||||||
|
|||||||
167
docs/PLAUSIBLE_SETUP.md
Normal file
167
docs/PLAUSIBLE_SETUP.md
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
# Plausible Analytics Konfiguration
|
||||||
|
|
||||||
|
## Übersicht
|
||||||
|
|
||||||
|
Die App verwendet Plausible Analytics für anonyme Nutzungsstatistiken. Die Konfiguration erfolgt über Umgebungsvariablen.
|
||||||
|
|
||||||
|
## Konfiguration
|
||||||
|
|
||||||
|
### Erforderliche Variablen
|
||||||
|
|
||||||
|
**Nur eine Variable ist erforderlich:**
|
||||||
|
|
||||||
|
1. **`NEXT_PUBLIC_PLAUSIBLE_SCRIPT_SRC`** (erforderlich)
|
||||||
|
- Die vollständige URL zum Plausible-Script
|
||||||
|
- Beispiel (selbst gehostet): `https://plausible.elpatron.me/js/script.js`
|
||||||
|
- Beispiel (extern): `https://plausible.io/js/script.js`
|
||||||
|
|
||||||
|
**Hinweis:** Die Domain wird automatisch aus der Request-Domain erkannt. Beide Domains (`hoerdle.de` und `hördle.de`) werden automatisch getrackt.
|
||||||
|
|
||||||
|
### Konfiguration für Docker
|
||||||
|
|
||||||
|
Da es sich um **Build-Time Variablen** handelt (NEXT_PUBLIC_*), muss die App neu gebaut werden, wenn diese geändert werden.
|
||||||
|
|
||||||
|
#### Schritt 1: Umgebungsvariablen setzen
|
||||||
|
|
||||||
|
Erstelle oder bearbeite eine `.env`-Datei im Projektverzeichnis:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Plausible Analytics (Script-URL ist erforderlich)
|
||||||
|
NEXT_PUBLIC_PLAUSIBLE_SCRIPT_SRC=https://plausible.elpatron.me/js/script.js
|
||||||
|
|
||||||
|
# Die Domain wird automatisch erkannt - keine weitere Konfiguration nötig!
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Schritt 2: docker-compose.yml konfigurieren
|
||||||
|
|
||||||
|
Stelle sicher, dass die Variablen als Build-Args übergeben werden:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
hoerdle:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
args:
|
||||||
|
NEXT_PUBLIC_PLAUSIBLE_SCRIPT_SRC: ${NEXT_PUBLIC_PLAUSIBLE_SCRIPT_SRC}
|
||||||
|
```
|
||||||
|
|
||||||
|
Die `docker-compose.example.yml` enthält bereits diese Konfiguration.
|
||||||
|
|
||||||
|
#### Schritt 3: App neu bauen
|
||||||
|
|
||||||
|
**WICHTIG:** Nach Änderung der Plausible-Variablen muss die App neu gebaut werden:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose build --no-cache
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
Oder mit dem Deploy-Script:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/deploy.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
### Konfiguration für beide Domains
|
||||||
|
|
||||||
|
Die App unterstützt **automatisches Tracking** für beide Domains (`hoerdle.de` und `hördle.de`). Die Domain wird automatisch aus dem Request-Header ausgelesen und entsprechend in Plausible getrackt.
|
||||||
|
|
||||||
|
#### Automatisches Domain-Tracking
|
||||||
|
|
||||||
|
**Standard-Verhalten:** Die App erkennt automatisch, welche Domain aufgerufen wurde, und setzt die entsprechende `data-domain` im Plausible-Script:
|
||||||
|
- `https://hoerdle.de/*` → `data-domain="hoerdle.de"`
|
||||||
|
- `https://hördle.de/*` → `data-domain="hördle.de"`
|
||||||
|
|
||||||
|
#### In Plausible konfigurieren
|
||||||
|
|
||||||
|
Du hast zwei Optionen:
|
||||||
|
|
||||||
|
##### Option 1: Beide Domains als separate Sites (separate Statistiken) - Empfohlen für getrenntes Tracking
|
||||||
|
|
||||||
|
1. Erstelle in Plausible zwei separate Sites:
|
||||||
|
- `hoerdle.de`
|
||||||
|
- `hördle.de`
|
||||||
|
|
||||||
|
2. Fertig! Die App trackt automatisch die richtige Domain.
|
||||||
|
|
||||||
|
**Vorteil:** Separate Statistiken für jede Domain.
|
||||||
|
|
||||||
|
##### Option 2: Beide Domains als Aliase für eine Site (gemeinsame Statistiken)
|
||||||
|
|
||||||
|
1. Erstelle in Plausible eine Site: `hoerdle.de`
|
||||||
|
2. Füge `hördle.de` als Alias hinzu (in den Site-Einstellungen)
|
||||||
|
|
||||||
|
3. Fertig! Die App trackt automatisch die richtige Domain, und Plausible behandelt beide als Aliase für die gleiche Site.
|
||||||
|
|
||||||
|
**Hinweis:** Du musst nichts zusätzlich konfigurieren. Die App trackt automatisch `hoerdle.de` oder `hördle.de` basierend auf der Request-Domain, und Plausible erkennt beide als Aliase.
|
||||||
|
|
||||||
|
**Vorteil:** Gemeinsame Statistiken für beide Domains in einer Site.
|
||||||
|
|
||||||
|
#### Empfehlung
|
||||||
|
|
||||||
|
Für separate Statistiken: **Option 1** (automatisches Tracking)
|
||||||
|
Für gemeinsame Statistiken: **Option 2** (Aliase in Plausible)
|
||||||
|
|
||||||
|
### Automatische CSP-Anpassung
|
||||||
|
|
||||||
|
Die Content Security Policy (CSP) in `proxy.ts` wird automatisch an die konfigurierte Plausible-URL angepasst. Die Domain wird automatisch aus der Script-URL extrahiert.
|
||||||
|
|
||||||
|
### Prüfen der Konfiguration
|
||||||
|
|
||||||
|
Nach dem Neubau kannst du prüfen, ob Plausible korrekt geladen wird:
|
||||||
|
|
||||||
|
1. **Browser-Entwicklertools öffnen**
|
||||||
|
- Network-Tab: Suche nach dem Plausible-Script
|
||||||
|
- Console: Prüfe auf Fehler
|
||||||
|
|
||||||
|
2. **Prüfe die Meta-Tags**
|
||||||
|
```html
|
||||||
|
<script defer data-domain="hoerdle.de" src="https://plausible.elpatron.me/js/script.js"></script>
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Prüfe Plausible-Dashboard**
|
||||||
|
- Öffne dein Plausible-Dashboard
|
||||||
|
- Prüfe, ob Daten ankommen
|
||||||
|
|
||||||
|
### Troubleshooting
|
||||||
|
|
||||||
|
#### Plausible wird nicht geladen
|
||||||
|
|
||||||
|
- Prüfe, ob die Umgebungsvariablen korrekt gesetzt sind
|
||||||
|
- Prüfe, ob die App neu gebaut wurde (Build-Time Variablen!)
|
||||||
|
- Prüfe Browser-Console auf CSP-Fehler
|
||||||
|
|
||||||
|
#### CSP blockiert Plausible
|
||||||
|
|
||||||
|
Die CSP sollte automatisch angepasst werden. Falls Probleme auftreten:
|
||||||
|
- Prüfe, ob `NEXT_PUBLIC_PLAUSIBLE_SCRIPT_SRC` korrekt gesetzt ist
|
||||||
|
- Prüfe die Logs des Containers
|
||||||
|
|
||||||
|
#### Daten werden nicht in Plausible angezeigt
|
||||||
|
|
||||||
|
- Prüfe, ob die Domain in Plausible als Site konfiguriert ist
|
||||||
|
- Prüfe, ob `data-domain` Attribut mit der konfigurierten Domain übereinstimmt
|
||||||
|
- Prüfe Browser-Console auf Fehler beim Laden des Scripts
|
||||||
|
|
||||||
|
### Beispiel-Konfiguration
|
||||||
|
|
||||||
|
#### Für selbst gehostetes Plausible:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
NEXT_PUBLIC_PLAUSIBLE_SCRIPT_SRC=https://plausible.elpatron.me/js/script.js
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Für Plausible.io (extern):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
NEXT_PUBLIC_PLAUSIBLE_SCRIPT_SRC=https://plausible.io/js/script.js
|
||||||
|
```
|
||||||
|
|
||||||
|
**Hinweis:** Die Domain wird automatisch aus der Request-Domain erkannt - keine weitere Konfiguration nötig!
|
||||||
|
|
||||||
|
### Weitere Informationen
|
||||||
|
|
||||||
|
- [Plausible Dokumentation](https://plausible.io/docs)
|
||||||
|
- [Plausible Self-Hosting](https://plausible.io/docs/self-hosting)
|
||||||
|
|
||||||
@@ -12,15 +12,15 @@ The application is configured via environment variables. You can set these in a
|
|||||||
|----------|-------------|---------|
|
|----------|-------------|---------|
|
||||||
| `NEXT_PUBLIC_APP_NAME` | The name of the application. | `Hördle` |
|
| `NEXT_PUBLIC_APP_NAME` | The name of the application. | `Hördle` |
|
||||||
| `NEXT_PUBLIC_APP_DESCRIPTION` | The description used in metadata. | `Daily music guessing game...` |
|
| `NEXT_PUBLIC_APP_DESCRIPTION` | The description used in metadata. | `Daily music guessing game...` |
|
||||||
| `NEXT_PUBLIC_DOMAIN` | The domain name (used for sharing). | `hoerdle.elpatron.me` |
|
| `NEXT_PUBLIC_DOMAIN` | The domain name (used for sharing). | `hoerdle.de` |
|
||||||
| `NEXT_PUBLIC_TWITTER_HANDLE` | Twitter handle for metadata. | `@elpatron` |
|
|
||||||
|
|
||||||
### Analytics (Plausible)
|
### Analytics (Plausible)
|
||||||
|
|
||||||
| Variable | Description | Default |
|
| Variable | Description | Default |
|
||||||
|----------|-------------|---------|
|
|----------|-------------|---------|
|
||||||
| `NEXT_PUBLIC_PLAUSIBLE_DOMAIN` | The domain to track in Plausible. | `hoerdle.elpatron.me` |
|
| `NEXT_PUBLIC_PLAUSIBLE_SCRIPT_SRC` | The URL of the Plausible script. | `https://plausible.example.com/js/script.js` |
|
||||||
| `NEXT_PUBLIC_PLAUSIBLE_SCRIPT_SRC` | The URL of the Plausible script. | `https://plausible.elpatron.me/js/script.js` |
|
|
||||||
|
**Hinweis:** Die Domain wird automatisch aus der Request-Domain erkannt. Beide Domains (`hoerdle.de` und `hördle.de`) werden automatisch getrackt.
|
||||||
|
|
||||||
### Credits
|
### Credits
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
export const config = {
|
export const config = {
|
||||||
appName: process.env.NEXT_PUBLIC_APP_NAME || 'Hördle',
|
appName: process.env.NEXT_PUBLIC_APP_NAME || 'Hördle',
|
||||||
appDescription: process.env.NEXT_PUBLIC_APP_DESCRIPTION || 'Daily music guessing game - Guess the song from short audio clips',
|
appDescription: process.env.NEXT_PUBLIC_APP_DESCRIPTION || 'Daily music guessing game - Guess the song from short audio clips',
|
||||||
domain: process.env.NEXT_PUBLIC_DOMAIN || 'hoerdle.elpatron.me',
|
domain: process.env.NEXT_PUBLIC_DOMAIN || 'hoerdle.de',
|
||||||
twitterHandle: process.env.NEXT_PUBLIC_TWITTER_HANDLE || '@elpatron',
|
plausibleScriptSrc: process.env.NEXT_PUBLIC_PLAUSIBLE_SCRIPT_SRC || 'https://plausible.example.com/js/script.js',
|
||||||
plausibleDomain: process.env.NEXT_PUBLIC_PLAUSIBLE_DOMAIN || 'hoerdle.elpatron.me',
|
|
||||||
plausibleScriptSrc: process.env.NEXT_PUBLIC_PLAUSIBLE_SCRIPT_SRC || 'https://plausible.elpatron.me/js/script.js',
|
|
||||||
colors: {
|
colors: {
|
||||||
themeColor: process.env.NEXT_PUBLIC_THEME_COLOR || '#000000',
|
themeColor: process.env.NEXT_PUBLIC_THEME_COLOR || '#000000',
|
||||||
backgroundColor: process.env.NEXT_PUBLIC_BACKGROUND_COLOR || '#ffffff',
|
backgroundColor: process.env.NEXT_PUBLIC_BACKGROUND_COLOR || '#ffffff',
|
||||||
@@ -14,5 +12,9 @@ export const config = {
|
|||||||
text: process.env.NEXT_PUBLIC_CREDITS_TEXT || 'Vibe coded with ☕ and 🍺 by',
|
text: process.env.NEXT_PUBLIC_CREDITS_TEXT || 'Vibe coded with ☕ and 🍺 by',
|
||||||
linkText: process.env.NEXT_PUBLIC_CREDITS_LINK_TEXT || '@elpatron@digitalcourage.social',
|
linkText: process.env.NEXT_PUBLIC_CREDITS_LINK_TEXT || '@elpatron@digitalcourage.social',
|
||||||
linkUrl: process.env.NEXT_PUBLIC_CREDITS_LINK_URL || 'https://digitalcourage.social/@elpatron',
|
linkUrl: process.env.NEXT_PUBLIC_CREDITS_LINK_URL || 'https://digitalcourage.social/@elpatron',
|
||||||
|
},
|
||||||
|
seo: {
|
||||||
|
ogImage: process.env.NEXT_PUBLIC_OG_IMAGE || '/favicon.ico',
|
||||||
|
twitterHandle: process.env.NEXT_PUBLIC_TWITTER_HANDLE || undefined,
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
64
lib/metadata.ts
Normal file
64
lib/metadata.ts
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import type { Metadata } from 'next';
|
||||||
|
import { config } from './config';
|
||||||
|
import { getBaseUrl } from './seo';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate base metadata with Open Graph, Twitter Cards, and canonical URLs
|
||||||
|
*/
|
||||||
|
export async function generateBaseMetadata(
|
||||||
|
locale: string,
|
||||||
|
path: string = '',
|
||||||
|
title?: string,
|
||||||
|
description?: string,
|
||||||
|
image?: string
|
||||||
|
): Promise<Metadata> {
|
||||||
|
const baseUrl = await getBaseUrl();
|
||||||
|
const pathSegment = path ? `/${path}` : '';
|
||||||
|
const fullUrl = `${baseUrl}/${locale}${pathSegment}`;
|
||||||
|
|
||||||
|
// Determine alternate URLs for both locales (same path for both)
|
||||||
|
const alternateLocale = locale === 'de' ? 'en' : 'de';
|
||||||
|
const alternateUrl = `${baseUrl}/${alternateLocale}${pathSegment}`;
|
||||||
|
|
||||||
|
// Default values
|
||||||
|
const metaTitle = title || config.appName;
|
||||||
|
const metaDescription = description || config.appDescription;
|
||||||
|
const ogImage = image || `${baseUrl}${config.seo.ogImage}`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: metaTitle,
|
||||||
|
description: metaDescription,
|
||||||
|
alternates: {
|
||||||
|
canonical: fullUrl,
|
||||||
|
languages: {
|
||||||
|
[locale]: fullUrl,
|
||||||
|
[alternateLocale]: alternateUrl,
|
||||||
|
'x-default': `${baseUrl}/en${pathSegment}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
openGraph: {
|
||||||
|
title: metaTitle,
|
||||||
|
description: metaDescription,
|
||||||
|
url: fullUrl,
|
||||||
|
siteName: config.appName,
|
||||||
|
images: [
|
||||||
|
{
|
||||||
|
url: ogImage,
|
||||||
|
width: 1200,
|
||||||
|
height: 630,
|
||||||
|
alt: metaTitle,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
locale: locale,
|
||||||
|
type: 'website',
|
||||||
|
},
|
||||||
|
twitter: {
|
||||||
|
card: 'summary_large_image',
|
||||||
|
title: metaTitle,
|
||||||
|
description: metaDescription,
|
||||||
|
images: [ogImage],
|
||||||
|
...(config.seo.twitterHandle && { creator: config.seo.twitterHandle }),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
43
lib/seo.ts
Normal file
43
lib/seo.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import { headers } from 'next/headers';
|
||||||
|
import { config } from './config';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the current base URL from request headers
|
||||||
|
* Automatically detects hoerdle.de or hördle.de (xn--hrdle-jua.de)
|
||||||
|
*/
|
||||||
|
export async function getBaseUrl(): Promise<string> {
|
||||||
|
const headersList = await headers();
|
||||||
|
const host = headersList.get('host') || headersList.get('x-forwarded-host') || '';
|
||||||
|
|
||||||
|
let domain = config.domain; // Default fallback
|
||||||
|
|
||||||
|
if (host) {
|
||||||
|
// Extract domain from host (remove port if present)
|
||||||
|
const detectedDomain = host.split(':')[0].toLowerCase();
|
||||||
|
|
||||||
|
// Map domains
|
||||||
|
if (detectedDomain === 'hoerdle.de') {
|
||||||
|
domain = 'hoerdle.de';
|
||||||
|
} else if (detectedDomain === 'hördle.de' || detectedDomain === 'xn--hrdle-jua.de') {
|
||||||
|
domain = 'hördle.de';
|
||||||
|
} else {
|
||||||
|
// Use detected domain if it's different from default
|
||||||
|
domain = detectedDomain;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Always use HTTPS in production
|
||||||
|
const protocol = process.env.NODE_ENV === 'production' ? 'https' : 'http';
|
||||||
|
return `${protocol}://${domain}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get base URL synchronously (for use in non-async contexts)
|
||||||
|
* Uses environment variable or config as fallback
|
||||||
|
*/
|
||||||
|
export function getBaseUrlSync(): string {
|
||||||
|
const domain = process.env.NEXT_PUBLIC_DOMAIN || config.domain;
|
||||||
|
const protocol = process.env.NODE_ENV === 'production' ? 'https' : 'http';
|
||||||
|
return `${protocol}://${domain}`;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -162,7 +162,6 @@
|
|||||||
"imprintOperator": "Verantwortlich für den Inhalt dieser Seite (Anbieter nach § 5 TMG):",
|
"imprintOperator": "Verantwortlich für den Inhalt dieser Seite (Anbieter nach § 5 TMG):",
|
||||||
"imprintCountry": "Deutschland",
|
"imprintCountry": "Deutschland",
|
||||||
"imprintEmailLabel": "E-Mail:",
|
"imprintEmailLabel": "E-Mail:",
|
||||||
"imprintDisclaimer": "Hinweis: Diese Angaben entsprechen dem aktuellen Stand. Für rechtliche Fragen solltest du eine Fachperson konsultieren.",
|
|
||||||
"costsTitle": "Laufende Kosten des Projekts",
|
"costsTitle": "Laufende Kosten des Projekts",
|
||||||
"costsIntro": "Auch wenn Hördle ein privates Projekt ist, entstehen für den Betrieb laufende Kosten, zum Beispiel:",
|
"costsIntro": "Auch wenn Hördle ein privates Projekt ist, entstehen für den Betrieb laufende Kosten, zum Beispiel:",
|
||||||
"costsDomain": "Domains (z. B. hördle.de / hoerdle.de)",
|
"costsDomain": "Domains (z. B. hördle.de / hoerdle.de)",
|
||||||
|
|||||||
@@ -162,7 +162,6 @@
|
|||||||
"imprintOperator": "Responsible for the content of this site (provider under German law):",
|
"imprintOperator": "Responsible for the content of this site (provider under German law):",
|
||||||
"imprintCountry": "Germany",
|
"imprintCountry": "Germany",
|
||||||
"imprintEmailLabel": "Email:",
|
"imprintEmailLabel": "Email:",
|
||||||
"imprintDisclaimer": "Note: This information is current as of the date indicated. For legal questions you should consult a legal professional.",
|
|
||||||
"costsTitle": "Ongoing costs of the project",
|
"costsTitle": "Ongoing costs of the project",
|
||||||
"costsIntro": "Even though Hördle is a private project, there are ongoing costs for running it, for example:",
|
"costsIntro": "Even though Hördle is a private project, there are ongoing costs for running it, for example:",
|
||||||
"costsDomain": "Domains (e.g. hördle.de / hoerdle.de)",
|
"costsDomain": "Domains (e.g. hördle.de / hoerdle.de)",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "hoerdle",
|
"name": "hoerdle",
|
||||||
"version": "0.1.3",
|
"version": "0.1.4.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
|
|||||||
29
proxy.ts
29
proxy.ts
@@ -21,16 +21,41 @@ export default function proxy(request: NextRequest) {
|
|||||||
headers.set('Referrer-Policy', 'strict-origin-when-cross-origin');
|
headers.set('Referrer-Policy', 'strict-origin-when-cross-origin');
|
||||||
headers.set('Permissions-Policy', 'camera=(), microphone=(), geolocation=()');
|
headers.set('Permissions-Policy', 'camera=(), microphone=(), geolocation=()');
|
||||||
|
|
||||||
|
// Extract Plausible domain from script URL for CSP
|
||||||
|
const plausibleScriptSrc = process.env.NEXT_PUBLIC_PLAUSIBLE_SCRIPT_SRC || 'https://plausible.example.com/js/script.js';
|
||||||
|
let plausibleOrigin = 'https://plausible.example.com';
|
||||||
|
try {
|
||||||
|
const url = new URL(plausibleScriptSrc);
|
||||||
|
plausibleOrigin = url.origin;
|
||||||
|
} catch {
|
||||||
|
// If URL parsing fails, try to extract domain manually
|
||||||
|
const match = plausibleScriptSrc.match(/https?:\/\/([^/]+)/);
|
||||||
|
if (match) {
|
||||||
|
plausibleOrigin = `https://${match[1]}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get other service URLs from environment (only add to CSP if configured)
|
||||||
|
const gotifyUrl = process.env.GOTIFY_URL;
|
||||||
|
const openrouterUrl = process.env.NEXT_PUBLIC_OPENROUTER_URL || 'https://openrouter.ai';
|
||||||
|
|
||||||
|
// Build CSP dynamically based on environment variables
|
||||||
|
const connectSrcParts = ["'self'", openrouterUrl, plausibleOrigin];
|
||||||
|
if (gotifyUrl && !gotifyUrl.includes('example.com')) {
|
||||||
|
connectSrcParts.push(gotifyUrl);
|
||||||
|
}
|
||||||
|
|
||||||
const csp = [
|
const csp = [
|
||||||
"default-src 'self'",
|
"default-src 'self'",
|
||||||
"script-src 'self' 'unsafe-inline' 'unsafe-eval' https://plausible.elpatron.me",
|
`script-src 'self' 'unsafe-inline' 'unsafe-eval' ${plausibleOrigin}`,
|
||||||
"style-src 'self' 'unsafe-inline'",
|
"style-src 'self' 'unsafe-inline'",
|
||||||
"img-src 'self' data: blob:",
|
"img-src 'self' data: blob:",
|
||||||
"font-src 'self' data:",
|
"font-src 'self' data:",
|
||||||
"connect-src 'self' https://openrouter.ai https://gotify.example.com https://plausible.elpatron.me",
|
`connect-src ${connectSrcParts.join(' ')}`,
|
||||||
"media-src 'self' blob:",
|
"media-src 'self' blob:",
|
||||||
"frame-ancestors 'self'",
|
"frame-ancestors 'self'",
|
||||||
].join('; ');
|
].join('; ');
|
||||||
|
|
||||||
headers.set('Content-Security-Policy', csp);
|
headers.set('Content-Security-Policy', csp);
|
||||||
|
|
||||||
return response;
|
return response;
|
||||||
|
|||||||
Reference in New Issue
Block a user