diff --git a/.env.example b/.env.example index 953e3eb..daf8546 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,7 @@ DATABASE_URL="postgresql://postgres:postgres@db:5432/moh_sass?schema=public" NEXT_PUBLIC_APP_URL="https://mohfarawati.de" +NEXT_PUBLIC_SITE_URL="https://mohfarawati.de" +NEXT_PUBLIC_ADMIN_URL="https://root.mohfarawati.de" NEXT_TELEMETRY_DISABLED="1" ADMIN_PASSWORD="123Yolo!321" ADMIN_AUTH_SECRET="Us0z76jwlTQLOeQWAGGxAxDcc0rHwp4q" diff --git a/app/_admin/layout.tsx b/app/_admin/layout.tsx new file mode 100644 index 0000000..aaa652e --- /dev/null +++ b/app/_admin/layout.tsx @@ -0,0 +1,2 @@ +export { metadata } from "../root/layout"; +export { default } from "../root/layout"; diff --git a/app/_admin/maintenance/page.tsx b/app/_admin/maintenance/page.tsx new file mode 100644 index 0000000..b100301 --- /dev/null +++ b/app/_admin/maintenance/page.tsx @@ -0,0 +1,2 @@ +export { dynamic } from "../../root/maintenance/page"; +export { default } from "../../root/maintenance/page"; diff --git a/app/_admin/marquee/page.tsx b/app/_admin/marquee/page.tsx new file mode 100644 index 0000000..292ef2f --- /dev/null +++ b/app/_admin/marquee/page.tsx @@ -0,0 +1 @@ +export { default } from "../../root/marquee/page"; diff --git a/app/_admin/media/page.tsx b/app/_admin/media/page.tsx new file mode 100644 index 0000000..966e62f --- /dev/null +++ b/app/_admin/media/page.tsx @@ -0,0 +1,2 @@ +export { dynamic } from "../../root/media/page"; +export { default } from "../../root/media/page"; diff --git a/app/_admin/page.tsx b/app/_admin/page.tsx new file mode 100644 index 0000000..d087c07 --- /dev/null +++ b/app/_admin/page.tsx @@ -0,0 +1,2 @@ +export { dynamic } from "../root/page"; +export { default } from "../root/page"; diff --git a/app/_admin/portfolio/categories/page.tsx b/app/_admin/portfolio/categories/page.tsx new file mode 100644 index 0000000..9b60cd3 --- /dev/null +++ b/app/_admin/portfolio/categories/page.tsx @@ -0,0 +1 @@ +export { default } from "../../../root/portfolio/categories/page"; diff --git a/app/_admin/portfolio/media/page.tsx b/app/_admin/portfolio/media/page.tsx new file mode 100644 index 0000000..732fa29 --- /dev/null +++ b/app/_admin/portfolio/media/page.tsx @@ -0,0 +1 @@ +export { default } from "../../../root/portfolio/media/page"; diff --git a/app/_admin/portfolio/page.tsx b/app/_admin/portfolio/page.tsx new file mode 100644 index 0000000..9b44930 --- /dev/null +++ b/app/_admin/portfolio/page.tsx @@ -0,0 +1 @@ +export { default } from "../../root/portfolio/page"; diff --git a/app/_admin/portfolio/projects/[id]/page.tsx b/app/_admin/portfolio/projects/[id]/page.tsx new file mode 100644 index 0000000..37a898d --- /dev/null +++ b/app/_admin/portfolio/projects/[id]/page.tsx @@ -0,0 +1 @@ +export { default } from "../../../../root/portfolio/projects/[id]/page"; diff --git a/app/_admin/portfolio/projects/new/page.tsx b/app/_admin/portfolio/projects/new/page.tsx new file mode 100644 index 0000000..60a3a51 --- /dev/null +++ b/app/_admin/portfolio/projects/new/page.tsx @@ -0,0 +1 @@ +export { default } from "../../../../root/portfolio/projects/new/page"; diff --git a/app/_admin/portfolio/projects/page.tsx b/app/_admin/portfolio/projects/page.tsx new file mode 100644 index 0000000..88752dd --- /dev/null +++ b/app/_admin/portfolio/projects/page.tsx @@ -0,0 +1 @@ +export { default } from "../../../root/portfolio/projects/page"; diff --git a/app/_admin/site-settings/page.tsx b/app/_admin/site-settings/page.tsx new file mode 100644 index 0000000..01d04ea --- /dev/null +++ b/app/_admin/site-settings/page.tsx @@ -0,0 +1 @@ +export { default } from "../../root/site-settings/page"; diff --git a/app/_admin/smtp/contact-protection/page.tsx b/app/_admin/smtp/contact-protection/page.tsx new file mode 100644 index 0000000..34e80a5 --- /dev/null +++ b/app/_admin/smtp/contact-protection/page.tsx @@ -0,0 +1 @@ +export { default } from "../../../root/smtp/contact-protection/page"; diff --git a/app/_admin/smtp/page.tsx b/app/_admin/smtp/page.tsx new file mode 100644 index 0000000..8c935aa --- /dev/null +++ b/app/_admin/smtp/page.tsx @@ -0,0 +1 @@ +export { default } from "../../root/smtp/page"; diff --git a/app/_admin/ui-kit/page.tsx b/app/_admin/ui-kit/page.tsx new file mode 100644 index 0000000..a9d5ab1 --- /dev/null +++ b/app/_admin/ui-kit/page.tsx @@ -0,0 +1 @@ +export { default } from "../../root/ui-kit/page"; diff --git a/app/robots.ts b/app/robots.ts index 6525321..a5b7c47 100644 --- a/app/robots.ts +++ b/app/robots.ts @@ -7,7 +7,7 @@ export default function robots(): MetadataRoute.Robots { rules: [ { userAgent: "*", - disallow: ["/root"], + disallow: ["/_admin"], }, ], sitemap: new URL("/sitemap.xml", siteUrl).toString(), diff --git a/app/root/maintenance/actions.ts b/app/root/maintenance/actions.ts index 8d213eb..d4ebfd3 100644 --- a/app/root/maintenance/actions.ts +++ b/app/root/maintenance/actions.ts @@ -4,6 +4,7 @@ import { revalidatePath } from "next/cache"; import { redirect } from "next/navigation"; import { routing } from "@/i18n/routing"; +import { toInternalAdminPath } from "@/lib/admin-routing"; import { clearAdminSessionCookie, isAdminAuthenticated } from "@/lib/admin-auth"; import { getLocalizedPath } from "@/lib/locale"; import { setMaintenanceMode } from "@/lib/app-config"; @@ -11,7 +12,7 @@ import { setMaintenanceMode } from "@/lib/app-config"; function ensureAdmin() { if (!isAdminAuthenticated()) { clearAdminSessionCookie(); - redirect("/root"); + redirect("/"); } } @@ -19,7 +20,7 @@ export async function updateMaintenanceModeAction(formData: FormData) { ensureAdmin(); const nextValue = formData.get("enabled") === "true"; - const redirectPath = String(formData.get("redirectPath") ?? "/root"); + const redirectPath = String(formData.get("redirectPath") ?? "/"); const redirectUrl = new URL(redirectPath, "http://localhost"); redirectUrl.searchParams.set( "success", @@ -29,9 +30,9 @@ export async function updateMaintenanceModeAction(formData: FormData) { await setMaintenanceMode(nextValue); revalidatePath("/", "layout"); revalidatePath("/coming-soon"); - revalidatePath("/root"); - revalidatePath("/root/maintenance"); - revalidatePath(redirectPath); + revalidatePath(toInternalAdminPath("/")); + revalidatePath(toInternalAdminPath("/maintenance")); + revalidatePath(toInternalAdminPath(redirectUrl.pathname)); for (const appLocale of routing.locales) { revalidatePath(getLocalizedPath(appLocale), "layout"); diff --git a/app/root/maintenance/page.tsx b/app/root/maintenance/page.tsx index 0139cf4..e5e4342 100644 --- a/app/root/maintenance/page.tsx +++ b/app/root/maintenance/page.tsx @@ -31,7 +31,7 @@ export default async function RootMaintenancePage() { const authenticated = isAdminAuthenticated(); if (!authenticated) { - redirect("/root"); + redirect("/"); } const maintenanceEnabled = await getMaintenanceMode(); @@ -39,7 +39,7 @@ export default async function RootMaintenancePage() { "use server"; clearAdminSessionCookie(); - redirect("/root"); + redirect("/"); } return ( diff --git a/app/root/marquee/actions.ts b/app/root/marquee/actions.ts index eea05ad..26365d4 100644 --- a/app/root/marquee/actions.ts +++ b/app/root/marquee/actions.ts @@ -4,6 +4,7 @@ import { revalidatePath } from "next/cache"; import { redirect } from "next/navigation"; import { isRedirectError } from "next/dist/client/components/redirect"; +import { toInternalAdminPath } from "@/lib/admin-routing"; import { clearAdminSessionCookie, isAdminAuthenticated } from "@/lib/admin-auth"; import { updateMarqueeSettings } from "@/lib/app-config"; import { routing } from "@/i18n/routing"; @@ -12,7 +13,7 @@ import { getLocalizedPath } from "@/lib/locale"; function ensureAdmin() { if (!isAdminAuthenticated()) { clearAdminSessionCookie(); - redirect("/root"); + redirect("/"); } } @@ -24,8 +25,8 @@ function withMessage(pathname: string, type: "success" | "error", message: strin } async function revalidateMarqueePages() { - revalidatePath("/root"); - revalidatePath("/root/marquee"); + revalidatePath(toInternalAdminPath("/")); + revalidatePath(toInternalAdminPath("/marquee")); for (const locale of routing.locales) { revalidatePath(getLocalizedPath(locale), "layout"); @@ -71,13 +72,13 @@ export async function saveMarqueeSettingsAction(formData: FormData) { await updateMarqueeSettings(settings); await revalidateMarqueePages(); - redirect(withMessage("/root/marquee", "success", "Marquee gespeichert.")); + redirect(withMessage("/marquee", "success", "Marquee gespeichert.")); } catch (error) { if (isRedirectError(error)) { throw error; } const message = error instanceof Error ? error.message : "Marquee konnte nicht gespeichert werden."; - redirect(withMessage("/root/marquee", "error", message)); + redirect(withMessage("/marquee", "error", message)); } } diff --git a/app/root/marquee/page.tsx b/app/root/marquee/page.tsx index cca3c52..4aaff81 100644 --- a/app/root/marquee/page.tsx +++ b/app/root/marquee/page.tsx @@ -27,14 +27,14 @@ const copy = { export default async function RootMarqueePage() { if (!isAdminAuthenticated()) { - redirect("/root"); + redirect("/"); } async function logoutAction() { "use server"; clearAdminSessionCookie(); - redirect("/root"); + redirect("/"); } const marqueeSettings = await getMarqueeSettings(); diff --git a/app/root/media/actions.ts b/app/root/media/actions.ts index d98acf8..b655c6a 100644 --- a/app/root/media/actions.ts +++ b/app/root/media/actions.ts @@ -5,6 +5,7 @@ import { revalidatePath } from "next/cache"; import { redirect } from "next/navigation"; import { isRedirectError } from "next/dist/client/components/redirect"; +import { toInternalAdminPath } from "@/lib/admin-routing"; import { clearAdminSessionCookie, isAdminAuthenticated } from "@/lib/admin-auth"; import { countMediaUsageReferences, getMediaAssetById } from "@/lib/media"; import { createStandaloneMediaAsset, deleteMediaAssetAndFile } from "@/lib/media-service"; @@ -14,7 +15,7 @@ import { prisma } from "@/lib/prisma"; function ensureAdmin() { if (!isAdminAuthenticated()) { clearAdminSessionCookie(); - redirect("/root"); + redirect("/"); } } @@ -26,10 +27,10 @@ function withMessage(pathname: string, type: "success" | "error", message: strin } function revalidateMediaPages() { - revalidatePath("/root"); - revalidatePath("/root/media"); - revalidatePath("/root/portfolio"); - revalidatePath("/root/portfolio/projects"); + revalidatePath(toInternalAdminPath("/")); + revalidatePath(toInternalAdminPath("/media")); + revalidatePath(toInternalAdminPath("/portfolio")); + revalidatePath(toInternalAdminPath("/portfolio/projects")); } export async function createMediaAssetAction(formData: FormData) { @@ -46,14 +47,14 @@ export async function createMediaAssetAction(formData: FormData) { }); revalidateMediaPages(); - redirect(withMessage("/root/media", "success", "Datei gespeichert.")); + redirect(withMessage("/media", "success", "Datei gespeichert.")); } catch (error) { if (isRedirectError(error)) { throw error; } const message = error instanceof Error ? error.message : "Datei konnte nicht gespeichert werden."; - redirect(withMessage("/root/media", "error", message)); + redirect(withMessage("/media", "error", message)); } } @@ -66,13 +67,13 @@ export async function deleteMediaAssetAction(formData: FormData) { const asset = await getMediaAssetById(assetId); if (!asset) { - redirect(withMessage("/root/media", "error", "Datei nicht gefunden.")); + redirect(withMessage("/media", "error", "Datei nicht gefunden.")); } const usageCount = await countMediaUsageReferences(asset.id); if (usageCount > 0) { - redirect(withMessage("/root/media", "error", "Datei wird noch verwendet.")); + redirect(withMessage("/media", "error", "Datei wird noch verwendet.")); } await prisma.mediaAsset.delete({ @@ -89,13 +90,13 @@ export async function deleteMediaAssetAction(formData: FormData) { } revalidateMediaPages(); - redirect(withMessage("/root/media", "success", "Datei geloescht.")); + redirect(withMessage("/media", "success", "Datei geloescht.")); } catch (error) { if (isRedirectError(error)) { throw error; } const message = error instanceof Error ? error.message : "Datei konnte nicht geloescht werden."; - redirect(withMessage("/root/media", "error", message)); + redirect(withMessage("/media", "error", message)); } } diff --git a/app/root/media/page.tsx b/app/root/media/page.tsx index 3047fb0..1f4fa7e 100644 --- a/app/root/media/page.tsx +++ b/app/root/media/page.tsx @@ -22,14 +22,14 @@ const copy = { export default async function RootMediaPage() { if (!isAdminAuthenticated()) { - redirect("/root"); + redirect("/"); } async function logoutAction() { "use server"; clearAdminSessionCookie(); - redirect("/root"); + redirect("/"); } const mediaAssets = await getAdminMediaAssets(); diff --git a/app/root/page.tsx b/app/root/page.tsx index 03ee884..ef5b468 100644 --- a/app/root/page.tsx +++ b/app/root/page.tsx @@ -75,28 +75,28 @@ export default async function RootPage({ searchParams }: RootPageProps) { const currentLockState = getAdminLockState(); if (currentLockState.locked) { - redirect("/root?error=locked"); + redirect("/?error=locked"); } if (!isAdminAuthConfigured() || !isPasswordValid(password)) { const failState = registerFailedAdminAttempt(); if (failState.locked) { - redirect("/root?error=locked"); + redirect("/?error=locked"); } - redirect("/root?error=invalid"); + redirect("/?error=invalid"); } resetAdminFailedAttempts(); setAdminSessionCookie(); - redirect("/root"); + redirect("/"); } async function logoutAction() { "use server"; clearAdminSessionCookie(); - redirect("/root"); + redirect("/"); } if (!authenticated) { diff --git a/app/root/portfolio/actions.ts b/app/root/portfolio/actions.ts index f89241c..288f044 100644 --- a/app/root/portfolio/actions.ts +++ b/app/root/portfolio/actions.ts @@ -7,6 +7,7 @@ import { isRedirectError } from "next/dist/client/components/redirect"; import { ZodError } from "zod"; import { routing } from "@/i18n/routing"; +import { toInternalAdminPath } from "@/lib/admin-routing"; import { clearAdminSessionCookie, isAdminAuthenticated } from "@/lib/admin-auth"; import { deleteEntityMediaUsages, replaceEntityMediaUsages } from "@/lib/media"; import { resolveMediaSelection } from "@/lib/media-service"; @@ -25,7 +26,7 @@ import { function ensureAdmin() { if (!isAdminAuthenticated()) { clearAdminSessionCookie(); - redirect("/root"); + redirect("/"); } } @@ -85,11 +86,11 @@ function parseZodError(error: ZodError) { } async function revalidatePortfolioPages() { - revalidatePath("/root"); - revalidatePath("/root/media"); - revalidatePath("/root/portfolio"); - revalidatePath("/root/portfolio/categories"); - revalidatePath("/root/portfolio/projects"); + revalidatePath(toInternalAdminPath("/")); + revalidatePath(toInternalAdminPath("/media")); + revalidatePath(toInternalAdminPath("/portfolio")); + revalidatePath(toInternalAdminPath("/portfolio/categories")); + revalidatePath(toInternalAdminPath("/portfolio/projects")); revalidatePath("/portfolio"); for (const locale of routing.locales) { @@ -106,7 +107,7 @@ async function removeManagedPaths(paths: string[]) { export async function upsertCategoryAction(formData: FormData) { ensureAdmin(); - const redirectPath = getRedirectPath(formData, "/root/portfolio/categories"); + const redirectPath = getRedirectPath(formData, "/portfolio/categories"); try { const parsed = categoryInputSchema.parse({ @@ -156,7 +157,7 @@ export async function upsertCategoryAction(formData: FormData) { export async function deleteCategoryAction(formData: FormData) { ensureAdmin(); - const redirectPath = getRedirectPath(formData, "/root/portfolio/categories"); + const redirectPath = getRedirectPath(formData, "/portfolio/categories"); const id = String(formData.get("id") ?? ""); try { @@ -191,8 +192,8 @@ export async function saveProjectAction(formData: FormData) { ensureAdmin(); const fallbackRedirect = String(formData.get("id") ?? "").trim() - ? `/root/portfolio/projects/${String(formData.get("id") ?? "").trim()}` - : "/root/portfolio/projects/new"; + ? `/portfolio/projects/${String(formData.get("id") ?? "").trim()}` + : "/portfolio/projects/new"; const redirectPath = getRedirectPath(formData, fallbackRedirect); const uploadedPaths: string[] = []; const createdMediaAssetIds: string[] = []; @@ -517,7 +518,7 @@ export async function saveProjectAction(formData: FormData) { }); await revalidatePortfolioPages(); - revalidatePath(`/root/portfolio/projects/${projectResult.project.id}`); + revalidatePath(toInternalAdminPath(`/portfolio/projects/${projectResult.project.id}`)); revalidatePath(`/portfolio/${projectResult.project.slug}`); for (const locale of routing.locales) { @@ -525,7 +526,7 @@ export async function saveProjectAction(formData: FormData) { } redirect( - withMessage(`/root/portfolio/projects/${projectResult.project.id}`, "success", "Projekt gespeichert."), + withMessage(`/portfolio/projects/${projectResult.project.id}`, "success", "Projekt gespeichert."), ); } catch (error) { if (isRedirectError(error)) { @@ -578,7 +579,7 @@ export async function deleteProjectAction(formData: FormData) { }); if (!project) { - redirect(withMessage("/root/portfolio", "error", "Project not found.")); + redirect(withMessage("/portfolio", "error", "Project not found.")); } await prisma.portfolioProject.delete({ @@ -595,12 +596,12 @@ export async function deleteProjectAction(formData: FormData) { revalidatePath(getLocalizedPath(locale, `/portfolio/${project.slug}`)); } - redirect(withMessage("/root/portfolio", "success", "Project deleted.")); + redirect(withMessage("/portfolio", "success", "Project deleted.")); } catch (error) { if (isRedirectError(error)) { throw error; } - redirect(withMessage("/root/portfolio", "error", "Unable to delete project.")); + redirect(withMessage("/portfolio", "error", "Unable to delete project.")); } } diff --git a/app/root/portfolio/categories/page.tsx b/app/root/portfolio/categories/page.tsx index c1c4858..2455d7b 100644 --- a/app/root/portfolio/categories/page.tsx +++ b/app/root/portfolio/categories/page.tsx @@ -24,14 +24,14 @@ const copy = { export default async function RootPortfolioCategoriesPage() { if (!isAdminAuthenticated()) { - redirect("/root"); + redirect("/"); } async function logoutAction() { "use server"; clearAdminSessionCookie(); - redirect("/root"); + redirect("/"); } const categories = await getAdminPortfolioCategories(); diff --git a/app/root/portfolio/media/page.tsx b/app/root/portfolio/media/page.tsx index 478883e..3d84c73 100644 --- a/app/root/portfolio/media/page.tsx +++ b/app/root/portfolio/media/page.tsx @@ -1,5 +1,5 @@ import { redirect } from "next/navigation"; export default function RootPortfolioMediaRedirectPage() { - redirect("/root/media"); + redirect("/media"); } diff --git a/app/root/portfolio/page.tsx b/app/root/portfolio/page.tsx index 1d71781..91104c5 100644 --- a/app/root/portfolio/page.tsx +++ b/app/root/portfolio/page.tsx @@ -31,14 +31,14 @@ type RootPortfolioPageProps = { export default async function RootPortfolioPage({ searchParams }: RootPortfolioPageProps) { if (!isAdminAuthenticated()) { - redirect("/root"); + redirect("/"); } async function logoutAction() { "use server"; clearAdminSessionCookie(); - redirect("/root"); + redirect("/"); } const selectedCategory = searchParams?.category && searchParams.category !== "__all__" diff --git a/app/root/portfolio/projects/[id]/page.tsx b/app/root/portfolio/projects/[id]/page.tsx index 8ebca64..1023886 100644 --- a/app/root/portfolio/projects/[id]/page.tsx +++ b/app/root/portfolio/projects/[id]/page.tsx @@ -53,14 +53,14 @@ export default async function RootPortfolioProjectPage({ params, }: RootPortfolioProjectPageProps) { if (!isAdminAuthenticated()) { - redirect("/root"); + redirect("/"); } async function logoutAction() { "use server"; clearAdminSessionCookie(); - redirect("/root"); + redirect("/"); } const [categories, mediaOptions, project] = await Promise.all([ @@ -70,7 +70,7 @@ export default async function RootPortfolioProjectPage({ ]); if (!project) { - redirect("/root/portfolio?error=Project+not+found."); + redirect("/portfolio?error=Project+not+found."); } return ( @@ -90,7 +90,7 @@ export default async function RootPortfolioProjectPage({ mediaOptions={mediaOptions} project={project} formId="portfolio-project-form" - redirectPath={`/root/portfolio/projects/${project.id}`} + redirectPath={`/portfolio/projects/${project.id}`} /> diff --git a/app/root/portfolio/projects/new/page.tsx b/app/root/portfolio/projects/new/page.tsx index 0b7c46b..04af32b 100644 --- a/app/root/portfolio/projects/new/page.tsx +++ b/app/root/portfolio/projects/new/page.tsx @@ -26,14 +26,14 @@ const copy = { export default async function RootNewPortfolioProjectPage() { if (!isAdminAuthenticated()) { - redirect("/root"); + redirect("/"); } async function logoutAction() { "use server"; clearAdminSessionCookie(); - redirect("/root"); + redirect("/"); } const [categories, mediaOptions] = await Promise.all([ @@ -57,7 +57,7 @@ export default async function RootNewPortfolioProjectPage() { categories={categories} mediaOptions={mediaOptions} formId="portfolio-project-form" - redirectPath="/root/portfolio/projects/new" + redirectPath="/portfolio/projects/new" /> diff --git a/app/root/portfolio/projects/page.tsx b/app/root/portfolio/projects/page.tsx index 2232167..6dfdac4 100644 --- a/app/root/portfolio/projects/page.tsx +++ b/app/root/portfolio/projects/page.tsx @@ -31,14 +31,14 @@ export default async function RootPortfolioProjectsPage({ searchParams, }: RootPortfolioProjectsPageProps) { if (!isAdminAuthenticated()) { - redirect("/root"); + redirect("/"); } async function logoutAction() { "use server"; clearAdminSessionCookie(); - redirect("/root"); + redirect("/"); } const selectedCategory = searchParams?.category && searchParams.category !== "__all__" diff --git a/app/root/site-settings/actions.ts b/app/root/site-settings/actions.ts index 8d9154c..f337123 100644 --- a/app/root/site-settings/actions.ts +++ b/app/root/site-settings/actions.ts @@ -14,6 +14,7 @@ import { SITE_SETTINGS_LOGO_LIGHT_FIELD_KEY, updateSiteSettings, } from "@/lib/app-config"; +import { toInternalAdminPath } from "@/lib/admin-routing"; import { PAGE_TITLE_TOKEN, type SiteSettings } from "@/lib/site-settings"; import { clearAdminSessionCookie, isAdminAuthenticated } from "@/lib/admin-auth"; import { replaceEntityMediaUsages } from "@/lib/media"; @@ -27,7 +28,7 @@ import { prisma } from "@/lib/prisma"; function ensureAdmin() { if (!isAdminAuthenticated()) { clearAdminSessionCookie(); - redirect("/root"); + redirect("/"); } } @@ -74,8 +75,8 @@ async function cleanupCreatedMedia(assetIds: string[], uploadedPaths: string[]) async function revalidateSiteSettingsPages() { revalidatePath("/", "layout"); - revalidatePath("/root"); - revalidatePath("/root/site-settings"); + revalidatePath(toInternalAdminPath("/")); + revalidatePath(toInternalAdminPath("/site-settings")); revalidatePath("/coming-soon"); const publicPaths = ["/", "/about", "/portfolio", "/contact", "/success", "/coming-soon"]; @@ -277,7 +278,7 @@ export async function saveSiteSettingsAction(formData: FormData) { }); await revalidateSiteSettingsPages(); - redirect(withMessage("/root/site-settings", "success", "Einstellungen gespeichert.")); + redirect(withMessage("/site-settings", "success", "Einstellungen gespeichert.")); } catch (error) { if (isRedirectError(error)) { throw error; @@ -290,6 +291,6 @@ export async function saveSiteSettingsAction(formData: FormData) { ? error.message : "Einstellungen konnten nicht gespeichert werden."; - redirect(withMessage("/root/site-settings", "error", message)); + redirect(withMessage("/site-settings", "error", message)); } } diff --git a/app/root/site-settings/page.tsx b/app/root/site-settings/page.tsx index 8d9cd59..ba67e5e 100644 --- a/app/root/site-settings/page.tsx +++ b/app/root/site-settings/page.tsx @@ -31,14 +31,14 @@ const copy = { export default async function RootSiteSettingsPage() { if (!isAdminAuthenticated()) { - redirect("/root"); + redirect("/"); } async function logoutAction() { "use server"; clearAdminSessionCookie(); - redirect("/root"); + redirect("/"); } const [siteSettings, mediaBindings, mediaOptions] = await Promise.all([ diff --git a/app/root/smtp/actions.ts b/app/root/smtp/actions.ts index 4de36ed..418206c 100644 --- a/app/root/smtp/actions.ts +++ b/app/root/smtp/actions.ts @@ -4,6 +4,7 @@ import { revalidatePath } from "next/cache"; import { redirect } from "next/navigation"; import { isRedirectError } from "next/dist/client/components/redirect"; +import { toInternalAdminPath } from "@/lib/admin-routing"; import { clearAdminSessionCookie, isAdminAuthenticated } from "@/lib/admin-auth"; import { isCheckedFormValue } from "@/lib/form-data"; import { @@ -16,7 +17,7 @@ import type { MailSettings } from "@/lib/mail-settings"; function ensureAdmin() { if (!isAdminAuthenticated()) { clearAdminSessionCookie(); - redirect("/root"); + redirect("/"); } } @@ -77,8 +78,8 @@ export async function saveMailSettingsAction(formData: FormData) { const nextMailSettings = parseMailSettingsFormData(formData, existingMailSettings); await updateMailSettings(nextMailSettings); - revalidatePath("/root/smtp"); - redirect(withMessage("/root/smtp", "success", "SMTP Einstellungen gespeichert.")); + revalidatePath(toInternalAdminPath("/smtp")); + redirect(withMessage("/smtp", "success", "SMTP Einstellungen gespeichert.")); } catch (error) { if (isRedirectError(error)) { throw error; @@ -89,7 +90,7 @@ export async function saveMailSettingsAction(formData: FormData) { ? error.message : "SMTP Einstellungen konnten nicht gespeichert werden."; - redirect(withMessage("/root/smtp", "error", message)); + redirect(withMessage("/smtp", "error", message)); } } @@ -98,7 +99,7 @@ export async function sendTestEmailAction() { try { await sendTestEmail(); - redirect(withMessage("/root/smtp", "success", "Test-E-Mail gesendet.")); + redirect(withMessage("/smtp", "success", "Test-E-Mail gesendet.")); } catch (error) { if (isRedirectError(error)) { throw error; @@ -109,6 +110,6 @@ export async function sendTestEmailAction() { ? error.message : "Test-E-Mail konnte nicht gesendet werden."; - redirect(withMessage("/root/smtp", "error", message)); + redirect(withMessage("/smtp", "error", message)); } } diff --git a/app/root/smtp/contact-protection/actions.ts b/app/root/smtp/contact-protection/actions.ts index 66d5b9c..3f73e23 100644 --- a/app/root/smtp/contact-protection/actions.ts +++ b/app/root/smtp/contact-protection/actions.ts @@ -4,6 +4,7 @@ import { revalidatePath } from "next/cache"; import { redirect } from "next/navigation"; import { isRedirectError } from "next/dist/client/components/redirect"; +import { toInternalAdminPath } from "@/lib/admin-routing"; import { clearAdminSessionCookie, isAdminAuthenticated } from "@/lib/admin-auth"; import { isCheckedFormValue } from "@/lib/form-data"; import { @@ -15,7 +16,7 @@ import type { ContactProtectionSettings } from "@/lib/contact-protection"; function ensureAdmin() { if (!isAdminAuthenticated()) { clearAdminSessionCookie(); - redirect("/root"); + redirect("/"); } } @@ -85,13 +86,13 @@ export async function saveContactProtectionSettingsAction(formData: FormData) { const nextSettings = parseContactProtectionFormData(formData, existingSettings); await updateContactProtectionSettings(nextSettings); - revalidatePath("/root/smtp/contact-protection"); + revalidatePath(toInternalAdminPath("/smtp/contact-protection")); revalidatePath("/contact"); revalidatePath("/ar/contact"); revalidatePath("/en/contact"); redirect( withMessage( - "/root/smtp/contact-protection", + "/smtp/contact-protection", "success", "Contact Protection gespeichert.", ), @@ -106,6 +107,6 @@ export async function saveContactProtectionSettingsAction(formData: FormData) { ? error.message : "Contact Protection konnte nicht gespeichert werden."; - redirect(withMessage("/root/smtp/contact-protection", "error", message)); + redirect(withMessage("/smtp/contact-protection", "error", message)); } } diff --git a/app/root/smtp/contact-protection/page.tsx b/app/root/smtp/contact-protection/page.tsx index 8e383ed..0f5cdc9 100644 --- a/app/root/smtp/contact-protection/page.tsx +++ b/app/root/smtp/contact-protection/page.tsx @@ -27,14 +27,14 @@ const copy = { export default async function RootSMTPProtectionPage() { if (!isAdminAuthenticated()) { - redirect("/root"); + redirect("/"); } async function logoutAction() { "use server"; clearAdminSessionCookie(); - redirect("/root"); + redirect("/"); } const settings = await getContactProtectionFormValues(); diff --git a/app/root/smtp/page.tsx b/app/root/smtp/page.tsx index b35ed8b..5c0994b 100644 --- a/app/root/smtp/page.tsx +++ b/app/root/smtp/page.tsx @@ -28,14 +28,14 @@ const copy = { export default async function RootSMTPPage() { if (!isAdminAuthenticated()) { - redirect("/root"); + redirect("/"); } async function logoutAction() { "use server"; clearAdminSessionCookie(); - redirect("/root"); + redirect("/"); } const mailSettings = await getMailSettingsFormValues(); diff --git a/app/root/ui-kit/page.tsx b/app/root/ui-kit/page.tsx index c903bb3..eb431ac 100644 --- a/app/root/ui-kit/page.tsx +++ b/app/root/ui-kit/page.tsx @@ -24,14 +24,14 @@ export default async function RootUiKitPage() { const authenticated = isAdminAuthenticated(); if (!authenticated) { - redirect("/root"); + redirect("/"); } async function logoutAction() { "use server"; clearAdminSessionCookie(); - redirect("/root"); + redirect("/"); } return ( diff --git a/components/layout/app-sidebar.tsx b/components/layout/app-sidebar.tsx index 014788e..5a33e4d 100644 --- a/components/layout/app-sidebar.tsx +++ b/components/layout/app-sidebar.tsx @@ -27,10 +27,10 @@ export function AppSidebar({ items, footer, }: AppSidebarProps) { - const portfolioItem = items.find((item) => item.href === "/root/portfolio"); - const systemOrder = ["/root", "/root/media", "/root/maintenance", "/root/ui-kit"]; + const portfolioItem = items.find((item) => item.href === "/portfolio"); + const systemOrder = ["/", "/media", "/maintenance", "/ui-kit"]; const systemItems = items - .filter((item) => item.href !== "/root/portfolio") + .filter((item) => item.href !== "/portfolio") .sort((left, right) => systemOrder.indexOf(left.href) - systemOrder.indexOf(right.href)); function renderItem(item: SidebarItem, nested = false) { diff --git a/components/layout/site-footer.tsx b/components/layout/site-footer.tsx index 7180ddb..9699176 100644 --- a/components/layout/site-footer.tsx +++ b/components/layout/site-footer.tsx @@ -2,6 +2,7 @@ import Link from "next/link"; import { useLocale, useTranslations } from "next-intl"; import { Container } from "@/components/layout/container"; +import { buildAdminUrl } from "@/lib/admin-routing"; import { getLocalizedPath } from "@/lib/locale"; const navItems = [ @@ -34,7 +35,7 @@ export function SiteFooter({ isAdmin = false }: SiteFooterProps) { ))} {isAdmin ? ( - + {tNav("root")} ) : null} diff --git a/components/layout/site-header.tsx b/components/layout/site-header.tsx index 215a81b..d571a0d 100644 --- a/components/layout/site-header.tsx +++ b/components/layout/site-header.tsx @@ -13,6 +13,7 @@ import { SiteLogo } from "@/components/layout/site-logo"; import { SoundToggle } from "@/components/sound-toggle"; import { ThemeToggle } from "@/components/theme-toggle"; import { Button } from "@/components/ui/button"; +import { buildAdminUrl } from "@/lib/admin-routing"; import { getLocalizedPath, stripLocalePrefix } from "@/lib/locale"; import { toast } from "@/lib/toast"; import { cn } from "@/lib/utils"; @@ -314,7 +315,7 @@ export function SiteHeader({ /> {isAdmin ? ( @@ -464,7 +465,7 @@ export function SiteHeader({ /> {isAdmin ? ( diff --git a/components/root/portfolio-categories-manager.tsx b/components/root/portfolio-categories-manager.tsx index 630174d..d510532 100644 --- a/components/root/portfolio-categories-manager.tsx +++ b/components/root/portfolio-categories-manager.tsx @@ -159,7 +159,7 @@ function CategoryForm({ return (
{categoryId ? : null} - +
@@ -281,7 +281,7 @@ function EditCategoryDialog({
- + {categories.map((category) => ( diff --git a/components/root/portfolio-subnav.tsx b/components/root/portfolio-subnav.tsx index 671a03c..f3e3419 100644 --- a/components/root/portfolio-subnav.tsx +++ b/components/root/portfolio-subnav.tsx @@ -12,12 +12,12 @@ const items = [ { key: "projects", label: "Projekte", - href: "/root/portfolio", + href: "/portfolio", }, { key: "categories", label: "Kategorien", - href: "/root/portfolio/categories", + href: "/portfolio/categories", }, ] as const; diff --git a/components/root/root-dashboard-shell.tsx b/components/root/root-dashboard-shell.tsx index 16a908d..757ee87 100644 --- a/components/root/root-dashboard-shell.tsx +++ b/components/root/root-dashboard-shell.tsx @@ -19,8 +19,8 @@ import { SidebarMaintenanceControl } from "@/components/root/sidebar-maintenance import { SoundToggle } from "@/components/sound-toggle"; import { ThemeToggle } from "@/components/theme-toggle"; import { Button } from "@/components/ui/button"; +import { buildSiteUrl } from "@/lib/admin-routing"; import { getMaintenanceMode, getSiteSettingsMediaBindings } from "@/lib/app-config"; -import { getLocalizedPath } from "@/lib/locale"; import { getRootNavigation } from "@/lib/root-navigation"; import { updateMaintenanceModeAction } from "@/app/root/maintenance/actions"; @@ -75,12 +75,12 @@ export async function RootDashboardShell({ const sidebarItems = getRootNavigation(copy, active, smtpChild, portfolioChild); const normalizedSidebarItems = sidebarItems.filter( (item) => - item.href !== "/root/maintenance" && - item.href !== "/root/ui-kit" && - item.href !== "/root/smtp" && - item.href !== "/root/marquee", + item.href !== "/maintenance" && + item.href !== "/ui-kit" && + item.href !== "/smtp" && + item.href !== "/marquee", ); - const footerSidebarItems = ["/root/marquee", "/root/smtp"] + const footerSidebarItems = ["/marquee", "/smtp"] .map((href) => sidebarItems.find((item) => item.href === href)) .filter((item): item is NonNullable => Boolean(item)); const headerIcon = @@ -110,7 +110,7 @@ export async function RootDashboardShell({ icon={headerIcon} items={normalizedSidebarItems} sidebarIconSrc={mediaBindings.favicon?.url} - sidebarBrandHref={getLocalizedPath("de")} + sidebarBrandHref={buildSiteUrl()} sidebarBrandHoverLabel={copy.backToSite} sidebarTop={sidebarTopContent} sidebarFooterItems={footerSidebarItems} @@ -126,7 +126,7 @@ export async function RootDashboardShell({ variant={active === "ui-kit" ? "default" : "outline"} className="w-full justify-between" > - + {copy.uiKit} diff --git a/docker-compose.yml b/docker-compose.yml index 592af1c..17efeb0 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -9,6 +9,7 @@ services: environment: NODE_ENV: production NEXT_TELEMETRY_DISABLED: "1" + NEXT_PUBLIC_ADMIN_URL: ${NEXT_PUBLIC_ADMIN_URL:-https://root.mohfarawati.de} DATABASE_URL: postgresql://postgres:postgres@db:5432/moh_sass?schema=public ADMIN_PASSWORD: ${ADMIN_PASSWORD:-change-me} ADMIN_AUTH_SECRET: ${ADMIN_AUTH_SECRET:-change-me-long-secret} @@ -26,7 +27,7 @@ services: labels: - "traefik.enable=true" - "traefik.docker.network=proxy" - - "traefik.http.routers.sass-mohfarawati.rule=Host(`mohfarawati.de`) || Host(`www.mohfarawati.de`)" + - "traefik.http.routers.sass-mohfarawati.rule=Host(`mohfarawati.de`) || Host(`www.mohfarawati.de`) || Host(`root.mohfarawati.de`)" - "traefik.http.routers.sass-mohfarawati.entrypoints=websecure" - "traefik.http.routers.sass-mohfarawati.tls=true" - "traefik.http.routers.sass-mohfarawati.tls.certresolver=cf" diff --git a/lib/admin-routing.ts b/lib/admin-routing.ts new file mode 100644 index 0000000..24ac3e0 --- /dev/null +++ b/lib/admin-routing.ts @@ -0,0 +1,67 @@ +const DEFAULT_ADMIN_HOST = "root.mohfarawati.de"; +const DEFAULT_ADMIN_URL = `https://${DEFAULT_ADMIN_HOST}`; +const DEFAULT_SITE_URL = "https://mohfarawati.de"; + +export const INTERNAL_ADMIN_PREFIX = "/_admin"; + +function normalizePathname(pathname: string): string { + if (!pathname || pathname === "/") { + return "/"; + } + + return pathname.startsWith("/") ? pathname : `/${pathname}`; +} + +function normalizeBaseUrl(url: string): string { + return url.replace(/\/+$/, ""); +} + +export function getAdminHost(): string { + return (process.env.ADMIN_HOST ?? DEFAULT_ADMIN_HOST).trim().toLowerCase(); +} + +export function getRequestHostname(hostHeader?: string | null): string { + return (hostHeader ?? "") + .split(",")[0] + ?.trim() + .toLowerCase() + .replace(/:\d+$/, "") ?? ""; +} + +export function isAdminHost(hostname: string): boolean { + return hostname === getAdminHost(); +} + +export function isLegacyAdminPath(pathname: string): boolean { + return pathname === "/root" || pathname.startsWith("/root/"); +} + +export function isInternalAdminPath(pathname: string): boolean { + return pathname === INTERNAL_ADMIN_PREFIX || pathname.startsWith(`${INTERNAL_ADMIN_PREFIX}/`); +} + +export function toInternalAdminPath(pathname: string): string { + const normalizedPathname = normalizePathname(pathname); + + if (normalizedPathname === "/") { + return INTERNAL_ADMIN_PREFIX; + } + + return `${INTERNAL_ADMIN_PREFIX}${normalizedPathname}`; +} + +export function getAdminBaseUrl(): string { + return normalizeBaseUrl(process.env.NEXT_PUBLIC_ADMIN_URL ?? DEFAULT_ADMIN_URL); +} + +export function buildAdminUrl(pathname = "/"): string { + return `${getAdminBaseUrl()}${normalizePathname(pathname)}`; +} + +export function getSiteBaseUrl(): string { + return normalizeBaseUrl(process.env.NEXT_PUBLIC_SITE_URL ?? DEFAULT_SITE_URL); +} + +export function buildSiteUrl(pathname = "/"): string { + return `${getSiteBaseUrl()}${normalizePathname(pathname)}`; +} diff --git a/lib/root-navigation.ts b/lib/root-navigation.ts index 8a764ca..f31487c 100644 --- a/lib/root-navigation.ts +++ b/lib/root-navigation.ts @@ -42,56 +42,56 @@ export function getRootNavigation( return [ { label: copy.overview, - href: "/root", + href: "/", icon: LayoutDashboard, active: active === "overview", }, { label: copy.maintenance, - href: "/root/maintenance", + href: "/maintenance", icon: ShieldAlert, active: active === "maintenance", }, { label: copy.uiKit, - href: "/root/ui-kit", + href: "/ui-kit", icon: SwatchBook, active: active === "ui-kit", }, { label: copy.media, - href: "/root/media", + href: "/media", icon: ImageIcon, active: active === "media", }, { label: copy.siteSettings, - href: "/root/site-settings", + href: "/site-settings", icon: Globe2, active: active === "site-settings", }, { label: copy.marquee ?? "Marquee", - href: "/root/marquee", + href: "/marquee", icon: Type, active: active === "marquee", }, { label: copy.smtp ?? "SMTP", - href: "/root/smtp", + href: "/smtp", icon: Mail, active: active === "smtp" && !smtpChild, expanded: active === "smtp", children: [ { label: copy.smtp ?? "SMTP", - href: "/root/smtp", + href: "/smtp", icon: Mail, active: smtpChild === "settings" || (!smtpChild && active === "smtp"), }, { label: copy.contactProtection ?? "Contact Protection", - href: "/root/smtp/contact-protection", + href: "/smtp/contact-protection", icon: ShieldAlert, active: smtpChild === "contact-protection", }, @@ -99,26 +99,26 @@ export function getRootNavigation( }, { label: copy.portfolio, - href: "/root/portfolio", + href: "/portfolio", icon: FolderKanban, active: active === "portfolio" && !portfolioChild, expanded: active === "portfolio", children: [ { label: "Projects", - href: "/root/portfolio", + href: "/portfolio", icon: FolderKanban, active: portfolioChild === "overview" || portfolioChild === "projects", }, { label: "Add Project", - href: "/root/portfolio/projects/new", + href: "/portfolio/projects/new", icon: PlusSquare, active: portfolioChild === "new-project", }, { label: "Add Category", - href: "/root/portfolio/categories", + href: "/portfolio/categories", icon: Tags, active: portfolioChild === "categories", }, diff --git a/middleware.ts b/middleware.ts index 8385be6..c70006d 100644 --- a/middleware.ts +++ b/middleware.ts @@ -3,6 +3,13 @@ import { NextResponse } from "next/server"; import type { NextRequest } from "next/server"; import { routing } from "./i18n/routing"; +import { + getRequestHostname, + isAdminHost, + isInternalAdminPath, + isLegacyAdminPath, + toInternalAdminPath, +} from "./lib/admin-routing"; const intlMiddleware = createMiddleware(routing); @@ -41,12 +48,39 @@ function isRootBasicAuthValid(request: NextRequest): boolean { export default async function middleware(request: NextRequest) { const { pathname } = request.nextUrl; - const isRootBaseRoute = pathname === "/root" || pathname.startsWith("/root/"); - const isRootRoute = isRootBaseRoute; + const hostname = getRequestHostname( + request.headers.get("x-forwarded-host") ?? request.headers.get("host") ?? request.nextUrl.hostname, + ); + const isAdminRequest = isAdminHost(hostname); const rootRobotsHeaders = { "X-Robots-Tag": "noindex, nofollow, noarchive, nosnippet, noimageindex", }; + if (isInternalAdminPath(pathname) || isLegacyAdminPath(pathname)) { + return new NextResponse("Not Found", { + status: 404, + }); + } + + if (isAdminRequest) { + if (isRootBasicAuthConfigured() && !isRootBasicAuthValid(request)) { + return new NextResponse("Authentication required", { + status: 401, + headers: { + "WWW-Authenticate": 'Basic realm="Root Area", charset="UTF-8"', + ...rootRobotsHeaders, + }, + }); + } + + const rewriteUrl = request.nextUrl.clone(); + rewriteUrl.pathname = toInternalAdminPath(pathname); + + const response = NextResponse.rewrite(rewriteUrl); + response.headers.set("X-Robots-Tag", rootRobotsHeaders["X-Robots-Tag"]); + return response; + } + if (pathname === "/de" || pathname.startsWith("/de/")) { const redirectUrl = request.nextUrl.clone(); const nextPath = pathname.slice(3) || "/"; @@ -54,22 +88,6 @@ export default async function middleware(request: NextRequest) { return NextResponse.redirect(redirectUrl, 308); } - if (isRootRoute && isRootBasicAuthConfigured() && !isRootBasicAuthValid(request)) { - return new NextResponse("Authentication required", { - status: 401, - headers: { - "WWW-Authenticate": 'Basic realm="Root Area", charset="UTF-8"', - ...rootRobotsHeaders, - }, - }); - } - - if (isRootBaseRoute) { - const response = NextResponse.next(); - response.headers.set("X-Robots-Tag", rootRobotsHeaders["X-Robots-Tag"]); - return response; - } - return intlMiddleware(request); }