stadtwerke/innungsapp/apps/admin/middleware.ts

125 lines
4.9 KiB
TypeScript

import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
const PUBLIC_PREFIXES = [
'/login',
'/api/auth',
'/api/health',
'/api/trpc/stellen.listPublic',
'/api/setup',
'/registrierung',
'/impressum',
'/datenschutz',
]
const PUBLIC_EXACT_PATHS = ['/']
const TENANT_SHARED_PATHS = ['/login', '/api', '/superadmin', '/registrierung', '/impressum', '/datenschutz', '/passwort-aendern']
// Reserved subdomains that shouldn't be treated as tenant slugs
const RESERVED_SUBDOMAINS = [
'www', 'app', 'admin', 'localhost', 'superadmin', 'api',
'logo.png', 'favicon.ico', 'robots.txt', 'sitemap.xml',
'apple-touch-icon', 'android-chrome', 'manifest'
]
export function middleware(request: NextRequest) {
const url = request.nextUrl
const pathname = url.pathname
// 1. Subdomain Extraction
const hostname = request.headers.get('host') || ''
const domainParts = hostname.split(':')[0].split('.')
let slug = null
// For localhost: tischler.localhost -> parts: ['tischler', 'localhost']
// For production: tischler.innungsapp.de -> parts: ['tischler', 'innungsapp', 'de']
if (
domainParts.length > 2 ||
(domainParts.length === 2 && domainParts[1] === 'localhost')
) {
const potentialSlug = domainParts[0]
if (!RESERVED_SUBDOMAINS.includes(potentialSlug)) {
slug = potentialSlug
}
}
// Normalize stale tenant-prefixed shared paths like /test/login to /login
// before auth checks, otherwise callbackUrl can get stuck on /test/login.
if (slug) {
for (const sharedPath of TENANT_SHARED_PATHS) {
const prefixedPath = `/${slug}${sharedPath}`
if (pathname === prefixedPath || pathname.startsWith(`${prefixedPath}/`)) {
const canonicalUrl = request.nextUrl.clone()
canonicalUrl.pathname = pathname.replace(prefixedPath, sharedPath)
return NextResponse.redirect(canonicalUrl)
}
}
}
// Allow static files from /public
const isStaticFile = pathname.includes('.') && !pathname.startsWith('/api')
const isPublic =
isStaticFile ||
PUBLIC_EXACT_PATHS.includes(pathname) ||
PUBLIC_PREFIXES.some((p) => pathname.startsWith(p))
// 2. Auth Check
const sessionToken =
request.cookies.get('better-auth.session_token') ??
request.cookies.get('__Secure-better-auth.session_token')
if (!isPublic && !sessionToken) {
const loginUrl = new URL('/login', request.url)
loginUrl.searchParams.set('callbackUrl', pathname)
return NextResponse.redirect(loginUrl)
}
// 3. Subdomain Redirection / Rewrite
if (slug) {
// Paths that should not be rewritten into the slug folder
// because they are shared across the entire app
const isSharedPath = TENANT_SHARED_PATHS.some((p) => pathname.startsWith(p)) ||
pathname.startsWith('/_next') ||
/\.(png|jpg|jpeg|gif|svg|webp|ico|txt|xml)$/i.test(pathname)
if (!isSharedPath && !pathname.startsWith(`/${slug}`)) {
const rewriteUrl = request.nextUrl.clone()
rewriteUrl.pathname = `/${slug}${pathname === '/' ? '' : pathname}`
return NextResponse.rewrite(rewriteUrl)
}
} else {
// Check if the user is trying to access a path that starts with a potential slug
// but they are on the root domain.
// Example: localhost/tischler/... should redirect to tischler.localhost/...
const pathParts = pathname.split('/')
if (pathParts.length > 1) {
const potentialSlug = pathParts[1]
// Check if it's a known non-reserved path but could be an organization slug
// We don't want to redirect /login, /api, etc.
const SHARED_PATHS = ['login', 'api', 'superadmin', 'dashboard', 'registrierung', 'impressum', 'datenschutz', '_next', 'uploads', 'favicon.ico', 'passwort-aendern']
const isStaticAsset = /\.(png|jpg|jpeg|gif|svg|webp|ico|txt|xml)$/i.test(potentialSlug)
const isValidSlug = /^[a-z0-9][a-z0-9-]*$/.test(potentialSlug)
if (potentialSlug && !SHARED_PATHS.includes(potentialSlug) && !isStaticAsset && isValidSlug) {
// This looks like a tenant path being accessed from the root domain.
// Redirect to subdomain.
const baseHost = hostname.split('.').slice(-2).join('.') // Simplistic, assumes domain.tld or localhost
// For localhost it's special
const isLocalhost = hostname.includes('localhost')
const newHost = isLocalhost
? `${potentialSlug}.localhost${hostname.includes(':') ? `:${hostname.split(':')[1]}` : ''}`
: `${potentialSlug}.${baseHost}`
const remainingPath = '/' + pathParts.slice(2).join('/')
return NextResponse.redirect(new URL(remainingPath, `${url.protocol}//${newHost}`))
}
}
}
return NextResponse.next()
}
export const config = {
matcher: [
'/((?!_next/static|_next/image|favicon.ico|uploads).*)',
],
}