Compare commits
38 Commits
68c074e9da
...
v0.1.4.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9006b208af | ||
|
|
20c8ad7eaf | ||
|
|
03129a5611 | ||
|
|
fd8f4adcc0 | ||
|
|
23997ccc3a | ||
|
|
85bdbf795c | ||
|
|
ac0bb02ba0 | ||
|
|
63269c2600 | ||
|
|
17a39d677d | ||
|
|
1ff0787e4e | ||
|
|
ed5f02bdec | ||
|
|
e3a09864a6 | ||
|
|
107739ade9 | ||
|
|
e4eae67612 | ||
|
|
891f52b0b8 | ||
|
|
725d3bcff4 | ||
|
|
69f69cf172 | ||
|
|
68c8f9a05a | ||
|
|
2b8733dea0 | ||
|
|
317eed5ea6 | ||
|
|
a503edb220 | ||
|
|
a80c14223b | ||
|
|
8c9c4eb159 | ||
|
|
68dfba38df | ||
|
|
b51ad2ff1a | ||
|
|
5613e5d48e | ||
|
|
09b998ea75 | ||
|
|
74a8a59083 | ||
|
|
f2c64281dd | ||
|
|
ca40b1efb9 | ||
|
|
3c051ec49d | ||
|
|
b268abb7d3 | ||
|
|
c7793dcb9d | ||
|
|
95fd6405be | ||
|
|
e881979da3 | ||
|
|
8ec713297a | ||
|
|
4aef034aa6 | ||
|
|
b120e5df45 |
67
.dockerignore
Normal file
67
.dockerignore
Normal file
@@ -0,0 +1,67 @@
|
||||
# Dependencies
|
||||
node_modules
|
||||
npm-debug.log
|
||||
yarn-debug.log
|
||||
yarn-error.log
|
||||
|
||||
# Next.js build outputs
|
||||
.next
|
||||
out
|
||||
build
|
||||
|
||||
# Environment files
|
||||
.env
|
||||
.env.local
|
||||
.env*.local
|
||||
|
||||
# Git (NICHT ausschließen - wird für Version-Extraktion benötigt!)
|
||||
# .git wird benötigt für: git describe --tags --always
|
||||
# .gitignore
|
||||
|
||||
# IDE
|
||||
.vscode
|
||||
.idea
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# OS files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Hördle specific - WICHTIG: Upload-Dateien NICHT ins Image kopieren!
|
||||
# Diese werden als Volume gemountet und sollten nicht im Image sein
|
||||
/public/uploads/*
|
||||
!/public/uploads/.gitkeep
|
||||
|
||||
# Database files - werden als Volume gemountet
|
||||
/data/*
|
||||
*.db
|
||||
*.db-journal
|
||||
*.db-wal
|
||||
*.db-shm
|
||||
|
||||
# Backups
|
||||
/backups
|
||||
|
||||
# Docker files (nicht notwendig im Image)
|
||||
docker-compose*.yml
|
||||
Dockerfile*
|
||||
.dockerignore
|
||||
|
||||
# Documentation
|
||||
/docs
|
||||
*.md
|
||||
!README.md
|
||||
|
||||
# Scripts die nicht im Container gebraucht werden
|
||||
scripts/fix-*.sh
|
||||
scripts/check-*.sh
|
||||
scripts/debug-*.sh
|
||||
scripts/quick-*.sh
|
||||
|
||||
# Temporary files
|
||||
*.tmp
|
||||
*.temp
|
||||
*.log
|
||||
|
||||
106
.env.example
Normal file
106
.env.example
Normal file
@@ -0,0 +1,106 @@
|
||||
# ============================================
|
||||
# Hördle Environment Variables
|
||||
# ============================================
|
||||
# Kopiere diese Datei zu .env und passe die Werte an deine Umgebung an:
|
||||
# cp .env.example .env
|
||||
#
|
||||
# WICHTIG: Die .env-Datei sollte niemals in Git committed werden!
|
||||
|
||||
# ============================================
|
||||
# Build-Time Variables (NEXT_PUBLIC_*)
|
||||
# ============================================
|
||||
# Diese Variablen werden beim Build-Zeitpunkt in die Next.js-App eingebettet.
|
||||
# Nach dem Build können sie nicht mehr geändert werden (ohne Rebuild).
|
||||
|
||||
# App-Name (wird in Browser-Tab, PWA, etc. verwendet)
|
||||
NEXT_PUBLIC_APP_NAME=Hördle
|
||||
|
||||
# App-Beschreibung (für SEO, PWA, etc.)
|
||||
NEXT_PUBLIC_APP_DESCRIPTION=Daily music guessing game - Guess the song from short audio clips
|
||||
|
||||
# Hauptdomain (ohne https://)
|
||||
NEXT_PUBLIC_DOMAIN=hoerdle.de
|
||||
|
||||
# Twitter/X Handle (für Meta-Tags)
|
||||
NEXT_PUBLIC_TWITTER_HANDLE=@hoerdle
|
||||
|
||||
# Plausible Analytics - Domain
|
||||
NEXT_PUBLIC_PLAUSIBLE_DOMAIN=hoerdle.de
|
||||
|
||||
# Plausible Analytics - Script-URL (selbst gehostet oder extern)
|
||||
NEXT_PUBLIC_PLAUSIBLE_SCRIPT_SRC=https://plausible.example.com/js/script.js
|
||||
|
||||
# Theme-Farbe (für Browser-UI, PWA, etc.)
|
||||
NEXT_PUBLIC_THEME_COLOR=#000000
|
||||
|
||||
# Hintergrundfarbe (für PWA, etc.)
|
||||
NEXT_PUBLIC_BACKGROUND_COLOR=#ffffff
|
||||
|
||||
# Credits im Footer aktivieren (true/false)
|
||||
NEXT_PUBLIC_CREDITS_ENABLED=true
|
||||
|
||||
# Credits-Text (vor dem Link)
|
||||
NEXT_PUBLIC_CREDITS_TEXT=Vibe coded with ☕ and 🍺 by
|
||||
|
||||
# Credits-Link-Text
|
||||
NEXT_PUBLIC_CREDITS_LINK_TEXT=@yourhandle@server.social
|
||||
|
||||
# Credits-Link-URL
|
||||
NEXT_PUBLIC_CREDITS_LINK_URL=https://server.social/@yourhandle
|
||||
|
||||
# ============================================
|
||||
# Runtime Variables
|
||||
# ============================================
|
||||
# Diese Variablen können zur Laufzeit geändert werden (benötigen keinen Rebuild).
|
||||
|
||||
# Datenbank-URL (SQLite für lokale/kleine Deployments)
|
||||
# Format: file:/path/to/database.db
|
||||
DATABASE_URL=file:/app/data/prod.db
|
||||
|
||||
# Admin-Passwort (bcrypt Hash)
|
||||
# Generiere einen Hash mit: node scripts/hash-password.js dein_passwort
|
||||
# In docker-compose.yml müssen $ als $$ escaped werden!
|
||||
ADMIN_PASSWORD=$2b$10$SHOt9G1qUNIvHoWre7499.eEtp5PtOII0daOQGNV.dhDEuPmOUdsq
|
||||
|
||||
# Zeitzone (für tägliche Puzzle-Rotation)
|
||||
TZ=Europe/Berlin
|
||||
|
||||
# ============================================
|
||||
# Optional: Gotify Integration
|
||||
# ============================================
|
||||
# Für Benachrichtigungen (z.B. Fehler-Alerts)
|
||||
|
||||
# Gotify Server URL
|
||||
GOTIFY_URL=https://gotify.example.com
|
||||
|
||||
# Gotify App Token
|
||||
GOTIFY_APP_TOKEN=your_gotify_app_token_here
|
||||
|
||||
# ============================================
|
||||
# Optional: OpenRouter Integration
|
||||
# ============================================
|
||||
# Für AI-Features (falls vorhanden)
|
||||
|
||||
# OpenRouter API Key
|
||||
OPENROUTER_API_KEY=your_openrouter_api_key_here
|
||||
|
||||
# ============================================
|
||||
# Caddy Reverse Proxy (Optional - Production)
|
||||
# ============================================
|
||||
# Nur benötigt, wenn Caddy für SSL/TLS verwendet wird.
|
||||
|
||||
# GoDaddy API Key (für DNS-01 Challenge bei Wildcard-Zertifikaten)
|
||||
# Siehe CADDY_SETUP.md für Anleitung zur Erstellung
|
||||
GODADDY_API_KEY=your_godaddy_api_key_here
|
||||
|
||||
# GoDaddy API Secret
|
||||
GODADDY_API_SECRET=your_godaddy_api_secret_here
|
||||
|
||||
# Email für Let's Encrypt Benachrichtigungen (optional)
|
||||
CADDY_EMAIL=admin@hoerdle.de
|
||||
|
||||
# ============================================
|
||||
# Build-Time Overrides
|
||||
# ============================================
|
||||
# Optional: Spezifische Version beim Build setzen
|
||||
# APP_VERSION=v1.0.0
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -32,6 +32,7 @@ yarn-error.log*
|
||||
|
||||
# env files (can opt-in for committing if needed)
|
||||
.env*
|
||||
!.env.example
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
@@ -50,3 +51,4 @@ next-env.d.ts
|
||||
/data
|
||||
.release-years-migrated
|
||||
.covers-migrated
|
||||
docker-compose.yml
|
||||
|
||||
54
Caddyfile
Normal file
54
Caddyfile
Normal file
@@ -0,0 +1,54 @@
|
||||
# Caddy-Konfiguration für Hördle
|
||||
# Root-Domains: hoerdle.de und hördle.de (xn--hrdle-jua.de)
|
||||
# Hinweis: Diese Konfiguration funktioniert nur für Root-Domains, nicht für Subdomains
|
||||
# Für Subdomains wären Wildcard-Zertifikate mit DNS-01 Challenge nötig
|
||||
|
||||
# Domain 1: hoerdle.de (ASCII)
|
||||
hoerdle.de {
|
||||
# TLS mit automatischer HTTP-01 Challenge (funktioniert nur für Root-Domain)
|
||||
# Caddy verwendet automatisch Let's Encrypt
|
||||
|
||||
# Upload-Limit: 50MB (wie in nginx.conf.example)
|
||||
request_body {
|
||||
max_size 50MB
|
||||
}
|
||||
|
||||
# Reverse Proxy zu hoerdle Container
|
||||
reverse_proxy hoerdle:3000 {
|
||||
# HTTP/1.1 für WebSocket Support
|
||||
transport http {
|
||||
versions 1.1
|
||||
}
|
||||
}
|
||||
|
||||
# HTTP zu HTTPS Redirect
|
||||
@http {
|
||||
protocol http
|
||||
}
|
||||
redir @http https://{host}{uri} permanent
|
||||
}
|
||||
|
||||
# Domain 2: hördle.de (Punycode: xn--hrdle-jua.de)
|
||||
xn--hrdle-jua.de {
|
||||
# TLS mit automatischer HTTP-01 Challenge (funktioniert nur für Root-Domain)
|
||||
# Caddy verwendet automatisch Let's Encrypt
|
||||
|
||||
# Upload-Limit: 50MB
|
||||
request_body {
|
||||
max_size 50MB
|
||||
}
|
||||
|
||||
# Reverse Proxy zu hoerdle Container
|
||||
reverse_proxy hoerdle:3000 {
|
||||
# HTTP/1.1 für WebSocket Support
|
||||
transport http {
|
||||
versions 1.1
|
||||
}
|
||||
}
|
||||
|
||||
# HTTP zu HTTPS Redirect
|
||||
@http {
|
||||
protocol http
|
||||
}
|
||||
redir @http https://{host}{uri} permanent
|
||||
}
|
||||
13
Dockerfile
13
Dockerfile
@@ -23,11 +23,13 @@ RUN apk add --no-cache git
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
|
||||
# Extract version: use build arg if provided, otherwise get from git
|
||||
# Extract version: use build arg if provided, otherwise get from git, fallback to package.json
|
||||
RUN if [ -n "$APP_VERSION" ]; then \
|
||||
echo "$APP_VERSION" > /tmp/version.txt; \
|
||||
else \
|
||||
git describe --tags --always 2>/dev/null > /tmp/version.txt || echo "unknown" > /tmp/version.txt; \
|
||||
(git describe --tags --always 2>/dev/null || \
|
||||
(grep -o '"version": "[^"]*"' package.json 2>/dev/null | cut -d'"' -f4 | sed 's/^/v/') || \
|
||||
echo "dev") > /tmp/version.txt; \
|
||||
fi && \
|
||||
echo "Building version: $(cat /tmp/version.txt)"
|
||||
|
||||
@@ -36,6 +38,9 @@ RUN if [ -n "$APP_VERSION" ]; then \
|
||||
# Uncomment the following line in case you want to disable telemetry during the build.
|
||||
ENV NEXT_TELEMETRY_DISABLED 1
|
||||
|
||||
# Suppress baseline-browser-mapping warning about old data (informational only)
|
||||
ENV NODE_ENV=production
|
||||
|
||||
# Generate Prisma Client
|
||||
ENV DATABASE_URL="file:./dev.db"
|
||||
RUN node_modules/.bin/prisma generate
|
||||
@@ -44,8 +49,6 @@ RUN node_modules/.bin/prisma generate
|
||||
ARG NEXT_PUBLIC_APP_NAME
|
||||
ARG NEXT_PUBLIC_APP_DESCRIPTION
|
||||
ARG NEXT_PUBLIC_DOMAIN
|
||||
ARG NEXT_PUBLIC_TWITTER_HANDLE
|
||||
ARG NEXT_PUBLIC_PLAUSIBLE_DOMAIN
|
||||
ARG NEXT_PUBLIC_PLAUSIBLE_SCRIPT_SRC
|
||||
ARG NEXT_PUBLIC_THEME_COLOR
|
||||
ARG NEXT_PUBLIC_BACKGROUND_COLOR
|
||||
@@ -58,8 +61,6 @@ ARG NEXT_PUBLIC_CREDITS_LINK_URL
|
||||
ENV NEXT_PUBLIC_APP_NAME=$NEXT_PUBLIC_APP_NAME
|
||||
ENV NEXT_PUBLIC_APP_DESCRIPTION=$NEXT_PUBLIC_APP_DESCRIPTION
|
||||
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_THEME_COLOR=$NEXT_PUBLIC_THEME_COLOR
|
||||
ENV NEXT_PUBLIC_BACKGROUND_COLOR=$NEXT_PUBLIC_BACKGROUND_COLOR
|
||||
|
||||
10
Dockerfile.caddy
Normal file
10
Dockerfile.caddy
Normal file
@@ -0,0 +1,10 @@
|
||||
# Dockerfile für Caddy mit GoDaddy DNS-Provider Plugin
|
||||
FROM caddy:2-builder AS builder
|
||||
|
||||
RUN xcaddy build \
|
||||
--with github.com/caddy-dns/godaddy
|
||||
|
||||
FROM caddy:2
|
||||
|
||||
COPY --from=builder /usr/bin/caddy /usr/bin/caddy
|
||||
|
||||
10
README.md
10
README.md
@@ -56,18 +56,18 @@ Eine Web-App inspiriert von Heardle, bei der Nutzer täglich einen Song anhand k
|
||||
|
||||
Hördle unterstützt vollständige Mehrsprachigkeit für Deutsch und Englisch.
|
||||
|
||||
👉 **[Vollständige i18n-Dokumentation](I18N.md)**
|
||||
👉 **[Vollständige i18n-Dokumentation](docs/I18N.md)**
|
||||
|
||||
**Schnellstart:**
|
||||
- Deutsche Version: `http://localhost:3000/de`
|
||||
- Englische Version: `http://localhost:3000/en`
|
||||
- Root (`/`) leitet automatisch zur Standardsprache (Deutsch) um
|
||||
- Root (`/`) leitet automatisch zur Standardsprache (Englisch) um
|
||||
|
||||
## White Labeling
|
||||
|
||||
Hördle ist "White Label Ready". Das bedeutet, du kannst das Branding (Name, Farben, Logos) komplett anpassen, ohne den Code zu ändern.
|
||||
|
||||
👉 **[Anleitung zur Anpassung (White Label Guide)](WHITE_LABEL.md)**
|
||||
👉 **[Anleitung zur Anpassung (White Label Guide)](docs/WHITE_LABEL.md)**
|
||||
|
||||
Die Konfiguration erfolgt einfach über Umgebungsvariablen und CSS-Variablen.
|
||||
|
||||
@@ -115,13 +115,13 @@ Das Ziel ist es, den Song mit so wenigen Hinweisen wie möglich zu erraten und d
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
Die App läuft unter `http://localhost:3000` (leitet automatisch zu `/de` um).
|
||||
Die App läuft unter `http://localhost:3000` (leitet automatisch zu `/en` um).
|
||||
|
||||
## Deployment mit Docker
|
||||
|
||||
Das Projekt ist für den Betrieb mit Docker optimiert.
|
||||
|
||||
👉 **[White Labeling mit Docker? Hier klicken!](WHITE_LABEL.md#docker-deployment)**
|
||||
👉 **[White Labeling mit Docker? Hier klicken!](docs/WHITE_LABEL.md#docker-deployment)**
|
||||
|
||||
1. **Vorbereitung:**
|
||||
Kopiere die Beispiel-Konfiguration:
|
||||
|
||||
@@ -6,6 +6,8 @@ import { PrismaClient } from '@prisma/client';
|
||||
import { notFound } from 'next/navigation';
|
||||
import { getLocalizedValue } from '@/lib/i18n';
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
import { generateBaseMetadata } from '@/lib/metadata';
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
@@ -15,6 +17,32 @@ interface PageProps {
|
||||
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) {
|
||||
const { locale, genre } = await params;
|
||||
const decodedGenre = decodeURIComponent(genre);
|
||||
|
||||
@@ -1,10 +1,22 @@
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { Link } from "@/lib/navigation";
|
||||
import { generateBaseMetadata } from "@/lib/metadata";
|
||||
import type { Metadata } from "next";
|
||||
|
||||
interface AboutPageProps {
|
||||
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) {
|
||||
const { locale } = await params;
|
||||
const t = await getTranslations({ locale, namespace: "About" });
|
||||
@@ -51,11 +63,6 @@ export default async function AboutPage({ params }: AboutPageProps) {
|
||||
{t("imprintEmailLabel")}{" "}
|
||||
<a href="mailto:markus@hoerdle.de">markus@hoerdle.de</a>
|
||||
</p>
|
||||
<p
|
||||
style={{ marginTop: "0.5rem", fontSize: "0.9rem", color: "#6b7280" }}
|
||||
>
|
||||
{t("imprintDisclaimer")}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section style={{ marginBottom: "2rem" }}>
|
||||
|
||||
@@ -5,8 +5,10 @@ import "../globals.css"; // Adjusted path
|
||||
import { NextIntlClientProvider } from 'next-intl';
|
||||
import { getMessages } from 'next-intl/server';
|
||||
import { notFound } from 'next/navigation';
|
||||
import { headers } from 'next/headers';
|
||||
|
||||
import { config } from "@/lib/config";
|
||||
import { generateBaseMetadata } from "@/lib/metadata";
|
||||
import InstallPrompt from "@/components/InstallPrompt";
|
||||
import AppFooter from "@/components/AppFooter";
|
||||
|
||||
@@ -20,10 +22,10 @@ const geistMono = Geist_Mono({
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: config.appName,
|
||||
description: config.appDescription,
|
||||
};
|
||||
export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }): Promise<Metadata> {
|
||||
const { locale } = await params;
|
||||
return await generateBaseMetadata(locale);
|
||||
}
|
||||
|
||||
export const viewport: Viewport = {
|
||||
themeColor: config.colors.themeColor,
|
||||
@@ -52,12 +54,32 @@ export default async function LocaleLayout({
|
||||
// Providing all messages to the client
|
||||
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 (
|
||||
<html lang={locale}>
|
||||
<head>
|
||||
<Script
|
||||
defer
|
||||
data-domain={config.plausibleDomain}
|
||||
data-domain={plausibleDomain}
|
||||
src={config.plausibleScriptSrc}
|
||||
strategy="beforeInteractive"
|
||||
/>
|
||||
|
||||
@@ -7,15 +7,33 @@ import { Link } from '@/lib/navigation';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
import { getLocalizedValue } from '@/lib/i18n';
|
||||
import { generateBaseMetadata } from '@/lib/metadata';
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
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({
|
||||
params
|
||||
}: {
|
||||
params: { locale: string };
|
||||
params: Promise<{ locale: string }>;
|
||||
}) {
|
||||
const { locale } = await params;
|
||||
const t = await getTranslations('Home');
|
||||
@@ -45,86 +63,86 @@ export default async function Home({
|
||||
|
||||
return (
|
||||
<>
|
||||
<div id="tour-genres" style={{ textAlign: 'center', padding: '1rem', background: '#f3f4f6' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '0.5rem', maxWidth: '1200px', margin: '0 auto', padding: '0 1rem' }}>
|
||||
<div style={{ flex: 1 }} />
|
||||
<div style={{ display: 'flex', justifyContent: 'center', gap: '1rem', flexWrap: 'wrap', alignItems: 'center', flex: 2 }}>
|
||||
<div className="tooltip">
|
||||
<Link href="/" style={{ fontWeight: 'bold', textDecoration: 'underline' }}>{tNav('global')}</Link>
|
||||
<span className="tooltip-text">{t('globalTooltip')}</span>
|
||||
</div>
|
||||
<div id="tour-genres" style={{ textAlign: 'center', padding: '1rem', background: '#f3f4f6', position: 'relative' }}>
|
||||
{/* Language Switcher - rechts oben */}
|
||||
<div style={{ position: 'absolute', top: '1rem', right: '1rem', zIndex: 10 }}>
|
||||
<LanguageSwitcher />
|
||||
</div>
|
||||
|
||||
{/* Zentrierte Navigation */}
|
||||
<div style={{ display: 'flex', justifyContent: 'center', gap: '1rem', flexWrap: 'wrap', alignItems: 'center', marginBottom: '0.5rem' }}>
|
||||
<div className="tooltip">
|
||||
<Link href="/" style={{ fontWeight: 'bold', textDecoration: 'underline' }}>{tNav('global')}</Link>
|
||||
<span className="tooltip-text">{t('globalTooltip')}</span>
|
||||
</div>
|
||||
|
||||
{/* Genres */}
|
||||
{genres.map(g => {
|
||||
const name = getLocalizedValue(g.name, locale);
|
||||
const subtitle = getLocalizedValue(g.subtitle, locale);
|
||||
return (
|
||||
<div key={g.id} className="tooltip">
|
||||
<Link href={`/${name}`} style={{ color: '#4b5563', textDecoration: 'none' }}>
|
||||
{name}
|
||||
{/* Genres */}
|
||||
{genres.map(g => {
|
||||
const name = getLocalizedValue(g.name, locale);
|
||||
const subtitle = getLocalizedValue(g.subtitle, locale);
|
||||
return (
|
||||
<div key={g.id} className="tooltip">
|
||||
<Link href={`/${name}`} style={{ color: '#4b5563', textDecoration: 'none' }}>
|
||||
{name}
|
||||
</Link>
|
||||
{subtitle && <span className="tooltip-text">{subtitle}</span>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Separator if both exist */}
|
||||
{genres.length > 0 && activeSpecials.length > 0 && (
|
||||
<span style={{ color: '#d1d5db' }}>|</span>
|
||||
)}
|
||||
|
||||
{/* Active Specials */}
|
||||
{activeSpecials.map(s => {
|
||||
const name = getLocalizedValue(s.name, locale);
|
||||
const subtitle = getLocalizedValue(s.subtitle, locale);
|
||||
return (
|
||||
<div key={s.id} style={{ display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
|
||||
<div className="tooltip">
|
||||
<Link
|
||||
href={`/special/${name}`}
|
||||
style={{
|
||||
color: '#be185d', // Pink-700
|
||||
textDecoration: 'none',
|
||||
fontWeight: '500'
|
||||
}}
|
||||
>
|
||||
★ {name}
|
||||
</Link>
|
||||
{subtitle && <span className="tooltip-text">{subtitle}</span>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Separator if both exist */}
|
||||
{genres.length > 0 && activeSpecials.length > 0 && (
|
||||
<span style={{ color: '#d1d5db' }}>|</span>
|
||||
)}
|
||||
|
||||
{/* Active Specials */}
|
||||
{activeSpecials.map(s => {
|
||||
{s.curator && (
|
||||
<span style={{ fontSize: '0.75rem', color: '#666' }}>
|
||||
{t('curatedBy')} {s.curator}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Upcoming Specials */}
|
||||
{upcomingSpecials.length > 0 && (
|
||||
<div style={{ marginTop: '0.5rem', fontSize: '0.875rem', color: '#666', textAlign: 'center' }}>
|
||||
{t('comingSoon')}: {upcomingSpecials.map(s => {
|
||||
const name = getLocalizedValue(s.name, locale);
|
||||
const subtitle = getLocalizedValue(s.subtitle, locale);
|
||||
return (
|
||||
<div key={s.id} style={{ display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
|
||||
<div className="tooltip">
|
||||
<Link
|
||||
href={`/special/${name}`}
|
||||
style={{
|
||||
color: '#be185d', // Pink-700
|
||||
textDecoration: 'none',
|
||||
fontWeight: '500'
|
||||
}}
|
||||
>
|
||||
★ {name}
|
||||
</Link>
|
||||
{subtitle && <span className="tooltip-text">{subtitle}</span>}
|
||||
</div>
|
||||
{s.curator && (
|
||||
<span style={{ fontSize: '0.75rem', color: '#666' }}>
|
||||
{t('curatedBy')} {s.curator}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span key={s.id} style={{ marginLeft: '0.5rem' }}>
|
||||
★ {name} ({s.launchDate ? new Date(s.launchDate).toLocaleDateString(locale, {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
timeZone: process.env.TZ
|
||||
}) : ''})
|
||||
{s.curator && <span style={{ fontStyle: 'italic', marginLeft: '0.25rem' }}>{t('curatedBy')} {s.curator}</span>}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Upcoming Specials */}
|
||||
{upcomingSpecials.length > 0 && (
|
||||
<div style={{ marginTop: '0.5rem', fontSize: '0.875rem', color: '#666' }}>
|
||||
{t('comingSoon')}: {upcomingSpecials.map(s => {
|
||||
const name = getLocalizedValue(s.name, locale);
|
||||
return (
|
||||
<span key={s.id} style={{ marginLeft: '0.5rem' }}>
|
||||
★ {name} ({s.launchDate ? new Date(s.launchDate).toLocaleDateString(locale, {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
timeZone: process.env.TZ
|
||||
}) : ''})
|
||||
{s.curator && <span style={{ fontStyle: 'italic', marginLeft: '0.25rem' }}>{t('curatedBy')} {s.curator}</span>}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ flex: 1, display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<LanguageSwitcher />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div id="tour-news">
|
||||
|
||||
@@ -5,6 +5,8 @@ import { Link } from '@/lib/navigation';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { getLocalizedValue } from '@/lib/i18n';
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
import { generateBaseMetadata } from '@/lib/metadata';
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
@@ -14,6 +16,30 @@ interface PageProps {
|
||||
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) {
|
||||
const { locale, name } = await params;
|
||||
const decodedName = decodeURIComponent(name);
|
||||
|
||||
@@ -15,7 +15,7 @@ export async function GET() {
|
||||
for (const versionFilePath of versionPaths) {
|
||||
if (existsSync(versionFilePath)) {
|
||||
const version = readFileSync(versionFilePath, 'utf-8').trim();
|
||||
if (version && version !== 'unknown') {
|
||||
if (version && version !== 'unknown' && version !== '') {
|
||||
return NextResponse.json({ version });
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,19 @@ export async function GET() {
|
||||
return NextResponse.json({ version: process.env.APP_VERSION });
|
||||
}
|
||||
|
||||
// Fallback: check package.json
|
||||
try {
|
||||
const packageJsonPath = join(process.cwd(), 'package.json');
|
||||
if (existsSync(packageJsonPath)) {
|
||||
const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf-8'));
|
||||
if (packageJson.version) {
|
||||
return NextResponse.json({ version: `v${packageJson.version}` });
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore package.json read errors
|
||||
}
|
||||
|
||||
// Fallback: try to get from git (local development)
|
||||
let version = 'dev';
|
||||
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -275,8 +275,8 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
||||
const genreText = genre ? `${isSpecial ? t('special') : t('genre')}: ${genre}\n` : '';
|
||||
|
||||
let shareUrl = `https://${config.domain}`;
|
||||
// Add locale prefix if not default (de)
|
||||
if (locale !== 'de') {
|
||||
// Add locale prefix if not default (en)
|
||||
if (locale !== 'en') {
|
||||
shareUrl += `/${locale}`;
|
||||
}
|
||||
if (genre) {
|
||||
|
||||
59
docker-compose.caddy.yml
Normal file
59
docker-compose.caddy.yml
Normal file
@@ -0,0 +1,59 @@
|
||||
# Docker Compose Konfiguration für Caddy Reverse Proxy
|
||||
# Optional: Nur in Produktionsumgebung verwenden
|
||||
#
|
||||
# Starten: docker compose -f docker-compose.yml -f docker-compose.caddy.yml up -d
|
||||
# Stoppen: docker compose -f docker-compose.yml -f docker-compose.caddy.yml down
|
||||
|
||||
services:
|
||||
caddy:
|
||||
# Verwende Custom-Image mit GoDaddy DNS-Plugin
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.caddy
|
||||
# Alternativ: Verwende Standard-Caddy und manuelle DNS-Konfiguration
|
||||
# image: caddy:2-alpine
|
||||
container_name: hoerdle-caddy
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
# Standard HTTP/HTTPS Ports
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
- "443:443/udp" # Für HTTP/3 (QUIC)
|
||||
environment:
|
||||
# GoDaddy API-Credentials für DNS-01 Challenge
|
||||
# Diese müssen in einer .env-Datei gesetzt werden:
|
||||
# GODADDY_API_KEY=your_api_key
|
||||
# GODADDY_API_SECRET=your_api_secret
|
||||
- GODADDY_API_KEY=${GODADDY_API_KEY:-}
|
||||
- GODADDY_API_SECRET=${GODADDY_API_SECRET:-}
|
||||
# Optional: Email für Let's Encrypt Benachrichtigungen
|
||||
- CADDY_EMAIL=${CADDY_EMAIL:-}
|
||||
volumes:
|
||||
# Caddyfile-Konfiguration
|
||||
- ./Caddyfile:/etc/caddy/Caddyfile:ro
|
||||
# Persistente Zertifikat-Speicherung
|
||||
- caddy_data:/data
|
||||
- caddy_config:/config
|
||||
networks:
|
||||
- default
|
||||
# Health Check
|
||||
healthcheck:
|
||||
test: ["CMD", "caddy", "version"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
# Nur starten, wenn ENABLE_CADDY=true gesetzt ist
|
||||
profiles:
|
||||
- production
|
||||
|
||||
volumes:
|
||||
caddy_data:
|
||||
driver: local
|
||||
caddy_config:
|
||||
driver: local
|
||||
|
||||
networks:
|
||||
default:
|
||||
name: hoerdle_default
|
||||
external: true
|
||||
|
||||
@@ -8,8 +8,6 @@ services:
|
||||
NEXT_PUBLIC_APP_NAME: ${NEXT_PUBLIC_APP_NAME}
|
||||
NEXT_PUBLIC_APP_DESCRIPTION: ${NEXT_PUBLIC_APP_DESCRIPTION}
|
||||
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_THEME_COLOR: ${NEXT_PUBLIC_THEME_COLOR}
|
||||
NEXT_PUBLIC_BACKGROUND_COLOR: ${NEXT_PUBLIC_BACKGROUND_COLOR}
|
||||
@@ -37,4 +35,11 @@ services:
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 40s
|
||||
networks:
|
||||
- default
|
||||
# docker-entrypoint.sh handles migrations and server startup (with baseline fallback)
|
||||
|
||||
networks:
|
||||
default:
|
||||
name: hoerdle_default
|
||||
external: true
|
||||
|
||||
289
docs/CADDY_SETUP.md
Normal file
289
docs/CADDY_SETUP.md
Normal file
@@ -0,0 +1,289 @@
|
||||
# Caddy-Setup für Hördle
|
||||
|
||||
Diese Anleitung erklärt, wie du Caddy als Reverse-Proxy mit automatischen Let's Encrypt Wildcard-Zertifikaten für die Domains `hoerdle.de` und `hördle.de` (xn--hrdle-jua.de) einrichtest.
|
||||
|
||||
## Übersicht
|
||||
|
||||
Caddy übernimmt folgende Aufgaben:
|
||||
- Automatische SSL/TLS-Zertifikate via Let's Encrypt
|
||||
- Wildcard-Zertifikate für beide Domains (inkl. Subdomains)
|
||||
- Reverse Proxy zu deinem Hördle-Container
|
||||
- HTTP zu HTTPS Redirect
|
||||
- Optimierte Einstellungen für Audio-Streaming und Uploads
|
||||
|
||||
## Voraussetzungen
|
||||
|
||||
1. Docker und Docker Compose installiert
|
||||
2. Zugriff auf deine GoDaddy Domain-Verwaltung
|
||||
3. Ports 80 und 443 müssen frei sein (Caddy übernimmt diese)
|
||||
|
||||
## Schritt 1: GoDaddy DNS-API-Zugangsdaten erstellen
|
||||
|
||||
Für Wildcard-Zertifikate benötigt Caddy DNS-01 Challenge, was API-Zugriff auf dein GoDaddy-Konto erfordert.
|
||||
|
||||
### GoDaddy API-Keys erstellen
|
||||
|
||||
1. Gehe zu [GoDaddy Developer Portal](https://developer.godaddy.com/)
|
||||
2. Melde dich mit deinem GoDaddy-Konto an
|
||||
3. Klicke auf **"Keys"** in der Navigation
|
||||
4. Klicke auf **"Create New API Key"**
|
||||
5. Fülle das Formular aus:
|
||||
- **Key Name**: z.B. "Hördle Caddy DNS"
|
||||
- **Environment**: Production (für echte Domains)
|
||||
6. Klicke auf **"Create"**
|
||||
7. **Wichtig**: Kopiere dir den **API Key** und das **API Secret** - das Secret wird nur einmal angezeigt!
|
||||
|
||||
### Alternative: Manuelle DNS-TXT-Records (ohne API)
|
||||
|
||||
Wenn du keine API-Keys verwenden möchtest, kannst du die DNS-TXT-Records manuell setzen. **Hinweis**: Dies ist nur für die initiale Zertifikatsanfrage möglich, nicht für automatische Erneuerungen.
|
||||
|
||||
Siehe Abschnitt "Manuelle DNS-Konfiguration (ohne API)" weiter unten.
|
||||
|
||||
## Schritt 2: Environment-Variablen konfigurieren
|
||||
|
||||
Erstelle eine `.env`-Datei im Projektverzeichnis (oder erweitere die bestehende):
|
||||
|
||||
```bash
|
||||
# GoDaddy API-Credentials für DNS-01 Challenge
|
||||
GODADDY_API_KEY=your_api_key_here
|
||||
GODADDY_API_SECRET=your_api_secret_here
|
||||
|
||||
# Optional: Email für Let's Encrypt Benachrichtigungen
|
||||
CADDY_EMAIL=markus@hoerdle.de
|
||||
```
|
||||
|
||||
**Wichtig**: Die `.env`-Datei sollte nicht in Git committed werden (sollte bereits in `.gitignore` sein).
|
||||
|
||||
## Schritt 3: Docker-Netzwerk erstellen
|
||||
|
||||
Caddy und Hördle müssen im gleichen Docker-Netzwerk kommunizieren:
|
||||
|
||||
```bash
|
||||
# Prüfe, ob das Netzwerk bereits existiert
|
||||
docker network ls | grep hoerdle
|
||||
|
||||
# Falls das Netzwerk bereits existiert, aber falsche Labels hat:
|
||||
# 1. Stoppe alle Container, die das Netzwerk nutzen
|
||||
docker compose -f docker-compose.yml down
|
||||
|
||||
# 2. Lösche das alte Netzwerk (falls keine Container mehr dranhängen)
|
||||
docker network rm hoerdle_default
|
||||
|
||||
# 3. Erstelle das Netzwerk neu
|
||||
docker network create hoerdle_default
|
||||
|
||||
# Falls das Netzwerk nicht existiert, erstelle es:
|
||||
docker network create hoerdle_default
|
||||
```
|
||||
|
||||
**Hinweis**: Die docker-compose.caddy.yml ist so konfiguriert, dass sie das Netzwerk als externes Netzwerk nutzt. Das bedeutet, dass das Netzwerk bereits existieren muss, bevor Caddy gestartet wird.
|
||||
|
||||
## Schritt 4: Caddy starten
|
||||
|
||||
### Option A: Mit docker-compose (Empfohlen)
|
||||
|
||||
```bash
|
||||
# Starte Hördle + Caddy zusammen
|
||||
docker compose -f docker-compose.yml -f docker-compose.caddy.yml --profile production up -d
|
||||
|
||||
# Nur Caddy starten (wenn Hördle bereits läuft)
|
||||
docker compose -f docker-compose.caddy.yml --profile production up -d
|
||||
```
|
||||
|
||||
### Option B: Nur Caddy starten (Hördle läuft bereits)
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.caddy.yml --profile production up -d
|
||||
```
|
||||
|
||||
## Schritt 5: DNS-Konfiguration in GoDaddy
|
||||
|
||||
### Automatisch (mit API-Keys)
|
||||
|
||||
Wenn du API-Keys konfiguriert hast, wird Caddy automatisch die benötigten DNS-TXT-Records erstellen. Keine manuellen DNS-Änderungen nötig!
|
||||
|
||||
### Manuell (ohne API-Keys)
|
||||
|
||||
Wenn du die API-Keys nicht verwenden möchtest, musst du die DNS-TXT-Records manuell setzen:
|
||||
|
||||
#### Für hoerdle.de:
|
||||
|
||||
1. Gehe zu deinem [GoDaddy DNS-Verwaltung](https://dcc.godaddy.com/manage/YOUR_DOMAIN/dns)
|
||||
2. Für jedes Wildcard-Zertifikat benötigst du einen TXT-Record:
|
||||
- **Typ**: TXT
|
||||
- **Name**: `_acme-challenge`
|
||||
- **Wert**: (wird von Let's Encrypt generiert - siehe Caddy-Logs)
|
||||
- **TTL**: 600 (10 Minuten)
|
||||
|
||||
**Wichtig**: Für Wildcard-Zertifikate brauchst du:
|
||||
- Einen TXT-Record für `_acme-challenge.hoerdle.de` (Domain selbst)
|
||||
- Einen TXT-Record für `_acme-challenge.*.hoerdle.de` (Wildcard)
|
||||
|
||||
#### Für hördle.de (xn--hrdle-jua.de):
|
||||
|
||||
Das gleiche Vorgehen für die Punycode-Domain:
|
||||
- `_acme-challenge.xn--hrdle-jua.de`
|
||||
- `_acme-challenge.*.xn--hrdle-jua.de`
|
||||
|
||||
**Hinweis**: Die manuelle Methode funktioniert nur für die initiale Zertifikatsanfrage. Für automatische Erneuerungen benötigst du die API-Keys.
|
||||
|
||||
## Schritt 6: Prüfen, ob alles funktioniert
|
||||
|
||||
### Caddy-Logs ansehen
|
||||
|
||||
```bash
|
||||
docker logs -f hoerdle-caddy
|
||||
```
|
||||
|
||||
Du solltest sehen:
|
||||
- Caddy startet erfolgreich
|
||||
- Let's Encrypt-Zertifikate werden angefordert
|
||||
- Zertifikate sind gültig
|
||||
|
||||
### Zertifikate prüfen
|
||||
|
||||
```bash
|
||||
# Prüfe Zertifikate im Browser
|
||||
# Öffne: https://hoerdle.de
|
||||
# Öffne: https://hördle.de
|
||||
```
|
||||
|
||||
Oder via Command-Line:
|
||||
|
||||
```bash
|
||||
# Prüfe Zertifikat für hoerdle.de
|
||||
openssl s_client -connect hoerdle.de:443 -servername hoerdle.de < /dev/null 2>/dev/null | openssl x509 -noout -text | grep "Subject:"
|
||||
|
||||
# Prüfe Zertifikat für hördle.de
|
||||
openssl s_client -connect xn--hrdle-jua.de:443 -servername xn--hrdle-jua.de < /dev/null 2>/dev/null | openssl x509 -noout -text | grep "Subject:"
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Caddy startet nicht
|
||||
|
||||
**Problem**: Container stoppt sofort nach Start.
|
||||
|
||||
**Lösung**:
|
||||
1. Prüfe Caddy-Logs: `docker logs hoerdle-caddy`
|
||||
2. Prüfe Caddyfile-Syntax: `docker run --rm -v $(pwd)/Caddyfile:/etc/caddy/Caddyfile:ro caddy:2-alpine caddy validate --config /etc/caddy/Caddyfile`
|
||||
3. Prüfe, ob Ports 80/443 frei sind: `sudo netstat -tlnp | grep -E ':80|:443'`
|
||||
|
||||
### Zertifikate werden nicht erstellt
|
||||
|
||||
**Problem**: Let's Encrypt-Zertifikate werden nicht angefordert.
|
||||
|
||||
**Lösung**:
|
||||
1. Prüfe GoDaddy API-Credentials in `.env`
|
||||
2. Prüfe Caddy-Logs für DNS-Challenge-Fehler
|
||||
3. Stelle sicher, dass die Domains korrekt auf deinen Server zeigen (A-Records)
|
||||
4. Bei manueller DNS-Konfiguration: Prüfe, ob TXT-Records korrekt gesetzt sind
|
||||
|
||||
### DNS-Challenge schlägt fehl
|
||||
|
||||
**Problem**: DNS-01 Challenge kann DNS-Records nicht erstellen.
|
||||
|
||||
**Lösung**:
|
||||
1. Prüfe GoDaddy API-Permissions
|
||||
2. Stelle sicher, dass API-Keys Production-Keys sind (nicht Development)
|
||||
3. Prüfe Domain-Ownership in GoDaddy
|
||||
4. Warte einige Minuten - DNS-Propagierung kann dauern
|
||||
|
||||
### Audio-Dateien funktionieren nicht
|
||||
|
||||
**Problem**: MP3-Dateien werden nicht korrekt gestreamt.
|
||||
|
||||
**Lösung**:
|
||||
1. Prüfe Caddy-Logs: `docker logs hoerdle-caddy | grep -i range`
|
||||
2. Prüfe, ob Range-Header weitergegeben werden (Browser DevTools → Network)
|
||||
3. Stelle sicher, dass der `/uploads/` Handle korrekt konfiguriert ist
|
||||
|
||||
### Container können nicht kommunizieren
|
||||
|
||||
**Problem**: Caddy kann den hoerdle-Container nicht erreichen.
|
||||
|
||||
**Lösung**:
|
||||
1. Prüfe, ob beide Container im gleichen Netzwerk sind:
|
||||
```bash
|
||||
docker network inspect hoerdle_default
|
||||
```
|
||||
2. Prüfe, ob hoerdle-Container läuft: `docker ps | grep hoerdle`
|
||||
3. Teste Verbindung von Caddy zu Hördle:
|
||||
```bash
|
||||
docker exec hoerdle-caddy wget -O- http://hoerdle:3000/api/health
|
||||
```
|
||||
**Hinweis**: Der Container-Port ist 3000 (nicht 3010, das ist nur der Host-Port).
|
||||
|
||||
### Netzwerk-Warnung beim Deployment
|
||||
|
||||
**Problem**: Warnung `network hoerdle_default was found but has incorrect label`
|
||||
|
||||
**Erklärung**: Diese Warnung ist **harmlos** und kann ignoriert werden. Docker Compose funktioniert trotzdem einwandfrei. Sie entsteht, wenn das Netzwerk bereits existiert, aber nicht von Docker Compose erstellt wurde.
|
||||
|
||||
**Optional: Warnung beheben** (nur wenn sie stört):
|
||||
```bash
|
||||
# Reparatur-Skript ausführen (stoppt Container kurz)
|
||||
./scripts/fix-network.sh
|
||||
|
||||
# Danach Container neu starten
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
**Hinweis**: Das Reparatur-Skript stoppt alle Container kurz, die das Netzwerk nutzen. In Produktion sollte dies außerhalb der Hauptnutzungszeit erfolgen.
|
||||
|
||||
## Deployment-Workflow
|
||||
|
||||
### Caddy nur in Produktion aktivieren
|
||||
|
||||
Die `docker-compose.caddy.yml` verwendet das `production`-Profile. Um Caddy zu aktivieren:
|
||||
|
||||
```bash
|
||||
# Mit Production-Profile
|
||||
docker compose -f docker-compose.yml -f docker-compose.caddy.yml --profile production up -d
|
||||
|
||||
# Ohne Caddy (nur Hördle)
|
||||
docker compose -f docker-compose.yml up -d
|
||||
```
|
||||
|
||||
### Caddy aktualisieren
|
||||
|
||||
```bash
|
||||
# Pull neues Caddy-Image
|
||||
docker compose -f docker-compose.caddy.yml pull
|
||||
|
||||
# Restart Caddy-Container
|
||||
docker compose -f docker-compose.caddy.yml --profile production restart caddy
|
||||
```
|
||||
|
||||
### Caddy-Konfiguration ändern
|
||||
|
||||
Nach Änderungen am Caddyfile:
|
||||
|
||||
```bash
|
||||
# Caddyfile validieren
|
||||
docker run --rm -v $(pwd)/Caddyfile:/etc/caddy/Caddyfile:ro caddy:2-alpine caddy validate --config /etc/caddy/Caddyfile
|
||||
|
||||
# Caddy neu laden (ohne Downtime)
|
||||
docker compose -f docker-compose.caddy.yml --profile production exec caddy caddy reload --config /etc/caddy/Caddyfile
|
||||
```
|
||||
|
||||
## Sicherheit
|
||||
|
||||
### API-Keys schützen
|
||||
|
||||
- **Niemals** API-Keys in Git committen
|
||||
- Verwende `.env`-Dateien (sollten in `.gitignore` sein)
|
||||
- Setze minimale Berechtigungen für API-Keys in GoDaddy
|
||||
- Rotiere API-Keys regelmäßig
|
||||
|
||||
### Firewall
|
||||
|
||||
Stelle sicher, dass nur Ports 80 und 443 öffentlich erreichbar sind. Port 3010 (Hördle) sollte nicht öffentlich erreichbar sein.
|
||||
|
||||
## Weitere Ressourcen
|
||||
|
||||
- [Caddy Dokumentation](https://caddyserver.com/docs/)
|
||||
- [Caddy DNS-Provider](https://caddyserver.com/docs/modules/tls.dns)
|
||||
- [GoDaddy API Dokumentation](https://developer.godaddy.com/doc/endpoint/domains)
|
||||
- [Let's Encrypt Wildcard-Zertifikate](https://letsencrypt.org/docs/challenge-types/#dns-01-challenge)
|
||||
|
||||
183
docs/CADDY_TROUBLESHOOTING.md
Normal file
183
docs/CADDY_TROUBLESHOOTING.md
Normal file
@@ -0,0 +1,183 @@
|
||||
# Caddy Zertifikat-Troubleshooting
|
||||
|
||||
## Problem: Zertifikat für Punycode-Domain (hördle.de / xn--hrdle-jua.de) fehlt
|
||||
|
||||
Wenn die Domain `hördle.de` (xn--hrdle-jua.de) einen `ERR_SSL_PROTOCOL_ERROR` zeigt, bedeutet das, dass kein gültiges SSL-Zertifikat vorhanden ist.
|
||||
|
||||
### Schritt 1: Zertifikat-Status prüfen
|
||||
|
||||
Führe das Check-Script aus:
|
||||
|
||||
```bash
|
||||
./scripts/check-caddy-certificates.sh
|
||||
```
|
||||
|
||||
Dieses Script prüft:
|
||||
- Ob Caddy läuft
|
||||
- Welche Zertifikate vorhanden sind
|
||||
- Ob die DNS-Einträge korrekt sind
|
||||
- Ob die HTTPS-Verbindungen funktionieren
|
||||
|
||||
### Schritt 2: DNS-Einträge prüfen
|
||||
|
||||
**Wichtig**: Beide Domains müssen auf die gleiche Server-IP zeigen!
|
||||
|
||||
#### In GoDaddy prüfen:
|
||||
|
||||
1. Gehe zu [GoDaddy DNS-Verwaltung](https://dcc.godaddy.com/manage/hoerdle.de/dns)
|
||||
2. Prüfe die A-Records:
|
||||
|
||||
**Für hoerdle.de:**
|
||||
- Name: `@` oder `hoerdle.de`
|
||||
- Typ: `A`
|
||||
- Wert: `DEINE_SERVER_IP`
|
||||
|
||||
**Für hördle.de (Punycode):**
|
||||
- Name: `@` oder `xn--hrdle-jua.de` (oder der Unicode-Name, falls unterstützt)
|
||||
- Typ: `A`
|
||||
- Wert: **GLEICHE_SERVER_IP wie hoerdle.de**
|
||||
|
||||
#### DNS manuell testen:
|
||||
|
||||
```bash
|
||||
# Prüfe hoerdle.de
|
||||
dig +short hoerdle.de @8.8.8.8
|
||||
|
||||
# Prüfe xn--hrdle-jua.de (Punycode)
|
||||
dig +short xn--hrdle-jua.de @8.8.8.8
|
||||
|
||||
# Beide sollten die gleiche IP zurückgeben!
|
||||
```
|
||||
|
||||
### Schritt 3: Zertifikat neu erstellen
|
||||
|
||||
Wenn die DNS-Einträge korrekt sind, lösche das alte (fehlgeschlagene) Zertifikat und lass Caddy es neu erstellen:
|
||||
|
||||
```bash
|
||||
./scripts/renew-caddy-certificates.sh
|
||||
```
|
||||
|
||||
Wähle Option 2: "Nur Zertifikat für xn--hrdle-jua.de löschen"
|
||||
|
||||
### Schritt 4: Caddy-Logs überwachen
|
||||
|
||||
Während Caddy das Zertifikat erstellt, überwache die Logs:
|
||||
|
||||
```bash
|
||||
docker logs hoerdle-caddy -f
|
||||
```
|
||||
|
||||
Du solltest sehen:
|
||||
- `[INFO] attempting ACME challenge` - Caddy versucht die Challenge
|
||||
- `[INFO] successfully completed ACME challenge` - Challenge erfolgreich
|
||||
- `[INFO] certificate obtained successfully` - Zertifikat erstellt
|
||||
|
||||
Bei Fehlern siehst du:
|
||||
- `[ERROR] acme: error` - Challenge fehlgeschlagen
|
||||
- `[ERROR] unable to validate` - Validierung fehlgeschlagen
|
||||
|
||||
### Schritt 5: Häufige Probleme und Lösungen
|
||||
|
||||
#### Problem 1: DNS zeigt auf falsche IP
|
||||
|
||||
**Symptom**: `dig` zeigt eine andere IP als erwartet
|
||||
|
||||
**Lösung**:
|
||||
1. Prüfe DNS-Einträge in GoDaddy
|
||||
2. Warte auf DNS-Propagierung (kann 5-60 Minuten dauern)
|
||||
3. Verwende einen DNS-Checker: https://www.whatsmydns.net/
|
||||
|
||||
#### Problem 2: Port 80 nicht erreichbar
|
||||
|
||||
**Symptom**: Caddy-Logs zeigen "connection refused" oder Timeout
|
||||
|
||||
**Lösung**:
|
||||
1. Prüfe Firewall: `sudo ufw status`
|
||||
2. Prüfe ob Port 80 offen ist: `sudo netstat -tulpn | grep :80`
|
||||
3. Prüfe ob Caddy auf Port 80 lauscht: `docker exec hoerdle-caddy netstat -tulpn | grep :80`
|
||||
|
||||
#### Problem 3: Let's Encrypt Rate Limit
|
||||
|
||||
**Symptom**: Logs zeigen "too many certificates already issued"
|
||||
|
||||
**Lösung**:
|
||||
- Warte 1 Woche (Rate Limit von Let's Encrypt)
|
||||
- Oder verwende Staging-Environment zum Testen:
|
||||
```caddyfile
|
||||
tls {
|
||||
staging
|
||||
}
|
||||
```
|
||||
|
||||
#### Problem 4: Punycode-Domain wird nicht erkannt
|
||||
|
||||
**Symptom**: Caddy erstellt Zertifikat nur für hoerdle.de, nicht für xn--hrdle-jua.de
|
||||
|
||||
**Lösung**:
|
||||
1. Prüfe ob beide Domains in der Caddyfile stehen
|
||||
2. Prüfe DNS-Einträge (siehe Schritt 2)
|
||||
3. Erzwinge Zertifikat-Erstellung (siehe Schritt 3)
|
||||
|
||||
### Manuelle Zertifikat-Löschung
|
||||
|
||||
Falls das Script nicht funktioniert, kannst du Zertifikate manuell löschen:
|
||||
|
||||
```bash
|
||||
# Alle Zertifikate löschen
|
||||
docker exec hoerdle-caddy rm -rf /data/caddy/certificates/acme-v02.api.letsencrypt.org-directory/*
|
||||
|
||||
# Nur Punycode-Zertifikat löschen (manuell)
|
||||
docker exec hoerdle-caddy find /data/caddy/certificates -name "*xn--*" -delete
|
||||
|
||||
# Container neu starten
|
||||
docker compose -f docker-compose.caddy.yml --profile production restart caddy
|
||||
```
|
||||
|
||||
### DNS-Propagierung prüfen
|
||||
|
||||
Nach DNS-Änderungen kann es bis zu 60 Minuten dauern, bis alle DNS-Server aktualisiert sind:
|
||||
|
||||
```bash
|
||||
# Prüfe DNS-Propagierung weltweit
|
||||
curl "https://dnschecker.org/#A/hoerdle.de"
|
||||
curl "https://dnschecker.org/#A/xn--hrdle-jua.de"
|
||||
```
|
||||
|
||||
### Test-Zertifikat erstellen (Staging)
|
||||
|
||||
Zum Testen ohne Rate-Limits kannst du ein Staging-Zertifikat erstellen:
|
||||
|
||||
1. Temporär Caddyfile ändern (in beiden Domain-Blocks):
|
||||
```caddyfile
|
||||
tls {
|
||||
staging
|
||||
}
|
||||
```
|
||||
|
||||
2. Container neu starten
|
||||
3. Zertifikat erstellen lassen
|
||||
4. Zurück zu Produktion ändern (Staging-Block entfernen)
|
||||
5. Erneut Container neu starten
|
||||
|
||||
### Verifizieren, dass es funktioniert
|
||||
|
||||
Nach erfolgreicher Zertifikats-Erstellung:
|
||||
|
||||
```bash
|
||||
# Teste HTTPS-Verbindung
|
||||
curl -I https://hoerdle.de
|
||||
curl -I https://xn--hrdle-jua.de
|
||||
|
||||
# Prüfe Zertifikat-Details
|
||||
echo | openssl s_client -connect hoerdle.de:443 -servername hoerdle.de 2>/dev/null | openssl x509 -noout -subject -dates
|
||||
echo | openssl s_client -connect xn--hrdle-jua.de:443 -servername xn--hrdle-jua.de 2>/dev/null | openssl x509 -noout -subject -dates
|
||||
```
|
||||
|
||||
### Support
|
||||
|
||||
Falls das Problem weiterhin besteht:
|
||||
1. Prüfe Caddy-Logs: `docker logs hoerdle-caddy`
|
||||
2. Prüfe DNS: `dig +short xn--hrdle-jua.de @8.8.8.8`
|
||||
3. Prüfe Firewall: `sudo ufw status`
|
||||
4. Prüfe Port-Zugriff: `curl -I http://hoerdle.de`
|
||||
|
||||
@@ -82,3 +82,35 @@ docker ps
|
||||
```
|
||||
|
||||
Look for the "healthy" status in the STATUS column.
|
||||
|
||||
## Caddy Reverse Proxy (Optional - Production)
|
||||
|
||||
For production deployments with automatic SSL/TLS certificates, Caddy can be used as a reverse proxy. Caddy provides:
|
||||
|
||||
- Automatic Let's Encrypt certificates (including wildcard certificates)
|
||||
- HTTP to HTTPS redirect
|
||||
- Optimized settings for audio streaming and file uploads
|
||||
- Support for both `hoerdle.de` and `hördle.de` (Punycode: `xn--hrdle-jua.de`)
|
||||
|
||||
### Quick Start
|
||||
|
||||
1. **Follow the setup guide**: See `CADDY_SETUP.md` for detailed instructions
|
||||
2. **Configure environment variables**: Add GoDaddy API credentials to your `.env` file
|
||||
3. **Start with Caddy**:
|
||||
```bash
|
||||
docker compose -f docker-compose.yml -f docker-compose.caddy.yml --profile production up -d
|
||||
```
|
||||
|
||||
### Without Caddy
|
||||
|
||||
If you don't want to use Caddy, you can deploy normally:
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.yml up -d
|
||||
```
|
||||
|
||||
The application will still be accessible on port 3010, but you'll need to configure SSL/TLS separately (e.g., with nginx).
|
||||
|
||||
### Caddy Troubleshooting
|
||||
|
||||
See `CADDY_SETUP.md` for detailed troubleshooting information.
|
||||
106
docs/DOCKER_BUILD_FIX.md
Normal file
106
docs/DOCKER_BUILD_FIX.md
Normal file
@@ -0,0 +1,106 @@
|
||||
# Docker Build Fix: Upload-Dateien ausschließen
|
||||
|
||||
## Problem
|
||||
|
||||
Der Docker Build schlug fehl mit:
|
||||
```
|
||||
no space left on device
|
||||
```
|
||||
|
||||
**Ursache**: Die großen MP3-Dateien in `public/uploads/` wurden in den Build-Context kopiert und verbrauchten zu viel Speicherplatz.
|
||||
|
||||
## Lösung
|
||||
|
||||
Eine `.dockerignore` Datei wurde erstellt, die folgende Dateien/Ordner vom Build ausschließt:
|
||||
|
||||
- `public/uploads/*` - Upload-Dateien (werden als Volume gemountet)
|
||||
- `data/*` - Datenbank-Dateien (werden als Volume gemountet)
|
||||
- `node_modules` - werden während des Builds installiert
|
||||
- `.next`, `out`, `build` - Build-Artefakte
|
||||
- Backup-Dateien, Logs, temporäre Dateien
|
||||
|
||||
## Zusätzliche Maßnahmen
|
||||
|
||||
Falls der Build weiterhin Probleme macht:
|
||||
|
||||
### 1. Docker aufräumen
|
||||
|
||||
```bash
|
||||
# Entferne nicht verwendete Images
|
||||
docker image prune -a
|
||||
|
||||
# Entferne nicht verwendete Container
|
||||
docker container prune
|
||||
|
||||
# Entferne nicht verwendete Volumes (VORSICHT: kann Daten löschen!)
|
||||
docker volume prune
|
||||
|
||||
# Kompletter Cleanup (alles außer laufenden Containern)
|
||||
docker system prune -a
|
||||
```
|
||||
|
||||
### 2. Speicherplatz prüfen
|
||||
|
||||
```bash
|
||||
# Zeige Speicherplatz
|
||||
df -h
|
||||
|
||||
# Zeige Docker-Speicherverbrauch
|
||||
docker system df
|
||||
|
||||
# Zeige größte Images
|
||||
docker images --format "table {{.Repository}}\t{{.Tag}}\t{{.Size}}" | sort -k3 -h
|
||||
```
|
||||
|
||||
### 3. Build-Kontext prüfen
|
||||
|
||||
```bash
|
||||
# Prüfe was in den Build-Context kopiert wird
|
||||
docker build --no-cache --progress=plain -t test-build . 2>&1 | grep "transferring context"
|
||||
```
|
||||
|
||||
### 4. Upload-Dateien manuell ausschließen
|
||||
|
||||
Falls die `.dockerignore` nicht greift, können Upload-Dateien vorübergehend verschoben werden:
|
||||
|
||||
```bash
|
||||
# Vor dem Build
|
||||
mv public/uploads public/uploads.backup
|
||||
mkdir -p public/uploads
|
||||
touch public/uploads/.gitkeep
|
||||
|
||||
# Build durchführen
|
||||
docker compose build
|
||||
|
||||
# Uploads wiederherstellen
|
||||
rm -rf public/uploads
|
||||
mv public/uploads.backup public/uploads
|
||||
```
|
||||
|
||||
## Wichtig
|
||||
|
||||
Die Upload-Dateien werden **nicht** ins Docker-Image kopiert, sondern als Volume gemountet:
|
||||
|
||||
```yaml
|
||||
volumes:
|
||||
- ./public/uploads:/app/public/uploads
|
||||
```
|
||||
|
||||
Das bedeutet:
|
||||
- Upload-Dateien bleiben auf dem Host-System
|
||||
- Sie werden zur Laufzeit gemountet
|
||||
- Sie sollten **nicht** ins Image kopiert werden (spart viel Speicher)
|
||||
|
||||
## Verifikation
|
||||
|
||||
Nach dem Build sollte das Image deutlich kleiner sein:
|
||||
|
||||
```bash
|
||||
# Zeige Image-Größe
|
||||
docker images hoerdle-hoerdle
|
||||
|
||||
# Prüfe ob Uploads im Image sind
|
||||
docker run --rm hoerdle-hoerdle ls -lh /app/public/uploads
|
||||
# Sollte nur .gitkeep oder Covers zeigen, keine MP3-Dateien
|
||||
```
|
||||
|
||||
83
docs/FIX_I18N.md
Normal file
83
docs/FIX_I18N.md
Normal file
@@ -0,0 +1,83 @@
|
||||
# Fix für i18n-Daten (String → JSON Konvertierung)
|
||||
|
||||
## Problem
|
||||
Die Datenbank hat Genre-/Special-/News-Namen als einfache Strings (`"Rock"`) statt JSON (`{"de": "Rock", "en": "Rock"}`) gespeichert, was zu `SyntaxError: "Rock" is not valid JSON` führt.
|
||||
|
||||
## Lösung: Manuell ausführen
|
||||
|
||||
Führe diese Befehle **direkt auf dem Server** aus:
|
||||
|
||||
```bash
|
||||
cd ~/hoerdle
|
||||
|
||||
# 1. Backup erstellen
|
||||
docker cp hoerdle:/app/data/prod.db ./data/prod.db.backup.$(date +%Y%m%d_%H%M%S)
|
||||
|
||||
# 2. Kopiere DB lokal
|
||||
docker cp hoerdle:/app/data/prod.db ./data/prod.db.tmp
|
||||
|
||||
# 3. Setze Berechtigungen
|
||||
sudo chmod 666 ./data/prod.db.tmp
|
||||
sudo chmod 775 ./data
|
||||
|
||||
# 4. Prüfe ob sqlite3 installiert ist
|
||||
which sqlite3 || sudo apt-get install -y sqlite3
|
||||
|
||||
# 5. Fixe die Datenbank (kopiere diesen Block komplett)
|
||||
sqlite3 ./data/prod.db.tmp << 'EOF'
|
||||
UPDATE Genre SET name = json_object('de', name, 'en', name) WHERE typeof(name) = 'text' AND name NOT LIKE '{%';
|
||||
UPDATE Genre SET subtitle = json_object('de', subtitle, 'en', subtitle) WHERE subtitle IS NOT NULL AND typeof(subtitle) = 'text' AND subtitle NOT LIKE '{%';
|
||||
UPDATE Special SET name = json_object('de', name, 'en', name) WHERE typeof(name) = 'text' AND name NOT LIKE '{%';
|
||||
UPDATE Special SET subtitle = json_object('de', subtitle, 'en', subtitle) WHERE subtitle IS NOT NULL AND typeof(subtitle) = 'text' AND subtitle NOT LIKE '{%';
|
||||
UPDATE News SET title = json_object('de', title, 'en', title) WHERE typeof(title) = 'text' AND title NOT LIKE '{%';
|
||||
UPDATE News SET content = json_object('de', content, 'en', content) WHERE typeof(content) = 'text' AND content NOT LIKE '{%';
|
||||
SELECT '✅ Fertig!' as status;
|
||||
EOF
|
||||
|
||||
# 6. Kopiere zurück
|
||||
docker cp ./data/prod.db.tmp hoerdle:/app/data/prod.db
|
||||
|
||||
# 7. Aufräumen
|
||||
rm ./data/prod.db.tmp
|
||||
|
||||
# 8. Container neu starten
|
||||
docker compose restart hoerdle
|
||||
|
||||
# 9. Logs prüfen
|
||||
docker logs hoerdle --tail=50
|
||||
```
|
||||
|
||||
Falls Schritt 5 mit "permission denied" fehlschlägt, verwende `sudo`:
|
||||
|
||||
```bash
|
||||
sudo sqlite3 ./data/prod.db.tmp << 'EOF'
|
||||
[... SQL-Befehle wie oben ...]
|
||||
EOF
|
||||
```
|
||||
|
||||
## Automatisiertes Skript
|
||||
|
||||
Alternativ kannst du das automatische Skript verwenden:
|
||||
|
||||
```bash
|
||||
./scripts/fix-i18n-easy.sh
|
||||
```
|
||||
|
||||
Oder das lokale Skript:
|
||||
|
||||
```bash
|
||||
./scripts/fix-i18n-local.sh
|
||||
```
|
||||
|
||||
## Prüfen ob es funktioniert hat
|
||||
|
||||
Nach dem Neustart sollte die Seite wieder funktionieren:
|
||||
|
||||
```bash
|
||||
# Prüfe Logs (sollte keine JSON-Fehler mehr zeigen)
|
||||
docker logs hoerdle --tail=100 | grep -i "json\|error" || echo "✅ Keine JSON-Fehler gefunden"
|
||||
|
||||
# Teste die Seite
|
||||
curl -s https://hoerdle.de/de | head -20
|
||||
```
|
||||
|
||||
@@ -8,14 +8,14 @@ Die i18n-Implementierung basiert auf [next-intl](https://next-intl-docs.vercel.a
|
||||
|
||||
## Unterstützte Sprachen
|
||||
|
||||
- **Deutsch (de)** - Standardsprache
|
||||
- **Englisch (en)**
|
||||
- **Englisch (en)** - Standardsprache
|
||||
- **Deutsch (de)**
|
||||
|
||||
## URL-Struktur
|
||||
|
||||
Alle Routen sind lokalisiert:
|
||||
|
||||
- `http://localhost:3000/` → Redirect zu `/de` (Standard)
|
||||
- `http://localhost:3000/` → Redirect zu `/en` (Standard)
|
||||
- `http://localhost:3000/de` → Deutsche Version
|
||||
- `http://localhost:3000/en` → Englische Version
|
||||
- `http://localhost:3000/de/admin` → Admin-Dashboard (Deutsch)
|
||||
@@ -103,8 +103,8 @@ const genreNameEn = getLocalizedValue(genre.name, 'en'); // "Rock"
|
||||
|
||||
**Fallback-Verhalten:**
|
||||
1. Versucht die angeforderte Locale (`de` oder `en`)
|
||||
2. Fallback zu `de` falls nicht vorhanden
|
||||
3. Fallback zu `en` falls nicht vorhanden
|
||||
2. Fallback zu `en` falls nicht vorhanden
|
||||
3. Fallback zu `de` falls nicht vorhanden
|
||||
4. Fallback zum ersten verfügbaren Schlüssel
|
||||
5. Fallback zum übergebenen `fallback`-Parameter
|
||||
|
||||
@@ -195,7 +195,7 @@ Bestehende Daten werden automatisch migriert:
|
||||
|
||||
Der Proxy (`proxy.ts`) leitet Anfragen automatisch um:
|
||||
|
||||
- `/` → `/de` (Standard)
|
||||
- `/` → `/en` (Standard)
|
||||
- Ungültige Locales → 404
|
||||
- Validiert Locale-Parameter
|
||||
|
||||
@@ -223,7 +223,7 @@ GET /api/specials?locale=en
|
||||
GET /api/news?locale=de
|
||||
```
|
||||
|
||||
Falls kein `locale` angegeben wird, wird `de` als Standard verwendet.
|
||||
Falls kein `locale` angegeben wird, wird `en` als Standard verwendet.
|
||||
|
||||
## Best Practices
|
||||
|
||||
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)
|
||||
|
||||
206
docs/TROUBLESHOOTING.md
Normal file
206
docs/TROUBLESHOOTING.md
Normal file
@@ -0,0 +1,206 @@
|
||||
# Troubleshooting Guide
|
||||
|
||||
## Application Error: "a server-side exception has occurred"
|
||||
|
||||
Dieser Fehler tritt auf, wenn die Next.js-Anwendung auf dem Server einen Fehler hat.
|
||||
|
||||
### ⚠️ Datenbank-Berechtigungen (wenn DB von anderem Server kopiert wurde)
|
||||
|
||||
**Symptom**: Application Error nach dem Kopieren einer Datenbank von einem anderen Server
|
||||
|
||||
**Ursache**: SQLite benötigt Schreibrechte auf:
|
||||
- Die Datenbankdatei selbst (`prod.db`)
|
||||
- Das Datenbankverzeichnis (für temporäre Dateien wie `-wal`, `-shm`)
|
||||
|
||||
**Sofort-Lösung (auf dem Server ausführen)**:
|
||||
```bash
|
||||
# 1. Setze Berechtigungen für Datenbankverzeichnis und Datei
|
||||
chmod 775 ./data
|
||||
chmod 664 ./data/prod.db
|
||||
|
||||
# 2. Falls temporäre SQLite-Dateien existieren, auch diese:
|
||||
chmod 664 ./data/*.db-wal 2>/dev/null || true
|
||||
chmod 664 ./data/*.db-shm 2>/dev/null || true
|
||||
|
||||
# 3. Oder verwende das Fix-Skript:
|
||||
./scripts/fix-database-permissions.sh
|
||||
|
||||
# 4. Container neu starten
|
||||
docker compose restart hoerdle
|
||||
|
||||
# 5. Logs prüfen
|
||||
docker logs hoerdle --tail=50
|
||||
```
|
||||
|
||||
**Warum passiert das?**
|
||||
- Wenn du eine Datenbankdatei von einem anderen Server kopierst, behält sie die ursprünglichen Berechtigungen
|
||||
- SQLite muss Schreibrechte haben, um zu funktionieren
|
||||
- Auch das Verzeichnis braucht Schreibrechte (für SQLite-WAL-Modus)
|
||||
|
||||
### Sofort-Diagnose (auf dem Server ausführen)
|
||||
|
||||
```bash
|
||||
# 1. Container-Logs prüfen (die wichtigste Information!)
|
||||
docker logs hoerdle --tail=100
|
||||
|
||||
# 2. Container-Status prüfen
|
||||
docker ps | grep hoerdle
|
||||
|
||||
# 3. Prüfe ob Datenbank existiert
|
||||
docker exec hoerdle ls -lh /app/data/prod.db
|
||||
|
||||
# 4. Prüfe ob Server auf Port 3000 antwortet (intern)
|
||||
docker exec hoerdle curl -f http://localhost:3000/api/daily
|
||||
|
||||
# 5. Prüfe Health Check
|
||||
docker inspect hoerdle --format='{{json .State.Health}}' | python3 -m json.tool
|
||||
```
|
||||
|
||||
### Häufige Ursachen und Lösungen
|
||||
|
||||
#### 1. Datenbankfehler / Migrationen fehlgeschlagen
|
||||
|
||||
**Symptom**: Logs zeigen Prisma-Fehler oder "database locked"
|
||||
|
||||
**Lösung**:
|
||||
```bash
|
||||
# Container-Logs prüfen
|
||||
docker logs hoerdle | grep -i "migration\|database\|prisma"
|
||||
|
||||
# Falls Migrationen fehlgeschlagen sind:
|
||||
docker compose restart hoerdle
|
||||
|
||||
# Bei persistierenden Problemen: Datenbank-Backup prüfen
|
||||
ls -lh ./backups/
|
||||
```
|
||||
|
||||
#### 2. Container läuft nicht oder ist crashed
|
||||
|
||||
**Symptom**: Container existiert nicht oder Status zeigt "Exited"
|
||||
|
||||
**Lösung**:
|
||||
```bash
|
||||
# Container-Status prüfen
|
||||
docker ps -a | grep hoerdle
|
||||
|
||||
# Container neu starten
|
||||
docker compose up -d
|
||||
|
||||
# Falls Container nicht startet, Logs prüfen
|
||||
docker logs hoerdle --tail=200
|
||||
```
|
||||
|
||||
#### 3. Caddy kann Container nicht erreichen
|
||||
|
||||
**Symptom**: 502 Bad Gateway oder "connection refused" in Caddy-Logs
|
||||
|
||||
**Lösung**:
|
||||
```bash
|
||||
# Prüfe ob hoerdle Container läuft
|
||||
docker ps | grep hoerdle
|
||||
|
||||
# Prüfe Netzwerk
|
||||
docker network inspect hoerdle_default
|
||||
|
||||
# Prüfe Caddy-Logs
|
||||
docker logs hoerdle-caddy --tail=50
|
||||
|
||||
# Stelle sicher, dass Caddyfile Port 3000 verwendet (nicht 3010!)
|
||||
grep "reverse_proxy" Caddyfile
|
||||
```
|
||||
|
||||
#### 4. Fehlende Umgebungsvariablen
|
||||
|
||||
**Symptom**: Logs zeigen undefined variables
|
||||
|
||||
**Lösung**:
|
||||
```bash
|
||||
# Prüfe wichtige Umgebungsvariablen
|
||||
docker exec hoerdle env | grep -E "DATABASE_URL|NODE_ENV"
|
||||
|
||||
# Prüfe .env Datei (falls vorhanden)
|
||||
cat .env | grep DATABASE_URL
|
||||
```
|
||||
|
||||
#### 5. Build-Fehler oder fehlerhafte Dateien
|
||||
|
||||
**Symptom**: Container startet, aber App crasht sofort
|
||||
|
||||
**Lösung**:
|
||||
```bash
|
||||
# Container komplett neu bauen
|
||||
docker compose down
|
||||
docker compose build --no-cache
|
||||
docker compose up -d
|
||||
|
||||
# Prüfe Build-Logs
|
||||
docker compose build 2>&1 | tee build.log
|
||||
```
|
||||
|
||||
### Detaillierte Log-Analyse
|
||||
|
||||
```bash
|
||||
# Alle Fehler in Logs finden
|
||||
docker logs hoerdle 2>&1 | grep -i -E "error|exception|fatal|panic" | tail -50
|
||||
|
||||
# Prisma-spezifische Fehler
|
||||
docker logs hoerdle 2>&1 | grep -i prisma | tail -20
|
||||
|
||||
# Next.js-spezifische Fehler
|
||||
docker logs hoerdle 2>&1 | grep -i "next\|react" | tail -20
|
||||
```
|
||||
|
||||
### Netzwerk-Debugging
|
||||
|
||||
```bash
|
||||
# Teste Verbindung von Caddy zu Hördle
|
||||
docker exec hoerdle-caddy wget -O- http://hoerdle:3000/api/daily
|
||||
|
||||
# Prüfe alle Container im Netzwerk
|
||||
docker network inspect hoerdle_default --format='{{range .Containers}}{{.Name}}: {{.IPv4Address}}{{"\n"}}{{end}}'
|
||||
```
|
||||
|
||||
### Datenbank-Debugging
|
||||
|
||||
```bash
|
||||
# Prüfe Datenbank-Integrität
|
||||
docker exec hoerdle npx prisma db pull
|
||||
|
||||
# Prüfe Datenbank-Struktur
|
||||
docker exec hoerdle npx prisma studio &
|
||||
# (dann Browser öffnen - erfordert X11 forwarding oder lokalen Zugriff)
|
||||
```
|
||||
|
||||
### Quick-Fix: Vollständiger Neustart
|
||||
|
||||
Wenn nichts anderes hilft:
|
||||
|
||||
```bash
|
||||
# 1. Backup erstellen
|
||||
cp ./data/prod.db ./backups/prod_$(date +%Y%m%d_%H%M%S).db
|
||||
|
||||
# 2. Container stoppen
|
||||
docker compose down
|
||||
|
||||
# 3. Container neu starten
|
||||
docker compose up -d
|
||||
|
||||
# 4. Logs beobachten
|
||||
docker compose logs -f
|
||||
```
|
||||
|
||||
## Bei weiterem Bedarf
|
||||
|
||||
Sammle folgende Informationen für weitere Hilfe:
|
||||
|
||||
```bash
|
||||
echo "=== Container Status ===" && \
|
||||
docker ps -a | grep hoerdle && \
|
||||
echo -e "\n=== Letzte 50 Log-Zeilen ===" && \
|
||||
docker logs hoerdle --tail=50 && \
|
||||
echo -e "\n=== Fehler in Logs ===" && \
|
||||
docker logs hoerdle 2>&1 | grep -i error | tail -20
|
||||
```
|
||||
|
||||
Kopiere die vollständige Ausgabe und sende sie weiter.
|
||||
|
||||
@@ -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_DESCRIPTION` | The description used in metadata. | `Daily music guessing game...` |
|
||||
| `NEXT_PUBLIC_DOMAIN` | The domain name (used for sharing). | `hoerdle.elpatron.me` |
|
||||
| `NEXT_PUBLIC_TWITTER_HANDLE` | Twitter handle for metadata. | `@elpatron` |
|
||||
| `NEXT_PUBLIC_DOMAIN` | The domain name (used for sharing). | `hoerdle.de` |
|
||||
|
||||
### Analytics (Plausible)
|
||||
|
||||
| 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.elpatron.me/js/script.js` |
|
||||
| `NEXT_PUBLIC_PLAUSIBLE_SCRIPT_SRC` | The URL of the Plausible script. | `https://plausible.example.com/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
|
||||
|
||||
@@ -9,7 +9,7 @@ export default getRequestConfig(async ({ requestLocale }) => {
|
||||
console.log('[i18n/request] incoming requestLocale:', locale);
|
||||
|
||||
if (!locale || !locales.includes(locale as (typeof locales)[number])) {
|
||||
locale = 'de';
|
||||
locale = 'en';
|
||||
console.log('[i18n/request] falling back to default locale:', locale);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
export const config = {
|
||||
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',
|
||||
domain: process.env.NEXT_PUBLIC_DOMAIN || 'hoerdle.elpatron.me',
|
||||
twitterHandle: process.env.NEXT_PUBLIC_TWITTER_HANDLE || '@elpatron',
|
||||
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',
|
||||
domain: process.env.NEXT_PUBLIC_DOMAIN || 'hoerdle.de',
|
||||
plausibleScriptSrc: process.env.NEXT_PUBLIC_PLAUSIBLE_SCRIPT_SRC || 'https://plausible.example.com/js/script.js',
|
||||
colors: {
|
||||
themeColor: process.env.NEXT_PUBLIC_THEME_COLOR || '#000000',
|
||||
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',
|
||||
linkText: process.env.NEXT_PUBLIC_CREDITS_LINK_TEXT || '@elpatron@digitalcourage.social',
|
||||
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,
|
||||
}
|
||||
};
|
||||
|
||||
@@ -16,12 +16,12 @@ export function getLocalizedValue(
|
||||
if (typeof value === 'object') {
|
||||
if (value[locale]) return value[locale];
|
||||
|
||||
// Fallback to 'de'
|
||||
if (value['de']) return value['de'];
|
||||
|
||||
// Fallback to 'en'
|
||||
if (value['en']) return value['en'];
|
||||
|
||||
// Fallback to 'de'
|
||||
if (value['de']) return value['de'];
|
||||
|
||||
// Fallback to first key
|
||||
const keys = Object.keys(value);
|
||||
if (keys.length > 0) return value[keys[0]];
|
||||
|
||||
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}`;
|
||||
}
|
||||
|
||||
305
messages/de.json
305
messages/de.json
@@ -1,156 +1,156 @@
|
||||
{
|
||||
"Common": {
|
||||
"loading": "Laden...",
|
||||
"error": "Ein Fehler ist aufgetreten",
|
||||
"save": "Speichern",
|
||||
"cancel": "Abbrechen",
|
||||
"delete": "Löschen",
|
||||
"edit": "Bearbeiten",
|
||||
"back": "Zurück"
|
||||
},
|
||||
"Navigation": {
|
||||
"home": "Startseite",
|
||||
"admin": "Admin",
|
||||
"global": "Global",
|
||||
"news": "Neuigkeiten"
|
||||
},
|
||||
"Game": {
|
||||
"play": "Abspielen",
|
||||
"pause": "Pause",
|
||||
"skip": "Überspringen",
|
||||
"submit": "Raten",
|
||||
"next": "Nächstes",
|
||||
"won": "Gewonnen!",
|
||||
"lost": "Verloren",
|
||||
"correct": "Richtig!",
|
||||
"wrong": "Falsch",
|
||||
"guessPlaceholder": "Lied oder Interpret eingeben...",
|
||||
"attempts": "Versuche",
|
||||
"share": "Teilen",
|
||||
"nextPuzzle": "Nächstes Rätsel in",
|
||||
"noPuzzleAvailable": "Kein Rätsel verfügbar",
|
||||
"noPuzzleDescription": "Tägliches Rätsel konnte nicht generiert werden.",
|
||||
"noPuzzleGenre": "Bitte stelle sicher, dass Songs in der Datenbank vorhanden sind",
|
||||
"goToAdmin": "Zum Admin-Dashboard gehen",
|
||||
"loadingState": "Lade Status...",
|
||||
"attempt": "Versuch",
|
||||
"unlocked": "freigeschaltet",
|
||||
"start": "Start",
|
||||
"skipWithBonus": "Überspringen (+{seconds}s)",
|
||||
"solveGiveUp": "Lösen (Aufgeben)",
|
||||
"comeBackTomorrow": "Komm morgen zurück für ein neues Lied.",
|
||||
"theSongWas": "Das Lied war:",
|
||||
"score": "Punkte",
|
||||
"scoreBreakdown": "Punkteaufschlüsselung",
|
||||
"albumCover": "Album-Cover",
|
||||
"released": "Veröffentlicht",
|
||||
"yourBrowserDoesNotSupport": "Ihr Browser unterstützt das Audio-Element nicht.",
|
||||
"thanksForRating": "Danke für die Bewertung!",
|
||||
"rateThisPuzzle": "Bewerte dieses Rätsel:",
|
||||
"shared": "✓ Geteilt!",
|
||||
"copied": "✓ Kopiert!",
|
||||
"shareFailed": "✗ Fehlgeschlagen",
|
||||
"bonusRound": "Bonus-Runde!",
|
||||
"guessReleaseYear": "Errate das Veröffentlichungsjahr für",
|
||||
"points": "Punkte",
|
||||
"skipBonus": "Bonus überspringen",
|
||||
"notQuite": "Nicht ganz!",
|
||||
"youGuessed": "Du hast geraten",
|
||||
"actuallyReleasedIn": "Tatsächlich veröffentlicht in",
|
||||
"skipped": "Übersprungen",
|
||||
"gameOverPlaceholder": "Spiel beendet",
|
||||
"knowItSearch": "Weißt du es? Suche nach Interpret / Titel",
|
||||
"special": "Special",
|
||||
"genre": "Genre"
|
||||
},
|
||||
"Statistics": {
|
||||
"yourStatistics": "Deine Statistiken",
|
||||
"totalPuzzles": "Gesamte Rätsel",
|
||||
"try": "Versuch",
|
||||
"failed": "Verloren"
|
||||
},
|
||||
"OnboardingTour": {
|
||||
"done": "Fertig",
|
||||
"next": "Weiter",
|
||||
"previous": "Zurück",
|
||||
"genresSpecials": "Genres & Specials",
|
||||
"genresSpecialsDescription": "Wähle hier ein bestimmtes Genre oder ein kuratiertes Special-Event.",
|
||||
"news": "Neuigkeiten",
|
||||
"newsDescription": "Bleibe auf dem Laufenden mit den neuesten Nachrichten und Ankündigungen.",
|
||||
"hoerdle": "Hördle",
|
||||
"hoerdleDescription": "Das ist das tägliche Rätsel. Ein neues Lied jeden Tag pro Genre.",
|
||||
"attempts": "Versuche",
|
||||
"attemptsDescription": "Du hast eine begrenzte Anzahl von Versuchen, um das Lied zu erraten.",
|
||||
"score": "Punkte",
|
||||
"scoreDescription": "Deine aktuelle Punktzahl. Versuche sie hoch zu halten!",
|
||||
"player": "Player",
|
||||
"playerDescription": "Höre dir den Ausschnitt an. Jedes zusätzliche Abspielen reduziert deine mögliche Punktzahl.",
|
||||
"input": "Eingabe",
|
||||
"inputDescription": "Gib hier deine Vermutung ein. Suche nach Interpret oder Titel.",
|
||||
"controls": "Steuerung",
|
||||
"controlsDescription": "Starte die Musik oder überspringe zum nächsten Ausschnitt, wenn du feststeckst."
|
||||
},
|
||||
"InstallPrompt": {
|
||||
"installApp": "Hördle App installieren",
|
||||
"installDescription": "Installiere die App für eine bessere Erfahrung und schnellen Zugriff!",
|
||||
"iosInstructions": "Tippe auf",
|
||||
"iosShare": "Teilen",
|
||||
"iosThen": "dann \"Zum Home-Bildschirm hinzufügen\"",
|
||||
"installButton": "App installieren"
|
||||
},
|
||||
"Home": {
|
||||
"welcome": "Willkommen bei Hördle",
|
||||
"subtitle": "Errate den Song anhand kurzer Ausschnitte",
|
||||
"globalTooltip": "Ein zufälliger Song aus der gesamten Sammlung",
|
||||
"comingSoon": "Demnächst",
|
||||
"curatedBy": "Kuratiert von"
|
||||
},
|
||||
"Admin": {
|
||||
"title": "Hördle Admin Dashboard",
|
||||
"login": "Admin Login",
|
||||
"password": "Passwort",
|
||||
"loginButton": "Login",
|
||||
"logout": "Abmelden",
|
||||
"manageSpecials": "Specials verwalten",
|
||||
"manageGenres": "Genres verwalten",
|
||||
"manageNews": "News & Ankündigungen verwalten",
|
||||
"uploadSongs": "Songs hochladen",
|
||||
"todaysPuzzles": "Heutige tägliche Rätsel",
|
||||
"show": "▶ Anzeigen",
|
||||
"hide": "▼ Ausblenden",
|
||||
"addSpecial": "Special hinzufügen",
|
||||
"addGenre": "Genre hinzufügen",
|
||||
"addNews": "News hinzufügen",
|
||||
"edit": "Bearbeiten",
|
||||
"delete": "Löschen",
|
||||
"save": "Speichern",
|
||||
"cancel": "Abbrechen",
|
||||
"curate": "Kurieren",
|
||||
"name": "Name",
|
||||
"subtitle": "Untertitel",
|
||||
"maxAttempts": "Max. Versuche",
|
||||
"unlockSteps": "Freischalt-Schritte",
|
||||
"launchDate": "Startdatum",
|
||||
"endDate": "Enddatum",
|
||||
"curator": "Kurator",
|
||||
"active": "Aktiv",
|
||||
"newGenreName": "Neuer Genre-Name",
|
||||
"editSpecial": "Special bearbeiten",
|
||||
"editGenre": "Genre bearbeiten",
|
||||
"editNews": "News bearbeiten",
|
||||
"newsTitle": "News-Titel",
|
||||
"content": "Inhalt (Markdown unterstützt)",
|
||||
"author": "Autor (optional)",
|
||||
"featured": "Hervorgehoben",
|
||||
"noSpecialLink": "Kein Special-Link",
|
||||
"noNewsItems": "Noch keine News-Einträge. Erstelle einen oben!",
|
||||
"noPuzzlesToday": "Keine täglichen Rätsel für heute gefunden.",
|
||||
"category": "Kategorie",
|
||||
"song": "Song",
|
||||
"artist": "Interpret",
|
||||
"actions": "Aktionen",
|
||||
"deletePuzzle": "Löschen",
|
||||
"wrongPassword": "Falsches Passwort"
|
||||
"Common": {
|
||||
"loading": "Laden...",
|
||||
"error": "Ein Fehler ist aufgetreten",
|
||||
"save": "Speichern",
|
||||
"cancel": "Abbrechen",
|
||||
"delete": "Löschen",
|
||||
"edit": "Bearbeiten",
|
||||
"back": "Zurück"
|
||||
},
|
||||
"Navigation": {
|
||||
"home": "Startseite",
|
||||
"admin": "Admin",
|
||||
"global": "Global",
|
||||
"news": "Neuigkeiten"
|
||||
},
|
||||
"Game": {
|
||||
"play": "Abspielen",
|
||||
"pause": "Pause",
|
||||
"skip": "Überspringen",
|
||||
"submit": "Raten",
|
||||
"next": "Nächstes",
|
||||
"won": "Gewonnen!",
|
||||
"lost": "Verloren",
|
||||
"correct": "Richtig!",
|
||||
"wrong": "Falsch",
|
||||
"guessPlaceholder": "Lied oder Interpret eingeben...",
|
||||
"attempts": "Versuche",
|
||||
"share": "Teilen",
|
||||
"nextPuzzle": "Nächstes Rätsel in",
|
||||
"noPuzzleAvailable": "Kein Rätsel verfügbar",
|
||||
"noPuzzleDescription": "Tägliches Rätsel konnte nicht generiert werden.",
|
||||
"noPuzzleGenre": "Bitte stelle sicher, dass Songs in der Datenbank vorhanden sind",
|
||||
"goToAdmin": "Zum Admin-Dashboard gehen",
|
||||
"loadingState": "Lade Status...",
|
||||
"attempt": "Versuch",
|
||||
"unlocked": "freigeschaltet",
|
||||
"start": "Start",
|
||||
"skipWithBonus": "Überspringen (+{seconds}s)",
|
||||
"solveGiveUp": "Lösen (Aufgeben)",
|
||||
"comeBackTomorrow": "Komm morgen zurück für ein neues Lied.",
|
||||
"theSongWas": "Das Lied war:",
|
||||
"score": "Punkte",
|
||||
"scoreBreakdown": "Punkteaufschlüsselung",
|
||||
"albumCover": "Album-Cover",
|
||||
"released": "Veröffentlicht",
|
||||
"yourBrowserDoesNotSupport": "Ihr Browser unterstützt das Audio-Element nicht.",
|
||||
"thanksForRating": "Danke für die Bewertung!",
|
||||
"rateThisPuzzle": "Bewerte dieses Rätsel:",
|
||||
"shared": "✓ Geteilt!",
|
||||
"copied": "✓ Kopiert!",
|
||||
"shareFailed": "✗ Fehlgeschlagen",
|
||||
"bonusRound": "Bonus-Runde!",
|
||||
"guessReleaseYear": "Errate das Veröffentlichungsjahr für",
|
||||
"points": "Punkte",
|
||||
"skipBonus": "Bonus überspringen",
|
||||
"notQuite": "Nicht ganz!",
|
||||
"youGuessed": "Du hast geraten",
|
||||
"actuallyReleasedIn": "Tatsächlich veröffentlicht in",
|
||||
"skipped": "Übersprungen",
|
||||
"gameOverPlaceholder": "Spiel beendet",
|
||||
"knowItSearch": "Weißt du es? Suche nach Interpret / Titel",
|
||||
"special": "Special",
|
||||
"genre": "Genre"
|
||||
},
|
||||
"Statistics": {
|
||||
"yourStatistics": "Deine Statistiken",
|
||||
"totalPuzzles": "Gesamte Rätsel",
|
||||
"try": "Versuch",
|
||||
"failed": "Verloren"
|
||||
},
|
||||
"OnboardingTour": {
|
||||
"done": "Fertig",
|
||||
"next": "Weiter",
|
||||
"previous": "Zurück",
|
||||
"genresSpecials": "Genres & Specials",
|
||||
"genresSpecialsDescription": "Wähle hier ein bestimmtes Genre oder ein kuratiertes Special-Event.",
|
||||
"news": "Neuigkeiten",
|
||||
"newsDescription": "Bleibe auf dem Laufenden mit den neuesten Nachrichten und Ankündigungen.",
|
||||
"hoerdle": "Hördle",
|
||||
"hoerdleDescription": "Das ist das tägliche Rätsel. Ein neues Lied jeden Tag pro Genre.",
|
||||
"attempts": "Versuche",
|
||||
"attemptsDescription": "Du hast eine begrenzte Anzahl von Versuchen, um das Lied zu erraten.",
|
||||
"score": "Punkte",
|
||||
"scoreDescription": "Deine aktuelle Punktzahl. Versuche sie hoch zu halten!",
|
||||
"player": "Player",
|
||||
"playerDescription": "Höre dir den Ausschnitt an. Jedes zusätzliche Abspielen reduziert deine mögliche Punktzahl.",
|
||||
"input": "Eingabe",
|
||||
"inputDescription": "Gib hier deine Vermutung ein. Suche nach Interpret oder Titel.",
|
||||
"controls": "Steuerung",
|
||||
"controlsDescription": "Starte die Musik oder überspringe zum nächsten Ausschnitt, wenn du feststeckst."
|
||||
},
|
||||
"InstallPrompt": {
|
||||
"installApp": "Hördle App installieren",
|
||||
"installDescription": "Installiere die App für eine bessere Erfahrung und schnellen Zugriff!",
|
||||
"iosInstructions": "Tippe auf",
|
||||
"iosShare": "Teilen",
|
||||
"iosThen": "dann \"Zum Home-Bildschirm hinzufügen\"",
|
||||
"installButton": "App installieren"
|
||||
},
|
||||
"Home": {
|
||||
"welcome": "Willkommen bei Hördle",
|
||||
"subtitle": "Errate den Song anhand kurzer Ausschnitte",
|
||||
"globalTooltip": "Ein zufälliger Song aus der gesamten Sammlung",
|
||||
"comingSoon": "Demnächst",
|
||||
"curatedBy": "Kuratiert von"
|
||||
},
|
||||
"Admin": {
|
||||
"title": "Hördle Admin Dashboard",
|
||||
"login": "Admin Login",
|
||||
"password": "Passwort",
|
||||
"loginButton": "Login",
|
||||
"logout": "Abmelden",
|
||||
"manageSpecials": "Specials verwalten",
|
||||
"manageGenres": "Genres verwalten",
|
||||
"manageNews": "News & Ankündigungen verwalten",
|
||||
"uploadSongs": "Songs hochladen",
|
||||
"todaysPuzzles": "Heutige tägliche Rätsel",
|
||||
"show": "▶ Anzeigen",
|
||||
"hide": "▼ Ausblenden",
|
||||
"addSpecial": "Special hinzufügen",
|
||||
"addGenre": "Genre hinzufügen",
|
||||
"addNews": "News hinzufügen",
|
||||
"edit": "Bearbeiten",
|
||||
"delete": "Löschen",
|
||||
"save": "Speichern",
|
||||
"cancel": "Abbrechen",
|
||||
"curate": "Kuratieren",
|
||||
"name": "Name",
|
||||
"subtitle": "Untertitel",
|
||||
"maxAttempts": "Max. Versuche",
|
||||
"unlockSteps": "Freischalt-Schritte",
|
||||
"launchDate": "Startdatum",
|
||||
"endDate": "Enddatum",
|
||||
"curator": "Kurator",
|
||||
"active": "Aktiv",
|
||||
"newGenreName": "Neuer Genre-Name",
|
||||
"editSpecial": "Special bearbeiten",
|
||||
"editGenre": "Genre bearbeiten",
|
||||
"editNews": "News bearbeiten",
|
||||
"newsTitle": "News-Titel",
|
||||
"content": "Inhalt (Markdown unterstützt)",
|
||||
"author": "Autor (optional)",
|
||||
"featured": "Hervorgehoben",
|
||||
"noSpecialLink": "Kein Special-Link",
|
||||
"noNewsItems": "Noch keine News-Einträge. Erstelle einen oben!",
|
||||
"noPuzzlesToday": "Keine täglichen Rätsel für heute gefunden.",
|
||||
"category": "Kategorie",
|
||||
"song": "Song",
|
||||
"artist": "Interpret",
|
||||
"actions": "Aktionen",
|
||||
"deletePuzzle": "Löschen",
|
||||
"wrongPassword": "Falsches Passwort"
|
||||
},
|
||||
"About": {
|
||||
"title": "Über Hördle & Impressum",
|
||||
@@ -162,7 +162,6 @@
|
||||
"imprintOperator": "Verantwortlich für den Inhalt dieser Seite (Anbieter nach § 5 TMG):",
|
||||
"imprintCountry": "Deutschland",
|
||||
"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",
|
||||
"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)",
|
||||
|
||||
305
messages/en.json
305
messages/en.json
@@ -1,156 +1,156 @@
|
||||
{
|
||||
"Common": {
|
||||
"loading": "Loading...",
|
||||
"error": "An error occurred",
|
||||
"save": "Save",
|
||||
"cancel": "Cancel",
|
||||
"delete": "Delete",
|
||||
"edit": "Edit",
|
||||
"back": "Back"
|
||||
},
|
||||
"Navigation": {
|
||||
"home": "Home",
|
||||
"admin": "Admin",
|
||||
"global": "Global",
|
||||
"news": "News"
|
||||
},
|
||||
"Game": {
|
||||
"play": "Play",
|
||||
"pause": "Pause",
|
||||
"skip": "Skip",
|
||||
"submit": "Guess",
|
||||
"next": "Next",
|
||||
"won": "You won!",
|
||||
"lost": "Game Over",
|
||||
"correct": "Correct!",
|
||||
"wrong": "Wrong",
|
||||
"guessPlaceholder": "Type song or artist...",
|
||||
"attempts": "Attempts",
|
||||
"share": "Share",
|
||||
"nextPuzzle": "Next puzzle in",
|
||||
"noPuzzleAvailable": "No Puzzle Available",
|
||||
"noPuzzleDescription": "Could not generate a daily puzzle.",
|
||||
"noPuzzleGenre": "Please ensure there are songs in the database",
|
||||
"goToAdmin": "Go to Admin Dashboard",
|
||||
"loadingState": "Loading state...",
|
||||
"attempt": "Attempt",
|
||||
"unlocked": "unlocked",
|
||||
"start": "Start",
|
||||
"skipWithBonus": "Skip (+{seconds}s)",
|
||||
"solveGiveUp": "Solve (Give Up)",
|
||||
"comeBackTomorrow": "Come back tomorrow for a new song.",
|
||||
"theSongWas": "The song was:",
|
||||
"score": "Score",
|
||||
"scoreBreakdown": "Score Breakdown",
|
||||
"albumCover": "Album Cover",
|
||||
"released": "Released",
|
||||
"yourBrowserDoesNotSupport": "Your browser does not support the audio element.",
|
||||
"thanksForRating": "Thanks for rating!",
|
||||
"rateThisPuzzle": "Rate this puzzle:",
|
||||
"shared": "✓ Shared!",
|
||||
"copied": "✓ Copied!",
|
||||
"shareFailed": "✗ Failed",
|
||||
"bonusRound": "Bonus Round!",
|
||||
"guessReleaseYear": "Guess the release year for",
|
||||
"points": "points",
|
||||
"skipBonus": "Skip Bonus",
|
||||
"notQuite": "Not quite!",
|
||||
"youGuessed": "You guessed",
|
||||
"actuallyReleasedIn": "Actually released in",
|
||||
"skipped": "Skipped",
|
||||
"gameOverPlaceholder": "Game Over",
|
||||
"knowItSearch": "Know it? Search for the artist / title",
|
||||
"special": "Special",
|
||||
"genre": "Genre"
|
||||
},
|
||||
"Statistics": {
|
||||
"yourStatistics": "Your Statistics",
|
||||
"totalPuzzles": "Total puzzles",
|
||||
"try": "try",
|
||||
"failed": "Failed"
|
||||
},
|
||||
"OnboardingTour": {
|
||||
"done": "Done",
|
||||
"next": "Next",
|
||||
"previous": "Previous",
|
||||
"genresSpecials": "Genres & Specials",
|
||||
"genresSpecialsDescription": "Choose a specific genre or a curated special event here.",
|
||||
"news": "News",
|
||||
"newsDescription": "Stay updated with the latest news and announcements.",
|
||||
"hoerdle": "Hördle",
|
||||
"hoerdleDescription": "This is the daily puzzle. One new song every day per genre.",
|
||||
"attempts": "Attempts",
|
||||
"attemptsDescription": "You have a limited number of attempts to guess the song.",
|
||||
"score": "Score",
|
||||
"scoreDescription": "Your current score. Try to keep it high!",
|
||||
"player": "Player",
|
||||
"playerDescription": "Listen to the snippet. Each additional play reduces your potential score.",
|
||||
"input": "Input",
|
||||
"inputDescription": "Type your guess here. Search for artist or title.",
|
||||
"controls": "Controls",
|
||||
"controlsDescription": "Start the music or skip to the next snippet if you're stuck."
|
||||
},
|
||||
"InstallPrompt": {
|
||||
"installApp": "Install Hördle App",
|
||||
"installDescription": "Install the app for a better experience and quick access!",
|
||||
"iosInstructions": "Tap",
|
||||
"iosShare": "share",
|
||||
"iosThen": "then \"Add to Home Screen\"",
|
||||
"installButton": "Install App"
|
||||
},
|
||||
"Home": {
|
||||
"welcome": "Welcome to Hördle",
|
||||
"subtitle": "Guess the song from short snippets",
|
||||
"globalTooltip": "A random song from the entire collection",
|
||||
"comingSoon": "Coming soon",
|
||||
"curatedBy": "Curated by"
|
||||
},
|
||||
"Admin": {
|
||||
"title": "Hördle Admin Dashboard",
|
||||
"login": "Admin Login",
|
||||
"password": "Password",
|
||||
"loginButton": "Login",
|
||||
"logout": "Logout",
|
||||
"manageSpecials": "Manage Specials",
|
||||
"manageGenres": "Manage Genres",
|
||||
"manageNews": "Manage News & Announcements",
|
||||
"uploadSongs": "Upload Songs",
|
||||
"todaysPuzzles": "Today's Daily Puzzles",
|
||||
"show": "▶ Show",
|
||||
"hide": "▼ Hide",
|
||||
"addSpecial": "Add Special",
|
||||
"addGenre": "Add Genre",
|
||||
"addNews": "Add News",
|
||||
"edit": "Edit",
|
||||
"delete": "Delete",
|
||||
"save": "Save",
|
||||
"cancel": "Cancel",
|
||||
"curate": "Curate",
|
||||
"name": "Name",
|
||||
"subtitle": "Subtitle",
|
||||
"maxAttempts": "Max Attempts",
|
||||
"unlockSteps": "Unlock Steps",
|
||||
"launchDate": "Launch Date",
|
||||
"endDate": "End Date",
|
||||
"curator": "Curator",
|
||||
"active": "Active",
|
||||
"newGenreName": "New Genre Name",
|
||||
"editSpecial": "Edit Special",
|
||||
"editGenre": "Edit Genre",
|
||||
"editNews": "Edit News",
|
||||
"newsTitle": "News Title",
|
||||
"content": "Content (Markdown supported)",
|
||||
"author": "Author (optional)",
|
||||
"featured": "Featured",
|
||||
"noSpecialLink": "No Special Link",
|
||||
"noNewsItems": "No news items yet. Create one above!",
|
||||
"noPuzzlesToday": "No daily puzzles found for today.",
|
||||
"category": "Category",
|
||||
"song": "Song",
|
||||
"artist": "Artist",
|
||||
"actions": "Actions",
|
||||
"deletePuzzle": "Delete",
|
||||
"wrongPassword": "Wrong password"
|
||||
"Common": {
|
||||
"loading": "Loading...",
|
||||
"error": "An error occurred",
|
||||
"save": "Save",
|
||||
"cancel": "Cancel",
|
||||
"delete": "Delete",
|
||||
"edit": "Edit",
|
||||
"back": "Back"
|
||||
},
|
||||
"Navigation": {
|
||||
"home": "Home",
|
||||
"admin": "Admin",
|
||||
"global": "Global",
|
||||
"news": "News"
|
||||
},
|
||||
"Game": {
|
||||
"play": "Play",
|
||||
"pause": "Pause",
|
||||
"skip": "Skip",
|
||||
"submit": "Guess",
|
||||
"next": "Next",
|
||||
"won": "You won!",
|
||||
"lost": "Game Over",
|
||||
"correct": "Correct!",
|
||||
"wrong": "Wrong",
|
||||
"guessPlaceholder": "Type song or artist...",
|
||||
"attempts": "Attempts",
|
||||
"share": "Share",
|
||||
"nextPuzzle": "Next puzzle in",
|
||||
"noPuzzleAvailable": "No Puzzle Available",
|
||||
"noPuzzleDescription": "Could not generate a daily puzzle.",
|
||||
"noPuzzleGenre": "Please ensure there are songs in the database",
|
||||
"goToAdmin": "Go to Admin Dashboard",
|
||||
"loadingState": "Loading state...",
|
||||
"attempt": "Attempt",
|
||||
"unlocked": "unlocked",
|
||||
"start": "Start",
|
||||
"skipWithBonus": "Skip (+{seconds}s)",
|
||||
"solveGiveUp": "Solve (Give Up)",
|
||||
"comeBackTomorrow": "Come back tomorrow for a new song.",
|
||||
"theSongWas": "The song was:",
|
||||
"score": "Score",
|
||||
"scoreBreakdown": "Score Breakdown",
|
||||
"albumCover": "Album Cover",
|
||||
"released": "Released",
|
||||
"yourBrowserDoesNotSupport": "Your browser does not support the audio element.",
|
||||
"thanksForRating": "Thanks for rating!",
|
||||
"rateThisPuzzle": "Rate this puzzle:",
|
||||
"shared": "✓ Shared!",
|
||||
"copied": "✓ Copied!",
|
||||
"shareFailed": "✗ Failed",
|
||||
"bonusRound": "Bonus Round!",
|
||||
"guessReleaseYear": "Guess the release year for",
|
||||
"points": "points",
|
||||
"skipBonus": "Skip Bonus",
|
||||
"notQuite": "Not quite!",
|
||||
"youGuessed": "You guessed",
|
||||
"actuallyReleasedIn": "Actually released in",
|
||||
"skipped": "Skipped",
|
||||
"gameOverPlaceholder": "Game Over",
|
||||
"knowItSearch": "Know it? Search for the artist / title",
|
||||
"special": "Special",
|
||||
"genre": "Genre"
|
||||
},
|
||||
"Statistics": {
|
||||
"yourStatistics": "Your Statistics",
|
||||
"totalPuzzles": "Total puzzles",
|
||||
"try": "try",
|
||||
"failed": "Failed"
|
||||
},
|
||||
"OnboardingTour": {
|
||||
"done": "Done",
|
||||
"next": "Next",
|
||||
"previous": "Previous",
|
||||
"genresSpecials": "Genres & Specials",
|
||||
"genresSpecialsDescription": "Choose a specific genre or a curated special event here.",
|
||||
"news": "News",
|
||||
"newsDescription": "Stay updated with the latest news and announcements.",
|
||||
"hoerdle": "Hördle",
|
||||
"hoerdleDescription": "This is the daily puzzle. One new song every day per genre.",
|
||||
"attempts": "Attempts",
|
||||
"attemptsDescription": "You have a limited number of attempts to guess the song.",
|
||||
"score": "Score",
|
||||
"scoreDescription": "Your current score. Try to keep it high!",
|
||||
"player": "Player",
|
||||
"playerDescription": "Listen to the snippet. Each additional play reduces your potential score.",
|
||||
"input": "Input",
|
||||
"inputDescription": "Type your guess here. Search for artist or title.",
|
||||
"controls": "Controls",
|
||||
"controlsDescription": "Start the music or skip to the next snippet if you're stuck."
|
||||
},
|
||||
"InstallPrompt": {
|
||||
"installApp": "Install Hördle App",
|
||||
"installDescription": "Install the app for a better experience and quick access!",
|
||||
"iosInstructions": "Tap",
|
||||
"iosShare": "share",
|
||||
"iosThen": "then \"Add to Home Screen\"",
|
||||
"installButton": "Install App"
|
||||
},
|
||||
"Home": {
|
||||
"welcome": "Welcome to Hördle",
|
||||
"subtitle": "Guess the song from short snippets",
|
||||
"globalTooltip": "A random song from the entire collection",
|
||||
"comingSoon": "Coming soon",
|
||||
"curatedBy": "Curated by"
|
||||
},
|
||||
"Admin": {
|
||||
"title": "Hördle Admin Dashboard",
|
||||
"login": "Admin Login",
|
||||
"password": "Password",
|
||||
"loginButton": "Login",
|
||||
"logout": "Logout",
|
||||
"manageSpecials": "Manage Specials",
|
||||
"manageGenres": "Manage Genres",
|
||||
"manageNews": "Manage News & Announcements",
|
||||
"uploadSongs": "Upload Songs",
|
||||
"todaysPuzzles": "Today's Daily Puzzles",
|
||||
"show": "▶ Show",
|
||||
"hide": "▼ Hide",
|
||||
"addSpecial": "Add Special",
|
||||
"addGenre": "Add Genre",
|
||||
"addNews": "Add News",
|
||||
"edit": "Edit",
|
||||
"delete": "Delete",
|
||||
"save": "Save",
|
||||
"cancel": "Cancel",
|
||||
"curate": "Curate",
|
||||
"name": "Name",
|
||||
"subtitle": "Subtitle",
|
||||
"maxAttempts": "Max Attempts",
|
||||
"unlockSteps": "Unlock Steps",
|
||||
"launchDate": "Launch Date",
|
||||
"endDate": "End Date",
|
||||
"curator": "Curator",
|
||||
"active": "Active",
|
||||
"newGenreName": "New Genre Name",
|
||||
"editSpecial": "Edit Special",
|
||||
"editGenre": "Edit Genre",
|
||||
"editNews": "Edit News",
|
||||
"newsTitle": "News Title",
|
||||
"content": "Content (Markdown supported)",
|
||||
"author": "Author (optional)",
|
||||
"featured": "Featured",
|
||||
"noSpecialLink": "No Special Link",
|
||||
"noNewsItems": "No news items yet. Create one above!",
|
||||
"noPuzzlesToday": "No daily puzzles found for today.",
|
||||
"category": "Category",
|
||||
"song": "Song",
|
||||
"artist": "Artist",
|
||||
"actions": "Actions",
|
||||
"deletePuzzle": "Delete",
|
||||
"wrongPassword": "Wrong password"
|
||||
},
|
||||
"About": {
|
||||
"title": "About Hördle & Imprint",
|
||||
@@ -162,7 +162,6 @@
|
||||
"imprintOperator": "Responsible for the content of this site (provider under German law):",
|
||||
"imprintCountry": "Germany",
|
||||
"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",
|
||||
"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)",
|
||||
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "hoerdle",
|
||||
"version": "0.1.0.15",
|
||||
"version": "0.1.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "hoerdle",
|
||||
"version": "0.1.0.15",
|
||||
"version": "0.1.2",
|
||||
"dependencies": {
|
||||
"@prisma/client": "^6.19.0",
|
||||
"bcryptjs": "^3.0.3",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "hoerdle",
|
||||
"version": "0.1.0.15",
|
||||
"version": "0.1.4.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
|
||||
35
proxy.ts
35
proxy.ts
@@ -2,9 +2,9 @@ import createMiddleware from 'next-intl/middleware';
|
||||
import type { NextRequest } from 'next/server';
|
||||
|
||||
const i18nMiddleware = createMiddleware({
|
||||
locales: ['de', 'en'],
|
||||
defaultLocale: 'de',
|
||||
// Wir nutzen überall Locale-Präfixe (`/de`, `/en`)
|
||||
locales: ['en', 'de'],
|
||||
defaultLocale: 'en',
|
||||
// Wir nutzen überall Locale-Präfixe (`/en`, `/de`)
|
||||
localePrefix: 'always'
|
||||
});
|
||||
|
||||
@@ -21,16 +21,41 @@ export default function proxy(request: NextRequest) {
|
||||
headers.set('Referrer-Policy', 'strict-origin-when-cross-origin');
|
||||
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 = [
|
||||
"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'",
|
||||
"img-src 'self' data: blob:",
|
||||
"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:",
|
||||
"frame-ancestors 'self'",
|
||||
].join('; ');
|
||||
|
||||
headers.set('Content-Security-Policy', csp);
|
||||
|
||||
return response;
|
||||
|
||||
94
scripts/check-caddy-certificates.sh
Executable file
94
scripts/check-caddy-certificates.sh
Executable file
@@ -0,0 +1,94 @@
|
||||
#!/bin/bash
|
||||
# Script zum Prüfen der Caddy-Zertifikate
|
||||
|
||||
set -e
|
||||
|
||||
echo "🔍 Prüfe Caddy-Zertifikat-Status..."
|
||||
echo ""
|
||||
|
||||
# Prüfe ob Caddy-Container läuft
|
||||
if ! docker ps | grep -q hoerdle-caddy; then
|
||||
echo "❌ Caddy-Container läuft nicht!"
|
||||
echo " Starte ihn mit: docker compose -f docker-compose.caddy.yml --profile production up -d"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "✅ Caddy-Container läuft"
|
||||
echo ""
|
||||
|
||||
# Prüfe Zertifikate im Volume
|
||||
echo "📜 Gespeicherte Zertifikate im Volume:"
|
||||
docker exec hoerdle-caddy find /data/caddy/certificates -name "*.crt" -o -name "*.key" 2>/dev/null | head -20 || echo " (Keine Zertifikate gefunden oder Fehler beim Zugriff)"
|
||||
echo ""
|
||||
|
||||
# Prüfe Caddy-Logs für Zertifikats-Fehler
|
||||
echo "📋 Letzte Caddy-Logs (Zertifikat-bezogen):"
|
||||
docker logs hoerdle-caddy 2>&1 | grep -i -E "(certificate|tls|acme|challenge|error)" | tail -20 || echo " (Keine relevanten Log-Einträge gefunden)"
|
||||
echo ""
|
||||
|
||||
# Prüfe DNS-Einträge
|
||||
echo "🌐 DNS-Einträge prüfen:"
|
||||
echo ""
|
||||
|
||||
# Prüfe hoerdle.de
|
||||
echo "1️⃣ hoerdle.de:"
|
||||
HOERDLE_IP=$(dig +short hoerdle.de @8.8.8.8 | head -1 || echo "DNS-Fehler")
|
||||
if [ -n "$HOERDLE_IP" ] && [ "$HOERDLE_IP" != "DNS-Fehler" ]; then
|
||||
echo " ✅ DNS-A-Record: $HOERDLE_IP"
|
||||
else
|
||||
echo " ❌ DNS-A-Record: nicht aufgelöst"
|
||||
fi
|
||||
|
||||
# Prüfe xn--hrdle-jua.de (Punycode)
|
||||
echo "2️⃣ xn--hrdle-jua.de (hördle.de):"
|
||||
PUNYCODE_IP=$(dig +short xn--hrdle-jua.de @8.8.8.8 | head -1 || echo "DNS-Fehler")
|
||||
if [ -n "$PUNYCODE_IP" ] && [ "$PUNYCODE_IP" != "DNS-Fehler" ]; then
|
||||
echo " ✅ DNS-A-Record: $PUNYCODE_IP"
|
||||
else
|
||||
echo " ❌ DNS-A-Record: nicht aufgelöst"
|
||||
fi
|
||||
|
||||
# Prüfe auch die Unicode-Domain direkt (falls unterstützt)
|
||||
echo "3️⃣ hördle.de (Unicode):"
|
||||
UNICODE_IP=$(dig +short hördle.de @8.8.8.8 2>/dev/null | head -1 || echo "Nicht unterstützt")
|
||||
if [ -n "$UNICODE_IP" ] && [ "$UNICODE_IP" != "Nicht unterstützt" ]; then
|
||||
echo " ✅ DNS-A-Record: $UNICODE_IP"
|
||||
else
|
||||
echo " ⚠️ Unicode-Domain-Abfrage nicht unterstützt (normal)"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "🔒 Zertifikat-Test:"
|
||||
echo ""
|
||||
|
||||
# Teste HTTPS-Verbindung zu hoerdle.de
|
||||
echo "1️⃣ hoerdle.de HTTPS:"
|
||||
if echo | timeout 5 openssl s_client -connect hoerdle.de:443 -servername hoerdle.de 2>/dev/null | grep -q "Verify return code: 0"; then
|
||||
echo " ✅ Zertifikat gültig"
|
||||
CERT_INFO=$(echo | timeout 5 openssl s_client -connect hoerdle.de:443 -servername hoerdle.de 2>/dev/null | openssl x509 -noout -subject -dates 2>/dev/null || echo "")
|
||||
echo " $CERT_INFO" | sed 's/^/ /'
|
||||
else
|
||||
echo " ❌ Zertifikat fehlt oder ungültig"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "2️⃣ hördle.de (xn--hrdle-jua.de) HTTPS:"
|
||||
if echo | timeout 5 openssl s_client -connect xn--hrdle-jua.de:443 -servername xn--hrdle-jua.de 2>/dev/null | grep -q "Verify return code: 0"; then
|
||||
echo " ✅ Zertifikat gültig"
|
||||
CERT_INFO=$(echo | timeout 5 openssl s_client -connect xn--hrdle-jua.de:443 -servername xn--hrdle-jua.de 2>/dev/null | openssl x509 -noout -subject -dates 2>/dev/null || echo "")
|
||||
echo " $CERT_INFO" | sed 's/^/ /'
|
||||
else
|
||||
echo " ❌ Zertifikat fehlt oder ungültig"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "📝 Zertifikat-Details aus Caddy Volume:"
|
||||
echo ""
|
||||
docker exec hoerdle-caddy ls -la /data/caddy/certificates/acme-v02.api.letsencrypt.org-directory/ 2>/dev/null | head -10 || echo " (Verzeichnis nicht gefunden oder leer)"
|
||||
|
||||
echo ""
|
||||
echo "💡 Tipps:"
|
||||
echo " - Wenn kein Zertifikat vorhanden ist, führe aus: ./scripts/renew-caddy-certificates.sh"
|
||||
echo " - Prüfe die Caddy-Logs: docker logs hoerdle-caddy"
|
||||
echo " - Prüfe DNS-Einträge in GoDaddy: https://dcc.godaddy.com/manage/hoerdle.de/dns"
|
||||
|
||||
65
scripts/check-db-permissions.sh
Executable file
65
scripts/check-db-permissions.sh
Executable file
@@ -0,0 +1,65 @@
|
||||
#!/bin/bash
|
||||
# Script zum Prüfen der Datenbank-Berechtigungen und User-Konfiguration
|
||||
|
||||
echo "🔍 Datenbank-Berechtigungen und User-Check"
|
||||
echo "=========================================="
|
||||
echo ""
|
||||
|
||||
# Prüfe User im Container
|
||||
echo "👤 User im Container:"
|
||||
docker exec hoerdle whoami
|
||||
echo ""
|
||||
|
||||
# Prüfe UID/GID
|
||||
echo "🆔 UID/GID des laufenden Prozesses:"
|
||||
docker exec hoerdle id
|
||||
echo ""
|
||||
|
||||
# Prüfe Datenbankdatei
|
||||
echo "💾 Datenbank-Datei-Informationen (im Container):"
|
||||
docker exec hoerdle ls -lh /app/data/prod.db
|
||||
echo ""
|
||||
|
||||
# Prüfe Datenbankverzeichnis
|
||||
echo "📁 Datenbankverzeichnis-Berechtigungen (im Container):"
|
||||
docker exec hoerdle ls -ld /app/data
|
||||
echo ""
|
||||
|
||||
# Prüfe ob Datei schreibbar ist
|
||||
echo "✍️ Schreibbarkeitstest:"
|
||||
if docker exec hoerdle sh -c "test -w /app/data/prod.db && echo '✅ Datei ist schreibbar' || echo '❌ Datei ist NICHT schreibbar'"; then
|
||||
echo " Datei ist schreibbar"
|
||||
else
|
||||
echo " ❌ Datei ist NICHT schreibbar!"
|
||||
fi
|
||||
|
||||
# Prüfe ob Verzeichnis schreibbar ist
|
||||
if docker exec hoerdle sh -c "test -w /app/data && echo '✅ Verzeichnis ist schreibbar' || echo '❌ Verzeichnis ist NICHT schreibbar'"; then
|
||||
echo " Verzeichnis ist schreibbar"
|
||||
else
|
||||
echo " ❌ Verzeichnis ist NICHT schreibbar!"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# Prüfe Host-Seite
|
||||
echo "🖥️ Host-Seite Berechtigungen:"
|
||||
ls -ld ./data
|
||||
ls -lh ./data/prod.db
|
||||
echo ""
|
||||
|
||||
# Prüfe Container-User aus docker-compose
|
||||
echo "🐳 Docker Compose Konfiguration:"
|
||||
if [ -f "docker-compose.yml" ]; then
|
||||
grep -E "^[[:space:]]*user:" docker-compose.yml || echo " Keine 'user:' Direktive gefunden"
|
||||
else
|
||||
echo " ⚠️ docker-compose.yml nicht gefunden (verwende example?)"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# Empfehlung
|
||||
echo "💡 Empfehlung:"
|
||||
echo " Wenn die Datei 'node:node' gehört und Container als 'root' läuft,"
|
||||
echo " sollte es funktionieren. Falls nicht, setze Besitzer auf root:"
|
||||
echo " sudo chown root:root ./data/prod.db"
|
||||
echo " Oder entferne 'user: root' aus docker-compose.yml"
|
||||
|
||||
99
scripts/debug-server-error.sh
Executable file
99
scripts/debug-server-error.sh
Executable file
@@ -0,0 +1,99 @@
|
||||
#!/bin/bash
|
||||
# Script zum Debuggen von Server-Errors in Hördle
|
||||
# Zeigt relevante Logs und Status-Informationen
|
||||
|
||||
set -e
|
||||
|
||||
echo "🔍 Hördle Server Error Debugging"
|
||||
echo "=================================="
|
||||
echo ""
|
||||
|
||||
# Container-Status prüfen
|
||||
echo "📦 Container-Status:"
|
||||
docker ps --filter "name=hoerdle" --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}"
|
||||
echo ""
|
||||
|
||||
# Prüfe ob Container läuft
|
||||
if ! docker ps | grep -q "hoerdle"; then
|
||||
echo "❌ FEHLER: hoerdle Container läuft nicht!"
|
||||
echo ""
|
||||
echo "Versuche Container zu starten..."
|
||||
docker compose up -d
|
||||
sleep 5
|
||||
echo ""
|
||||
fi
|
||||
|
||||
# Letzte Logs anzeigen
|
||||
echo "📋 Letzte 50 Zeilen der Container-Logs:"
|
||||
echo "----------------------------------------"
|
||||
docker logs --tail=50 hoerdle 2>&1 | tail -50
|
||||
echo ""
|
||||
echo ""
|
||||
|
||||
# Suche nach Fehlern in den Logs
|
||||
echo "🚨 Fehler in den Logs (letzte 100 Zeilen):"
|
||||
echo "----------------------------------------"
|
||||
docker logs --tail=100 hoerdle 2>&1 | grep -i -E "error|exception|failed|fatal|panic" || echo "Keine offensichtlichen Fehler gefunden"
|
||||
echo ""
|
||||
echo ""
|
||||
|
||||
# Container Health Status
|
||||
echo "💚 Health Check Status:"
|
||||
docker inspect hoerdle --format='{{json .State.Health}}' | python3 -m json.tool 2>/dev/null || docker inspect hoerdle --format='{{.State.Status}}'
|
||||
echo ""
|
||||
echo ""
|
||||
|
||||
# Prüfe ob der Server auf Port 3000 antwortet (intern)
|
||||
echo "🔌 Port-Verbindungstest (intern, Port 3000):"
|
||||
echo "----------------------------------------"
|
||||
docker exec hoerdle curl -f -s -o /dev/null -w "HTTP Status: %{http_code}\n" http://localhost:3000/api/daily 2>&1 || echo "❌ Verbindung fehlgeschlagen"
|
||||
echo ""
|
||||
echo ""
|
||||
|
||||
# Prüfe Datenbank
|
||||
echo "💾 Datenbank-Status:"
|
||||
echo "----------------------------------------"
|
||||
if docker exec hoerdle test -f /app/data/prod.db; then
|
||||
echo "✅ Datenbankdatei existiert"
|
||||
DB_SIZE=$(docker exec hoerdle stat -c%s /app/data/prod.db 2>/dev/null || echo "unbekannt")
|
||||
echo " Größe: $DB_SIZE Bytes"
|
||||
else
|
||||
echo "❌ Datenbankdatei nicht gefunden!"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# Prüfe Umgebungsvariablen (wichtige)
|
||||
echo "🔐 Wichtige Umgebungsvariablen:"
|
||||
echo "----------------------------------------"
|
||||
docker exec hoerdle env | grep -E "DATABASE_URL|NODE_ENV|PORT|HOSTNAME" || echo "Keine gefunden"
|
||||
echo ""
|
||||
|
||||
# Prüfe ob Next.js Server läuft
|
||||
echo "🌐 Next.js Prozess-Status:"
|
||||
echo "----------------------------------------"
|
||||
docker exec hoerdle ps aux | grep -E "node|next" | grep -v grep || echo "Keine Next.js Prozesse gefunden"
|
||||
echo ""
|
||||
|
||||
# Netzwerk-Verbindung prüfen
|
||||
echo "🌐 Netzwerk-Verbindungen:"
|
||||
echo "----------------------------------------"
|
||||
docker network inspect hoerdle_default --format='{{range .Containers}}{{.Name}}: {{.IPv4Address}}{{"\n"}}{{end}}' 2>/dev/null || echo "Netzwerk nicht gefunden"
|
||||
echo ""
|
||||
|
||||
# Caddy Status (falls vorhanden)
|
||||
if docker ps | grep -q "hoerdle-caddy"; then
|
||||
echo "🚪 Caddy-Container Status:"
|
||||
echo "----------------------------------------"
|
||||
docker logs --tail=20 hoerdle-caddy 2>&1 | tail -20
|
||||
echo ""
|
||||
fi
|
||||
|
||||
echo "=================================="
|
||||
echo "✅ Debug-Informationen gesammelt"
|
||||
echo ""
|
||||
echo "💡 Nächste Schritte:"
|
||||
echo "1. Prüfe die Fehler-Logs oben"
|
||||
echo "2. Prüfe ob die Datenbank erreichbar ist"
|
||||
echo "3. Prüfe ob alle Umgebungsvariablen gesetzt sind"
|
||||
echo "4. Bei weiteren Problemen: docker logs hoerdle --tail=200"
|
||||
|
||||
@@ -54,6 +54,19 @@ git pull
|
||||
echo "🏷️ Fetching git tags..."
|
||||
git fetch --tags
|
||||
|
||||
# Prüfe und erstelle/repariere Netzwerk falls nötig
|
||||
echo "🌐 Prüfe Docker-Netzwerk..."
|
||||
if ! docker network ls | grep -q "hoerdle_default"; then
|
||||
echo " Netzwerk existiert nicht, erstelle es..."
|
||||
docker network create hoerdle_default
|
||||
echo "✅ Netzwerk erstellt"
|
||||
else
|
||||
# Prüfe ob Netzwerk falsche Labels hat (wird durch external: true umgangen)
|
||||
echo "✅ Netzwerk existiert bereits"
|
||||
echo " (Hinweis: Falls Warnungen über falsche Labels erscheinen, verwende: ./scripts/fix-network.sh)"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# Build new image in background (doesn't stop running container)
|
||||
echo "🔨 Building new Docker image (this runs while app is still online)..."
|
||||
docker compose build
|
||||
|
||||
71
scripts/docker-cleanup.sh
Executable file
71
scripts/docker-cleanup.sh
Executable file
@@ -0,0 +1,71 @@
|
||||
#!/bin/bash
|
||||
# Docker Cleanup-Skript
|
||||
# Räumt nicht verwendete Docker-Images, Container, Volumes und Build-Cache auf
|
||||
|
||||
set -e
|
||||
|
||||
echo "🧹 Docker Cleanup..."
|
||||
echo ""
|
||||
|
||||
# Zeige aktuellen Speicherverbrauch
|
||||
echo "📊 Aktueller Docker-Speicherverbrauch:"
|
||||
docker system df
|
||||
echo ""
|
||||
|
||||
# Frage nach Bestätigung (falls interaktiv)
|
||||
if [ -t 0 ]; then
|
||||
echo "⚠️ Dies wird folgende Ressourcen entfernen:"
|
||||
echo " - Alle nicht verwendeten Images"
|
||||
echo " - Alle gestoppten Container"
|
||||
echo " - Alle nicht verwendeten Netzwerke"
|
||||
echo " - Build-Cache"
|
||||
echo ""
|
||||
echo "Möchtest du fortfahren? (j/n)"
|
||||
read -r response
|
||||
if [ "$response" != "j" ] && [ "$response" != "J" ]; then
|
||||
echo "❌ Abgebrochen."
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
|
||||
# 1. Entferne gestoppte Container
|
||||
echo "🗑️ Entferne gestoppte Container..."
|
||||
STOPPED_CONTAINERS=$(docker ps -a -q -f status=exited | wc -l)
|
||||
if [ "$STOPPED_CONTAINERS" -gt 0 ]; then
|
||||
docker container prune -f
|
||||
echo "✅ Gestoppte Container entfernt"
|
||||
else
|
||||
echo " Keine gestoppten Container gefunden"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# 2. Entferne nicht verwendete Images
|
||||
echo "🗑️ Entferne nicht verwendete Images..."
|
||||
docker image prune -a -f
|
||||
echo "✅ Nicht verwendete Images entfernt"
|
||||
echo ""
|
||||
|
||||
# 3. Entferne nicht verwendete Netzwerke
|
||||
echo "🗑️ Entferne nicht verwendete Netzwerke..."
|
||||
docker network prune -f
|
||||
echo "✅ Nicht verwendete Netzwerke entfernt"
|
||||
echo ""
|
||||
|
||||
# 4. Entferne Build-Cache (optional, kann lange dauern)
|
||||
echo "🗑️ Entferne Build-Cache..."
|
||||
docker builder prune -f
|
||||
echo "✅ Build-Cache entfernt"
|
||||
echo ""
|
||||
|
||||
# Zeige neuen Speicherverbrauch
|
||||
echo "📊 Neuer Docker-Speicherverbrauch:"
|
||||
docker system df
|
||||
echo ""
|
||||
|
||||
# Zeige verfügbaren Speicherplatz
|
||||
echo "💾 Verfügbarer Speicherplatz:"
|
||||
df -h / | tail -1 | awk '{print " Gesamt: " $2 ", Verfügbar: " $4 ", Belegt: " $5}'
|
||||
echo ""
|
||||
|
||||
echo "✅ Cleanup abgeschlossen!"
|
||||
|
||||
150
scripts/fix-i18n-local.sh
Executable file
150
scripts/fix-i18n-local.sh
Executable file
@@ -0,0 +1,150 @@
|
||||
#!/bin/bash
|
||||
# Fix für i18n-Daten: Kopiert DB lokal, fixt sie, kopiert zurück
|
||||
|
||||
set -e
|
||||
|
||||
echo "🔧 Fixe i18n-Daten (lokal kopieren, fixen, zurück kopieren)..."
|
||||
echo ""
|
||||
|
||||
# Prüfe ob Container läuft
|
||||
if ! docker ps | grep -q hoerdle; then
|
||||
echo "❌ Container 'hoerdle' läuft nicht!"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Backup erstellen
|
||||
BACKUP_FILE="./data/prod.db.backup.$(date +%Y%m%d_%H%M%S)"
|
||||
echo "💾 Erstelle Backup..."
|
||||
docker cp hoerdle:/app/data/prod.db "$BACKUP_FILE"
|
||||
# Setze Berechtigungen (kann root gehören)
|
||||
sudo chmod 666 "$BACKUP_FILE" 2>/dev/null || chmod 666 "$BACKUP_FILE" 2>/dev/null || true
|
||||
echo "✅ Backup erstellt: $BACKUP_FILE"
|
||||
echo ""
|
||||
|
||||
# Kopiere DB lokal
|
||||
echo "📥 Kopiere Datenbank lokal..."
|
||||
docker cp hoerdle:/app/data/prod.db ./data/prod.db.tmp
|
||||
# Setze Berechtigungen (Datei kann root gehören)
|
||||
sudo chmod 666 ./data/prod.db.tmp 2>/dev/null || chmod 666 ./data/prod.db.tmp 2>/dev/null || {
|
||||
echo "⚠️ Konnte Berechtigungen nicht setzen. Versuche mit sudo..."
|
||||
sudo chmod 666 ./data/prod.db.tmp
|
||||
}
|
||||
chmod 775 ./data 2>/dev/null || sudo chmod 775 ./data 2>/dev/null || true
|
||||
echo "✅ Datenbank kopiert"
|
||||
echo ""
|
||||
|
||||
# Prüfe ob sqlite3 verfügbar ist
|
||||
if ! command -v sqlite3 &> /dev/null; then
|
||||
echo "❌ sqlite3 ist nicht installiert!"
|
||||
echo " Installiere es mit: sudo apt-get install sqlite3"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Fixe die Datenbank
|
||||
echo "🔧 Fixe i18n-Daten..."
|
||||
# Stelle sicher, dass wir Schreibrechte haben (auch für WAL-Dateien)
|
||||
chmod 666 ./data/prod.db.tmp 2>/dev/null || sudo chmod 666 ./data/prod.db.tmp
|
||||
chmod 775 ./data 2>/dev/null || sudo chmod 775 ./data
|
||||
# Prüfe ob wir die Datei lesen können
|
||||
if [ ! -r "./data/prod.db.tmp" ]; then
|
||||
echo "❌ Kann Datenbankdatei nicht lesen. Setze Besitzer..."
|
||||
sudo chown $(whoami):$(whoami) ./data/prod.db.tmp || true
|
||||
fi
|
||||
|
||||
# Führe SQL-Befehle aus (mit sudo falls nötig)
|
||||
if [ -r "./data/prod.db.tmp" ] && [ -w "./data" ]; then
|
||||
sqlite3 ./data/prod.db.tmp << 'SQL'
|
||||
-- Fix Genre.name
|
||||
UPDATE Genre
|
||||
SET name = json_object('de', name, 'en', name)
|
||||
WHERE typeof(name) = 'text' AND name NOT LIKE '{%';
|
||||
|
||||
-- Fix Genre.subtitle
|
||||
UPDATE Genre
|
||||
SET subtitle = json_object('de', subtitle, 'en', subtitle)
|
||||
WHERE subtitle IS NOT NULL AND typeof(subtitle) = 'text' AND subtitle NOT LIKE '{%';
|
||||
|
||||
-- Fix Special.name
|
||||
UPDATE Special
|
||||
SET name = json_object('de', name, 'en', name)
|
||||
WHERE typeof(name) = 'text' AND name NOT LIKE '{%';
|
||||
|
||||
-- Fix Special.subtitle
|
||||
UPDATE Special
|
||||
SET subtitle = json_object('de', subtitle, 'en', subtitle)
|
||||
WHERE subtitle IS NOT NULL AND typeof(subtitle) = 'text' AND subtitle NOT LIKE '{%';
|
||||
|
||||
-- Fix News.title
|
||||
UPDATE News
|
||||
SET title = json_object('de', title, 'en', title)
|
||||
WHERE typeof(title) = 'text' AND title NOT LIKE '{%';
|
||||
|
||||
-- Fix News.content
|
||||
UPDATE News
|
||||
SET content = json_object('de', content, 'en', content)
|
||||
WHERE typeof(content) = 'text' AND content NOT LIKE '{%';
|
||||
|
||||
SELECT '✅ Alle i18n-Daten wurden gefixt!' as status;
|
||||
SQL
|
||||
else
|
||||
echo "❌ Kann Datenbankdatei nicht lesen oder schreiben!"
|
||||
echo " Versuche mit sudo..."
|
||||
sudo sqlite3 ./data/prod.db.tmp << 'SQL'
|
||||
-- Fix Genre.name
|
||||
UPDATE Genre
|
||||
SET name = json_object('de', name, 'en', name)
|
||||
WHERE typeof(name) = 'text' AND name NOT LIKE '{%';
|
||||
|
||||
-- Fix Genre.subtitle
|
||||
UPDATE Genre
|
||||
SET subtitle = json_object('de', subtitle, 'en', subtitle)
|
||||
WHERE subtitle IS NOT NULL AND typeof(subtitle) = 'text' AND subtitle NOT LIKE '{%';
|
||||
|
||||
-- Fix Special.name
|
||||
UPDATE Special
|
||||
SET name = json_object('de', name, 'en', name)
|
||||
WHERE typeof(name) = 'text' AND name NOT LIKE '{%';
|
||||
|
||||
-- Fix Special.subtitle
|
||||
UPDATE Special
|
||||
SET subtitle = json_object('de', subtitle, 'en', subtitle)
|
||||
WHERE subtitle IS NOT NULL AND typeof(subtitle) = 'text' AND subtitle NOT LIKE '{%';
|
||||
|
||||
-- Fix News.title
|
||||
UPDATE News
|
||||
SET title = json_object('de', title, 'en', title)
|
||||
WHERE typeof(title) = 'text' AND title NOT LIKE '{%';
|
||||
|
||||
-- Fix News.content
|
||||
UPDATE News
|
||||
SET content = json_object('de', content, 'en', content)
|
||||
WHERE typeof(content) = 'text' AND content NOT LIKE '{%';
|
||||
|
||||
SELECT '✅ Alle i18n-Daten wurden gefixt!' as status;
|
||||
SQL
|
||||
fi
|
||||
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "❌ Fehler beim Fixen der Datenbank!"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "✅ Datenbank gefixt"
|
||||
echo ""
|
||||
|
||||
# Kopiere zurück
|
||||
echo "📤 Kopiere gefixte Datenbank zurück..."
|
||||
docker cp ./data/prod.db.tmp hoerdle:/app/data/prod.db
|
||||
echo "✅ Datenbank zurück kopiert"
|
||||
echo ""
|
||||
|
||||
# Aufräumen
|
||||
rm -f ./data/prod.db.tmp ./data/prod.db.tmp.backup
|
||||
|
||||
echo "🔄 Starte Container neu..."
|
||||
docker compose restart hoerdle
|
||||
echo ""
|
||||
|
||||
echo "✅ Fertig! Prüfe die Logs:"
|
||||
echo " docker logs hoerdle --tail=50"
|
||||
|
||||
51
scripts/fix-network.sh
Executable file
51
scripts/fix-network.sh
Executable file
@@ -0,0 +1,51 @@
|
||||
#!/bin/bash
|
||||
# Script zum Reparieren des Docker-Netzwerks hoerdle_default
|
||||
# Dieses Script behebt die Warnung über falsche Netzwerk-Labels
|
||||
|
||||
set -e
|
||||
|
||||
echo "🔧 Repariere Docker-Netzwerk hoerdle_default..."
|
||||
|
||||
# Prüfe, ob Container laufen
|
||||
RUNNING_CONTAINERS=$(docker ps --filter "network=hoerdle_default" --format "{{.Names}}" | wc -l)
|
||||
|
||||
if [ "$RUNNING_CONTAINERS" -gt 0 ]; then
|
||||
echo "⚠️ Warnung: Es laufen noch Container, die das Netzwerk nutzen."
|
||||
echo "📋 Container, die betroffen sind:"
|
||||
docker ps --filter "network=hoerdle_default" --format " - {{.Names}}"
|
||||
echo ""
|
||||
echo "Möchtest du fortfahren? Die Container müssen neu gestartet werden. (j/n)"
|
||||
read -r response
|
||||
if [ "$response" != "j" ] && [ "$response" != "J" ]; then
|
||||
echo "❌ Abgebrochen."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "🛑 Stoppe Container..."
|
||||
docker compose down || true
|
||||
if [ -f "docker-compose.caddy.yml" ]; then
|
||||
docker compose -f docker-compose.caddy.yml down || true
|
||||
fi
|
||||
fi
|
||||
|
||||
# Prüfe, ob Netzwerk existiert
|
||||
if docker network ls | grep -q "hoerdle_default"; then
|
||||
echo "🗑️ Lösche altes Netzwerk..."
|
||||
docker network rm hoerdle_default || {
|
||||
echo "❌ Netzwerk konnte nicht gelöscht werden. Möglicherweise sind noch Container verbunden."
|
||||
echo " Versuche, alle Container zu trennen..."
|
||||
docker network disconnect hoerdle_default $(docker ps -q --filter "network=hoerdle_default") 2>/dev/null || true
|
||||
sleep 2
|
||||
docker network rm hoerdle_default || {
|
||||
echo "❌ Netzwerk konnte immer noch nicht gelöscht werden."
|
||||
echo " Bitte manuell prüfen: docker network inspect hoerdle_default"
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
fi
|
||||
|
||||
echo "✨ Netzwerk erfolgreich gelöscht."
|
||||
echo "📝 Das Netzwerk wird beim nächsten 'docker compose up' automatisch neu erstellt."
|
||||
echo ""
|
||||
echo "✅ Fertig! Du kannst jetzt 'docker compose up -d' ausführen."
|
||||
|
||||
110
scripts/quick-check-punycode-dns.sh
Executable file
110
scripts/quick-check-punycode-dns.sh
Executable file
@@ -0,0 +1,110 @@
|
||||
#!/bin/bash
|
||||
# Quick-Check für Punycode-Domain DNS und Zertifikat
|
||||
|
||||
set -e
|
||||
|
||||
echo "🔍 Quick-Check für hördle.de (xn--hrdle-jua.de)"
|
||||
echo ""
|
||||
|
||||
# Prüfe DNS
|
||||
echo "1️⃣ DNS-Auflösung:"
|
||||
echo ""
|
||||
echo " hoerdle.de:"
|
||||
HOERDLE_IP=$(dig +short hoerdle.de @8.8.8.8 | head -1 || echo "")
|
||||
if [ -z "$HOERDLE_IP" ]; then
|
||||
echo " ❌ Konnte nicht aufgelöst werden"
|
||||
else
|
||||
echo " ✅ IP: $HOERDLE_IP"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo " xn--hrdle-jua.de:"
|
||||
PUNYCODE_IP=$(dig +short xn--hrdle-jua.de @8.8.8.8 | head -1 || echo "")
|
||||
if [ -z "$PUNYCODE_IP" ]; then
|
||||
echo " ❌ Konnte nicht aufgelöst werden"
|
||||
else
|
||||
echo " ✅ IP: $PUNYCODE_IP"
|
||||
fi
|
||||
|
||||
if [ -n "$HOERDLE_IP" ] && [ -n "$PUNYCODE_IP" ]; then
|
||||
if [ "$HOERDLE_IP" = "$PUNYCODE_IP" ]; then
|
||||
echo ""
|
||||
echo " ✅ Beide Domains zeigen auf die gleiche IP ($HOERDLE_IP)"
|
||||
else
|
||||
echo ""
|
||||
echo " ⚠️ WARNUNG: Domains zeigen auf unterschiedliche IPs!"
|
||||
echo " hoerdle.de: $HOERDLE_IP"
|
||||
echo " xn--hrdle-jua.de: $PUNYCODE_IP"
|
||||
echo " → Beide sollten auf die gleiche IP zeigen!"
|
||||
fi
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "2️⃣ HTTPS-Verbindungstest:"
|
||||
echo ""
|
||||
|
||||
# Test hoerdle.de
|
||||
echo " hoerdle.de:"
|
||||
if timeout 5 bash -c "echo > /dev/tcp/hoerdle.de/443" 2>/dev/null; then
|
||||
echo " ✅ Port 443 ist erreichbar"
|
||||
if echo | timeout 5 openssl s_client -connect hoerdle.de:443 -servername hoerdle.de 2>/dev/null | grep -q "Verify return code: 0"; then
|
||||
CERT_VALID_UNTIL=$(echo | timeout 5 openssl s_client -connect hoerdle.de:443 -servername hoerdle.de 2>/dev/null | openssl x509 -noout -enddate 2>/dev/null | cut -d= -f2 || echo "")
|
||||
echo " ✅ Zertifikat gültig bis: $CERT_VALID_UNTIL"
|
||||
else
|
||||
echo " ❌ Zertifikat ungültig oder fehlt"
|
||||
fi
|
||||
else
|
||||
echo " ❌ Port 443 nicht erreichbar"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo " xn--hrdle-jua.de:"
|
||||
if timeout 5 bash -c "echo > /dev/tcp/xn--hrdle-jua.de/443" 2>/dev/null; then
|
||||
echo " ✅ Port 443 ist erreichbar"
|
||||
if echo | timeout 5 openssl s_client -connect xn--hrdle-jua.de:443 -servername xn--hrdle-jua.de 2>/dev/null | grep -q "Verify return code: 0"; then
|
||||
CERT_VALID_UNTIL=$(echo | timeout 5 openssl s_client -connect xn--hrdle-jua.de:443 -servername xn--hrdle-jua.de 2>/dev/null | openssl x509 -noout -enddate 2>/dev/null | cut -d= -f2 || echo "")
|
||||
echo " ✅ Zertifikat gültig bis: $CERT_VALID_UNTIL"
|
||||
else
|
||||
echo " ❌ Zertifikat ungültig oder fehlt (ERR_SSL_PROTOCOL_ERROR)"
|
||||
echo ""
|
||||
echo " 💡 Lösung: Führe aus: ./scripts/renew-caddy-certificates.sh"
|
||||
fi
|
||||
else
|
||||
echo " ❌ Port 443 nicht erreichbar"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "3️⃣ Caddy-Container Status:"
|
||||
echo ""
|
||||
if docker ps | grep -q hoerdle-caddy; then
|
||||
echo " ✅ Caddy-Container läuft"
|
||||
echo ""
|
||||
echo " 📋 Letzte Caddy-Logs (Zertifikat-bezogen):"
|
||||
docker logs hoerdle-caddy 2>&1 | grep -i -E "(xn--hrdle|punycode|certificate|tls|acme|challenge)" | tail -5 || echo " (Keine relevanten Einträge gefunden)"
|
||||
else
|
||||
echo " ❌ Caddy-Container läuft nicht"
|
||||
echo " Starte mit: docker compose -f docker-compose.caddy.yml --profile production up -d"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
echo ""
|
||||
echo "💡 Nächste Schritte:"
|
||||
echo ""
|
||||
if [ -n "$HOERDLE_IP" ] && [ -n "$PUNYCODE_IP" ] && [ "$HOERDLE_IP" != "$PUNYCODE_IP" ]; then
|
||||
echo " 1. ❗ DNS-Konfiguration prüfen!"
|
||||
echo " Beide Domains müssen auf die gleiche IP zeigen."
|
||||
echo " Prüfe in GoDaddy: https://dcc.godaddy.com/manage/hoerdle.de/dns"
|
||||
echo ""
|
||||
fi
|
||||
|
||||
echo " 2. Wenn DNS korrekt ist, Zertifikat neu erstellen:"
|
||||
echo " ./scripts/renew-caddy-certificates.sh"
|
||||
echo ""
|
||||
echo " 3. Caddy-Logs überwachen:"
|
||||
echo " docker logs hoerdle-caddy -f"
|
||||
echo ""
|
||||
echo " 4. Detaillierte Diagnose:"
|
||||
echo " ./scripts/check-caddy-certificates.sh"
|
||||
echo ""
|
||||
|
||||
47
scripts/quick-fix-db.sh
Executable file
47
scripts/quick-fix-db.sh
Executable file
@@ -0,0 +1,47 @@
|
||||
#!/bin/bash
|
||||
# Quick-Fix für Datenbank-Berechtigungen
|
||||
|
||||
set -e
|
||||
|
||||
echo "🔧 Quick-Fix für Datenbank-Berechtigungen..."
|
||||
echo ""
|
||||
|
||||
cd "$(dirname "$0")/.." || exit 1
|
||||
|
||||
# Setze Berechtigungen
|
||||
echo "1️⃣ Setze Berechtigungen..."
|
||||
chmod 775 ./data
|
||||
chmod 664 ./data/prod.db 2>/dev/null || echo " ⚠️ Datenbankdatei nicht gefunden"
|
||||
|
||||
# Setze Besitzer auf root (Container läuft als root)
|
||||
echo "2️⃣ Setze Besitzer auf root..."
|
||||
sudo chown -R root:root ./data
|
||||
|
||||
# Zeige aktuelle Berechtigungen
|
||||
echo ""
|
||||
echo "✅ Berechtigungen gesetzt!"
|
||||
echo ""
|
||||
echo "📋 Aktuelle Berechtigungen:"
|
||||
ls -ld ./data
|
||||
ls -lh ./data/prod.db 2>/dev/null || echo " (Datei nicht gefunden)"
|
||||
echo ""
|
||||
|
||||
# Teste im Container
|
||||
echo "3️⃣ Teste Zugriff im Container..."
|
||||
if docker ps | grep -q hoerdle; then
|
||||
echo " Container läuft, teste Zugriff..."
|
||||
docker exec hoerdle sh -c "test -r /app/data/prod.db && echo '✅ Lesbar' || echo '❌ Nicht lesbar'"
|
||||
docker exec hoerdle sh -c "test -w /app/data/prod.db && echo '✅ Schreibbar' || echo '❌ Nicht schreibbar'"
|
||||
docker exec hoerdle sh -c "test -w /app/data && echo '✅ Verzeichnis schreibbar' || echo '❌ Verzeichnis nicht schreibbar'"
|
||||
else
|
||||
echo " ⚠️ Container läuft nicht"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "🔄 Starte Container neu..."
|
||||
docker compose restart hoerdle 2>/dev/null || echo " ⚠️ Konnte Container nicht neustarten (vielleicht läuft docker compose nicht?)"
|
||||
|
||||
echo ""
|
||||
echo "✅ Fertig! Prüfe jetzt die Logs:"
|
||||
echo " docker logs hoerdle --tail=50"
|
||||
|
||||
83
scripts/renew-caddy-certificates.sh
Executable file
83
scripts/renew-caddy-certificates.sh
Executable file
@@ -0,0 +1,83 @@
|
||||
#!/bin/bash
|
||||
# Script zum Erneuern/Löschen und Neu-Erstellen von Caddy-Zertifikaten
|
||||
|
||||
set -e
|
||||
|
||||
echo "🔄 Caddy-Zertifikat-Erneuerung"
|
||||
echo ""
|
||||
echo "⚠️ WICHTIG: Dieses Script wird die Zertifikate löschen und Caddy zwingen, sie neu zu erstellen."
|
||||
echo ""
|
||||
|
||||
# Prüfe ob Caddy-Container läuft
|
||||
if ! docker ps | grep -q hoerdle-caddy; then
|
||||
echo "❌ Caddy-Container läuft nicht!"
|
||||
echo " Starte ihn mit: docker compose -f docker-compose.caddy.yml --profile production up -d"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "📋 Verfügbare Optionen:"
|
||||
echo " 1) Alle Zertifikate löschen und neu erstellen (empfohlen bei Problemen)"
|
||||
echo " 2) Nur Zertifikat für xn--hrdle-jua.de löschen (Punycode-Domain)"
|
||||
echo " 3) Nur Zertifikat für hoerdle.de löschen"
|
||||
echo " 4) Abbrechen"
|
||||
echo ""
|
||||
read -p "Wähle eine Option (1-4): " choice
|
||||
|
||||
case $choice in
|
||||
1)
|
||||
echo ""
|
||||
echo "🗑️ Lösche ALLE Zertifikate..."
|
||||
docker exec hoerdle-caddy rm -rf /data/caddy/certificates/acme-v02.api.letsencrypt.org-directory/* 2>/dev/null || true
|
||||
echo "✅ Alle Zertifikate gelöscht"
|
||||
;;
|
||||
2)
|
||||
echo ""
|
||||
echo "🗑️ Lösche Zertifikat für xn--hrdle-jua.de..."
|
||||
docker exec hoerdle-caddy find /data/caddy/certificates -name "*xn--hrdle-jua.de*" -delete 2>/dev/null || true
|
||||
docker exec hoerdle-caddy find /data/caddy/certificates -name "*xn--*" -delete 2>/dev/null || true
|
||||
echo "✅ Zertifikat für Punycode-Domain gelöscht"
|
||||
;;
|
||||
3)
|
||||
echo ""
|
||||
echo "🗑️ Lösche Zertifikat für hoerdle.de..."
|
||||
docker exec hoerdle-caddy find /data/caddy/certificates -name "*hoerdle.de*" ! -name "*xn--*" -delete 2>/dev/null || true
|
||||
echo "✅ Zertifikat für hoerdle.de gelöscht"
|
||||
;;
|
||||
4)
|
||||
echo "❌ Abgebrochen."
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "❌ Ungültige Option. Abgebrochen."
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
echo ""
|
||||
echo "🔄 Starte Caddy-Container neu, um Zertifikate neu zu erstellen..."
|
||||
docker compose -f docker-compose.caddy.yml --profile production restart caddy
|
||||
|
||||
echo ""
|
||||
echo "⏳ Warte 5 Sekunden, damit Caddy startet..."
|
||||
sleep 5
|
||||
|
||||
echo ""
|
||||
echo "📋 Prüfe Caddy-Logs auf Zertifikats-Erstellung:"
|
||||
echo ""
|
||||
docker logs hoerdle-caddy --tail=30 2>&1 | grep -i -E "(certificate|tls|acme|challenge|error|success)" || echo " (Keine relevanten Log-Einträge in den letzten 30 Zeilen)"
|
||||
|
||||
echo ""
|
||||
echo "✅ Container wurde neu gestartet."
|
||||
echo ""
|
||||
echo "💡 Nächste Schritte:"
|
||||
echo " 1. Warte 1-2 Minuten, damit Caddy die Zertifikate erstellt"
|
||||
echo " 2. Prüfe die Logs: docker logs hoerdle-caddy -f"
|
||||
echo " 3. Prüfe den Status: ./scripts/check-caddy-certificates.sh"
|
||||
echo " 4. Teste die Domain: curl -I https://xn--hrdle-jua.de"
|
||||
echo ""
|
||||
echo "⚠️ Falls das Zertifikat nicht erstellt wird:"
|
||||
echo " - Prüfe DNS-Einträge: Beide Domains müssen auf die Server-IP zeigen"
|
||||
echo " - Prüfe Port 80: Muss von außen erreichbar sein (für HTTP-01 Challenge)"
|
||||
echo " - Prüfe Firewall: Ports 80 und 443 müssen offen sein"
|
||||
echo " - Prüfe Caddy-Logs: docker logs hoerdle-caddy"
|
||||
|
||||
Reference in New Issue
Block a user