Files
sim/apps/sim/middleware.ts
T
Emir Karabegandwaleedlatif1 4f26a7aa73 feat(landing): new landing page (#1219)
* update infra and remove railway

* feat(landing): background; font; metadata; nav

* finished navbar ui

* completed hero UI

* hero heading UI/UX

* updated icon descriptions

* canvas improvements

* canvas improvements

* updated canvas; adjusted background

* removed gsap; adjusted canvas height

* added templates outline

* feat(landing, landing-2): Update background, hero components, nav, integrations, pricing, templates, testimonials, tailwind config

* feat(landing, landing-2): Update background, footer, hero, index components, integrations, landing-pricing, landing templates, footer in sections, icons, middleware

* improvement(landing): optimized html

* feat(landing): update background, footer, hero, integrations, landing-enterprise, landing-pricing, landing-templates, nav, add github-stars route

* feat(landing): added onclicks

* feat(landing): commented out templates

* fix: reset environment

* fixed build

* feat(landing): updated background, footer, index, integrations, landing-pricing, nav, testimonials, landing page, fonts, environment

* feat(landing): swapped integrations and pricing

* navigation for new landing

* login/signup/terms/privacy preliminary changes, as well as navigation setup

* feat(landing,nav,hero,integrations,footer,testimonials,background,structured-data): updates and additions across components

* feat(landing): updated terms and privacy

* feat(auth): adjusted background

* feat(auth): signup and login complete

* feat(auth): completed all flows ui/ux

* fix: testing and build

* feat(landing, auth): update nav and login tests

* fix(ui): update auth navigation component (149 chars)

* restore scripts dir

* revert back to old globals.css brand primary color, updated invite page

* Revert "update infra and remove railway"

This reverts commit abfa2f8d51.

* remove logos

* add gh stars action for reuse on landing + cht

---------

Co-authored-by: waleedlatif1 <walif6@gmail.com>
2025-09-15 21:40:35 -07:00

243 lines
8.4 KiB
TypeScript

import { getSessionCookie } from 'better-auth/cookies'
import { type NextRequest, NextResponse } from 'next/server'
import { isDev, isHosted } from './lib/environment'
import { createLogger } from './lib/logs/console/logger'
import { generateRuntimeCSP } from './lib/security/csp'
import { getBaseDomain } from './lib/urls/utils'
const logger = createLogger('Middleware')
const SUSPICIOUS_UA_PATTERNS = [
/^\s*$/, // Empty user agents
/\.\./, // Path traversal attempt
/<\s*script/i, // Potential XSS payloads
/^\(\)\s*{/, // Command execution attempt
/\b(sqlmap|nikto|gobuster|dirb|nmap)\b/i, // Known scanning tools
]
const BASE_DOMAIN = getBaseDomain()
export async function middleware(request: NextRequest) {
// Check for active session
const sessionCookie = getSessionCookie(request)
const hasActiveSession = !!sessionCookie
const url = request.nextUrl
const hostname = request.headers.get('host') || ''
// Extract subdomain - handle nested subdomains for any domain
const isCustomDomain = (() => {
// Standard check for non-base domains
if (hostname === BASE_DOMAIN || hostname.startsWith('www.')) {
return false
}
// Extract root domain from BASE_DOMAIN (e.g., "sim.ai" from "staging.sim.ai")
const baseParts = BASE_DOMAIN.split('.')
const rootDomain = isDev
? 'localhost'
: baseParts.length >= 2
? baseParts
.slice(-2)
.join('.') // Last 2 parts: ["simstudio", "ai"] -> "sim.ai"
: BASE_DOMAIN
// Check if hostname is under the same root domain
if (!hostname.includes(rootDomain)) {
return false
}
// For nested subdomain environments: handle cases like myapp.staging.example.com
const hostParts = hostname.split('.')
const basePartCount = BASE_DOMAIN.split('.').length
// If hostname has more parts than base domain, it's a nested subdomain
if (hostParts.length > basePartCount) {
return true
}
// For single-level subdomains: regular subdomain logic
return hostname !== BASE_DOMAIN
})()
const subdomain = isCustomDomain ? hostname.split('.')[0] : null
// Handle chat subdomains
if (subdomain && isCustomDomain) {
if (url.pathname.startsWith('/api/chat/') || url.pathname.startsWith('/api/proxy/')) {
return NextResponse.next()
}
// Rewrite to the chat page but preserve the URL in browser
return NextResponse.rewrite(new URL(`/chat/${subdomain}${url.pathname}`, request.url))
}
// Handle root path redirects based on session status and hosting type
// Only apply redirects to the main domain, not subdomains
if (!isCustomDomain && (url.pathname === '/' || url.pathname === '/homepage')) {
if (!isHosted) {
// Self-hosted: Always redirect based on session
if (hasActiveSession) {
return NextResponse.redirect(new URL('/workspace', request.url))
}
return NextResponse.redirect(new URL('/login', request.url))
}
// Hosted: Allow access to /homepage route even for authenticated users
if (url.pathname === '/homepage') {
return NextResponse.rewrite(new URL('/', request.url))
}
// For root path, redirect authenticated users to workspace
if (hasActiveSession && url.pathname === '/') {
return NextResponse.redirect(new URL('/workspace', request.url))
}
}
// Handle whitelabel redirects for terms and privacy pages
if (url.pathname === '/terms') {
const termsUrl = process.env.NEXT_PUBLIC_TERMS_URL
if (termsUrl?.startsWith('http')) {
return NextResponse.redirect(termsUrl)
}
}
if (url.pathname === '/privacy') {
const privacyUrl = process.env.NEXT_PUBLIC_PRIVACY_URL
if (privacyUrl?.startsWith('http')) {
return NextResponse.redirect(privacyUrl)
}
}
// Legacy redirect: /w -> /workspace (will be handled by workspace layout)
if (url.pathname === '/w' || url.pathname.startsWith('/w/')) {
// Extract workflow ID if present
const pathParts = url.pathname.split('/')
if (pathParts.length >= 3 && pathParts[1] === 'w') {
const workflowId = pathParts[2]
// Redirect old workflow URLs to new format
// We'll need to resolve the workspace ID for this workflow
return NextResponse.redirect(
new URL(`/workspace?redirect_workflow=${workflowId}`, request.url)
)
}
// Simple /w redirect to workspace root
return NextResponse.redirect(new URL('/workspace', request.url))
}
// Handle login page - redirect authenticated users to workspace
if (url.pathname === '/login' || url.pathname === '/signup') {
if (hasActiveSession) {
return NextResponse.redirect(new URL('/workspace', request.url))
}
return NextResponse.next()
}
// Handle protected routes that require authentication
if (url.pathname.startsWith('/workspace')) {
if (!hasActiveSession) {
return NextResponse.redirect(new URL('/login', request.url))
}
// Check if user needs email verification
const requiresVerification = request.cookies.get('requiresEmailVerification')
if (requiresVerification?.value === 'true') {
return NextResponse.redirect(new URL('/verify', request.url))
}
return NextResponse.next()
}
// Allow access to invitation links
if (request.nextUrl.pathname.startsWith('/invite/')) {
if (
!hasActiveSession &&
!request.nextUrl.pathname.endsWith('/login') &&
!request.nextUrl.pathname.endsWith('/signup') &&
!request.nextUrl.search.includes('callbackUrl')
) {
const token = request.nextUrl.searchParams.get('token')
const inviteId = request.nextUrl.pathname.split('/').pop()
const callbackParam = encodeURIComponent(
`/invite/${inviteId}${token ? `?token=${token}` : ''}`
)
return NextResponse.redirect(
new URL(`/login?callbackUrl=${callbackParam}&invite_flow=true`, request.url)
)
}
return NextResponse.next()
}
// Allow access to workspace invitation API endpoint
if (request.nextUrl.pathname.startsWith('/api/workspaces/invitations')) {
if (request.nextUrl.pathname.includes('/accept') && !hasActiveSession) {
const token = request.nextUrl.searchParams.get('token')
if (token) {
return NextResponse.redirect(new URL(`/invite/${token}?token=${token}`, request.url))
}
}
return NextResponse.next()
}
const userAgent = request.headers.get('user-agent') || ''
// Check if this is a webhook endpoint that should be exempt from User-Agent validation
const isWebhookEndpoint = url.pathname.startsWith('/api/webhooks/trigger/')
const isSuspicious = SUSPICIOUS_UA_PATTERNS.some((pattern) => pattern.test(userAgent))
// Block suspicious requests, but exempt webhook endpoints from User-Agent validation only
if (isSuspicious && !isWebhookEndpoint) {
logger.warn('Blocked suspicious request', {
userAgent,
ip: request.headers.get('x-forwarded-for') || 'unknown',
url: request.url,
method: request.method,
pattern: SUSPICIOUS_UA_PATTERNS.find((pattern) => pattern.test(userAgent))?.toString(),
})
return new NextResponse(null, {
status: 403,
statusText: 'Forbidden',
headers: {
'Content-Type': 'text/plain',
'X-Content-Type-Options': 'nosniff',
'X-Frame-Options': 'DENY',
'Content-Security-Policy': "default-src 'none'",
'Cache-Control': 'no-store, no-cache, must-revalidate, proxy-revalidate',
Pragma: 'no-cache',
Expires: '0',
},
})
}
const response = NextResponse.next()
response.headers.set('Vary', 'User-Agent')
// Generate runtime CSP for main application routes that need dynamic environment variables
if (
url.pathname.startsWith('/workspace') ||
url.pathname.startsWith('/chat') ||
url.pathname === '/'
) {
response.headers.set('Content-Security-Policy', generateRuntimeCSP())
}
return response
}
// Update matcher to include invitation routes and root path
export const config = {
matcher: [
'/', // Root path for self-hosted redirect logic
'/terms', // Whitelabel terms redirect
'/privacy', // Whitelabel privacy redirect
'/w', // Legacy /w redirect
'/w/:path*', // Legacy /w/* redirects
'/workspace/:path*', // New workspace routes
'/login',
'/signup',
'/invite/:path*', // Match invitation routes
// Catch-all for other pages, excluding static assets and public directories
'/((?!_next/static|_next/image|favicon.ico|logo/|static/|footer/|social/|enterprise/|favicon/|twitter/|robots.txt|sitemap.xml).*)',
],
}