diff --git a/app/Http/Controllers/DynamicAssetController.php b/app/Http/Controllers/DynamicAssetController.php new file mode 100644 index 00000000000..0ae5226963a --- /dev/null +++ b/app/Http/Controllers/DynamicAssetController.php @@ -0,0 +1,224 @@ +getHost(); + $cacheKey = "dynamic_css:{$domain}"; + + // Cache the generated CSS for performance + $css = Cache::remember($cacheKey, 3600, function () use ($domain) { + return $this->generateCssForDomain($domain); + }); + + return response($css, 200, [ + 'Content-Type' => 'text/css', + 'Cache-Control' => 'public, max-age=3600', + 'X-Generated-For-Domain' => $domain, // Debug header + ]); + } + + /** + * Generate CSS content for a specific domain. + */ + private function generateCssForDomain(string $domain): string + { + // Find branding config for this domain + $branding = WhiteLabelConfig::findByDomain($domain); + + if (! $branding) { + return $this->getDefaultCss(); + } + + // Start with base CSS + $css = $this->getBaseCss(); + + // Add custom CSS variables + $css .= "\n\n/* Custom theme for {$domain} */\n"; + $css .= $branding->generateCssVariables(); + + // Add any custom CSS + if ($branding->custom_css) { + $css .= "\n\n/* Custom CSS for {$domain} */\n"; + $css .= $branding->custom_css; + } + + return $css; + } + + /** + * Get the base CSS that's common to all themes. + */ + private function getBaseCss(): string + { + $baseCssPath = resource_path('css/base-theme.css'); + + if (file_exists($baseCssPath)) { + return file_get_contents($baseCssPath); + } + + // Fallback base CSS if file doesn't exist + return $this->getFallbackBaseCss(); + } + + /** + * Get default Coolify CSS. + */ + private function getDefaultCss(): string + { + $defaultCssPath = public_path('css/app.css'); + + if (file_exists($defaultCssPath)) { + return file_get_contents($defaultCssPath); + } + + return $this->getFallbackBaseCss(); + } + + /** + * Fallback CSS if no files are found. + */ + private function getFallbackBaseCss(): string + { + return <<<'CSS' +/* Fallback Base CSS for Dynamic Branding Demo */ +:root { + --primary-color: #3b82f6; + --secondary-color: #1f2937; + --accent-color: #10b981; + --background-color: #ffffff; + --text-color: #1f2937; + --border-color: #e5e7eb; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + background-color: var(--background-color); + color: var(--text-color); + margin: 0; + padding: 0; +} + +.navbar { + background-color: var(--primary-color); + color: white; + padding: 1rem; + display: flex; + align-items: center; + gap: 1rem; +} + +.navbar img { + height: 40px; +} + +.platform-name { + font-size: 1.5rem; + font-weight: bold; +} + +.btn-primary { + background-color: var(--primary-color); + border-color: var(--primary-color); + color: white; + padding: 0.5rem 1rem; + border-radius: 0.375rem; + text-decoration: none; + display: inline-block; +} + +.btn-primary:hover { + opacity: 0.9; +} + +.card { + background: white; + border: 1px solid var(--border-color); + border-radius: 0.5rem; + padding: 1.5rem; + margin: 1rem; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); +} + +.text-primary { + color: var(--primary-color); +} + +.text-secondary { + color: var(--secondary-color); +} + +.bg-primary { + background-color: var(--primary-color); +} + +.border-primary { + border-color: var(--primary-color); +} +CSS; + } + + /** + * Serve dynamic favicon based on domain branding. + */ + public function dynamicFavicon(Request $request): Response + { + $domain = $request->getHost(); + $branding = WhiteLabelConfig::findByDomain($domain); + + if ($branding && $branding->getLogoUrl()) { + // Redirect to custom logo + return redirect($branding->getLogoUrl()); + } + + // Serve default favicon + $defaultFavicon = public_path('favicon.ico'); + if (file_exists($defaultFavicon)) { + return response(file_get_contents($defaultFavicon), 200, [ + 'Content-Type' => 'image/x-icon', + 'Cache-Control' => 'public, max-age=86400', + ]); + } + + return response('', 404); + } + + /** + * Debug endpoint to show how domain detection works. + */ + public function debugBranding(Request $request): array + { + $domain = $request->getHost(); + $branding = WhiteLabelConfig::findByDomain($domain); + + return [ + 'domain' => $domain, + 'has_custom_branding' => $branding !== null, + 'platform_name' => $branding?->getPlatformName() ?? 'Coolify (Default)', + 'custom_logo' => $branding?->getLogoUrl(), + 'theme_variables' => $branding?->getThemeVariables() ?? WhiteLabelConfig::createDefault('')->getDefaultThemeVariables(), + 'custom_domains' => $branding?->getCustomDomains() ?? [], + 'hide_coolify_branding' => $branding?->shouldHideCoolifyBranding() ?? false, + 'organization_id' => $branding?->organization_id, + 'request_headers' => [ + 'host' => $request->header('host'), + 'user_agent' => $request->header('user-agent'), + 'x_forwarded_host' => $request->header('x-forwarded-host'), + ], + ]; + } +} diff --git a/app/Http/Controllers/Enterprise/BrandingController.php b/app/Http/Controllers/Enterprise/BrandingController.php new file mode 100644 index 00000000000..56583de5c31 --- /dev/null +++ b/app/Http/Controllers/Enterprise/BrandingController.php @@ -0,0 +1,508 @@ +whiteLabelService = $whiteLabelService; + $this->cacheService = $cacheService; + $this->domainService = $domainService; + $this->emailService = $emailService; + } + + /** + * Display branding management dashboard + */ + public function index(Request $request): Response + { + $organization = $this->getCurrentOrganization($request); + + Gate::authorize('manage-branding', $organization); + + $config = $this->whiteLabelService->getOrCreateConfig($organization); + $cacheStats = $this->cacheService->getCacheStats($organization->id); + + return Inertia::render('Enterprise/WhiteLabel/BrandingManager', [ + 'organization' => $organization, + 'config' => [ + 'id' => $config->id, + 'platform_name' => $config->platform_name, + 'logo_url' => $config->logo_url, + 'theme_config' => $config->theme_config, + 'custom_domains' => $config->custom_domains, + 'hide_coolify_branding' => $config->hide_coolify_branding, + 'custom_css' => $config->custom_css, + ], + 'themeVariables' => $config->getThemeVariables(), + 'emailTemplates' => $config->getAvailableEmailTemplates(), + 'cacheStats' => $cacheStats, + ]); + } + + /** + * Update branding configuration + */ + public function update(Request $request, string $organizationId) + { + $organization = Organization::findOrFail($organizationId); + + Gate::authorize('manage-branding', $organization); + + $validated = $request->validate([ + 'platform_name' => 'required|string|max:255', + 'hide_coolify_branding' => 'boolean', + 'custom_css' => 'nullable|string|max:50000', + ]); + + $config = WhiteLabelConfig::where('organization_id', $organization->id)->firstOrFail(); + $config->update($validated); + + // Clear cache + $this->cacheService->clearOrganizationCache($organization->id); + + return back()->with('success', 'Branding configuration updated successfully'); + } + + /** + * Upload and process logo + */ + public function uploadLogo(Request $request, string $organizationId) + { + $organization = Organization::findOrFail($organizationId); + + Gate::authorize('manage-branding', $organization); + + $request->validate([ + 'logo' => 'required|image|max:5120', // 5MB max + ]); + + try { + $logoUrl = $this->whiteLabelService->processLogo($request->file('logo'), $organization); + + $config = WhiteLabelConfig::where('organization_id', $organization->id)->firstOrFail(); + $config->update(['logo_url' => $logoUrl]); + + return response()->json([ + 'success' => true, + 'logo_url' => $logoUrl, + 'message' => 'Logo uploaded successfully', + ]); + } catch (\Exception $e) { + return response()->json([ + 'success' => false, + 'message' => $e->getMessage(), + ], 422); + } + } + + /** + * Update theme configuration + */ + public function updateTheme(Request $request, string $organizationId) + { + $organization = Organization::findOrFail($organizationId); + + Gate::authorize('manage-branding', $organization); + + $validated = $request->validate([ + 'theme_config' => 'required|array', + 'theme_config.primary_color' => 'required|regex:/^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/', + 'theme_config.secondary_color' => 'required|regex:/^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/', + 'theme_config.accent_color' => 'required|regex:/^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/', + 'theme_config.background_color' => 'required|regex:/^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/', + 'theme_config.text_color' => 'required|regex:/^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/', + 'theme_config.sidebar_color' => 'required|regex:/^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/', + 'theme_config.border_color' => 'required|regex:/^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/', + 'theme_config.success_color' => 'required|regex:/^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/', + 'theme_config.warning_color' => 'required|regex:/^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/', + 'theme_config.error_color' => 'required|regex:/^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/', + 'theme_config.info_color' => 'required|regex:/^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/', + 'theme_config.enable_dark_mode' => 'boolean', + ]); + + $config = WhiteLabelConfig::where('organization_id', $organization->id)->firstOrFail(); + $config->update($validated); + + // Compile and cache new theme + $compiledCss = $this->whiteLabelService->compileTheme($config); + + return response()->json([ + 'success' => true, + 'compiled_css' => $compiledCss, + 'message' => 'Theme updated successfully', + ]); + } + + /** + * Preview theme changes + */ + public function previewTheme(Request $request, string $organizationId) + { + $organization = Organization::findOrFail($organizationId); + + Gate::authorize('manage-branding', $organization); + + $config = WhiteLabelConfig::where('organization_id', $organization->id)->firstOrFail(); + + // Create temporary config with preview changes + $tempConfig = clone $config; + $tempConfig->theme_config = $request->input('theme_config', $config->theme_config); + $tempConfig->custom_css = $request->input('custom_css', $config->custom_css); + + $compiledCss = $this->whiteLabelService->compileTheme($tempConfig); + + return response()->json([ + 'success' => true, + 'compiled_css' => $compiledCss, + ]); + } + + /** + * Manage custom domains + */ + public function domains(Request $request, string $organizationId): Response + { + $organization = Organization::findOrFail($organizationId); + + Gate::authorize('manage-branding', $organization); + + $config = WhiteLabelConfig::where('organization_id', $organization->id)->firstOrFail(); + + return Inertia::render('Enterprise/WhiteLabel/DomainManager', [ + 'organization' => $organization, + 'domains' => $config->custom_domains ?? [], + 'verification_instructions' => $this->getVerificationInstructions($organization), + ]); + } + + /** + * Add custom domain + */ + public function addDomain(Request $request, string $organizationId) + { + $organization = Organization::findOrFail($organizationId); + + Gate::authorize('manage-branding', $organization); + + $validated = $request->validate([ + 'domain' => 'required|string|max:255', + ]); + + $config = WhiteLabelConfig::where('organization_id', $organization->id)->firstOrFail(); + + // Validate domain + $validation = $this->domainService->performComprehensiveValidation( + $validated['domain'], + $organization->id + ); + + if (! $validation['valid']) { + return response()->json([ + 'success' => false, + 'validation' => $validation, + ], 422); + } + + // Add domain + $result = $this->whiteLabelService->setCustomDomain($config, $validated['domain']); + + return response()->json($result); + } + + /** + * Validate domain + */ + public function validateDomain(Request $request, string $organizationId) + { + $organization = Organization::findOrFail($organizationId); + + Gate::authorize('manage-branding', $organization); + + $validated = $request->validate([ + 'domain' => 'required|string|max:255', + ]); + + $validation = $this->domainService->performComprehensiveValidation( + $validated['domain'], + $organization->id + ); + + return response()->json($validation); + } + + /** + * Remove custom domain + */ + public function removeDomain(Request $request, string $organizationId, string $domain) + { + $organization = Organization::findOrFail($organizationId); + + Gate::authorize('manage-branding', $organization); + + $config = WhiteLabelConfig::where('organization_id', $organization->id)->firstOrFail(); + $config->removeCustomDomain($domain); + $config->save(); + + // Clear domain cache + $this->cacheService->clearDomainCache($domain); + + return response()->json([ + 'success' => true, + 'message' => 'Domain removed successfully', + ]); + } + + /** + * Email template management + */ + public function emailTemplates(Request $request, string $organizationId): Response + { + $organization = Organization::findOrFail($organizationId); + + Gate::authorize('manage-branding', $organization); + + $config = WhiteLabelConfig::where('organization_id', $organization->id)->firstOrFail(); + + return Inertia::render('Enterprise/WhiteLabel/EmailTemplateEditor', [ + 'organization' => $organization, + 'availableTemplates' => $config->getAvailableEmailTemplates(), + 'customTemplates' => $config->custom_email_templates ?? [], + ]); + } + + /** + * Update email template + */ + public function updateEmailTemplate(Request $request, string $organizationId, string $templateName) + { + $organization = Organization::findOrFail($organizationId); + + Gate::authorize('manage-branding', $organization); + + $validated = $request->validate([ + 'subject' => 'required|string|max:255', + 'content' => 'required|string|max:100000', + ]); + + $config = WhiteLabelConfig::where('organization_id', $organization->id)->firstOrFail(); + $config->setEmailTemplate($templateName, $validated); + $config->save(); + + return response()->json([ + 'success' => true, + 'message' => 'Email template updated successfully', + ]); + } + + /** + * Preview email template + */ + public function previewEmailTemplate(Request $request, string $organizationId, string $templateName) + { + $organization = Organization::findOrFail($organizationId); + + Gate::authorize('manage-branding', $organization); + + $config = WhiteLabelConfig::where('organization_id', $organization->id)->firstOrFail(); + + $preview = $this->emailService->previewTemplate( + $config, + $templateName, + $request->input('sample_data', []) + ); + + return response()->json($preview); + } + + /** + * Reset email template to default + */ + public function resetEmailTemplate(Request $request, string $organizationId, string $templateName) + { + $organization = Organization::findOrFail($organizationId); + + Gate::authorize('manage-branding', $organization); + + $config = WhiteLabelConfig::where('organization_id', $organization->id)->firstOrFail(); + + $templates = $config->custom_email_templates ?? []; + unset($templates[$templateName]); + + $config->custom_email_templates = $templates; + $config->save(); + + return response()->json([ + 'success' => true, + 'message' => 'Email template reset to default', + ]); + } + + /** + * Export branding configuration + */ + public function export(Request $request, string $organizationId) + { + $organization = Organization::findOrFail($organizationId); + + Gate::authorize('manage-branding', $organization); + + $config = WhiteLabelConfig::where('organization_id', $organization->id)->firstOrFail(); + $exportData = $this->whiteLabelService->exportConfiguration($config); + + return response()->json($exportData) + ->header('Content-Disposition', 'attachment; filename="branding-config-'.$organization->id.'.json"'); + } + + /** + * Import branding configuration + */ + public function import(Request $request, string $organizationId) + { + $organization = Organization::findOrFail($organizationId); + + Gate::authorize('manage-branding', $organization); + + $request->validate([ + 'config_file' => 'required|file|mimes:json|max:1024', // 1MB max + ]); + + try { + $data = json_decode($request->file('config_file')->get(), true); + + if (json_last_error() !== JSON_ERROR_NONE) { + throw new \Exception('Invalid JSON file'); + } + + $config = WhiteLabelConfig::where('organization_id', $organization->id)->firstOrFail(); + $this->whiteLabelService->importConfiguration($config, $data); + + return response()->json([ + 'success' => true, + 'message' => 'Branding configuration imported successfully', + ]); + } catch (\Exception $e) { + return response()->json([ + 'success' => false, + 'message' => $e->getMessage(), + ], 422); + } + } + + /** + * Reset branding to defaults + */ + public function reset(Request $request, string $organizationId) + { + $organization = Organization::findOrFail($organizationId); + + Gate::authorize('manage-branding', $organization); + + $config = WhiteLabelConfig::where('organization_id', $organization->id)->firstOrFail(); + $config->resetToDefaults(); + + // Clear all caches + $this->cacheService->clearOrganizationCache($organization->id); + + return response()->json([ + 'success' => true, + 'message' => 'Branding reset to defaults', + ]); + } + + /** + * Get cache statistics + */ + public function cacheStats(Request $request, string $organizationId) + { + $organization = Organization::findOrFail($organizationId); + + Gate::authorize('manage-branding', $organization); + + $stats = $this->cacheService->getCacheStats($organization->id); + + return response()->json($stats); + } + + /** + * Clear branding cache + */ + public function clearCache(Request $request, string $organizationId) + { + $organization = Organization::findOrFail($organizationId); + + Gate::authorize('manage-branding', $organization); + + $this->cacheService->clearOrganizationCache($organization->id); + + return response()->json([ + 'success' => true, + 'message' => 'Cache cleared successfully', + ]); + } + + /** + * Get current organization from request + */ + protected function getCurrentOrganization(Request $request): Organization + { + // This would typically come from session or auth context + $organizationId = $request->route('organization') ?? + $request->session()->get('current_organization_id') ?? + $request->user()->organizations()->first()?->id; + + return Organization::findOrFail($organizationId); + } + + /** + * Get domain verification instructions + */ + protected function getVerificationInstructions(Organization $organization): array + { + $token = $this->domainService->generateVerificationToken('example.com', $organization->id); + + return [ + 'dns_txt' => [ + 'type' => 'TXT', + 'name' => '@', + 'value' => "coolify-verify={$token}", + 'ttl' => 3600, + ], + 'dns_a' => [ + 'type' => 'A', + 'name' => '@', + 'value' => config('whitelabel.server_ips.0', 'YOUR_SERVER_IP'), + 'ttl' => 3600, + ], + 'ssl' => [ + 'message' => 'Ensure your domain has a valid SSL certificate', + 'providers' => ['Let\'s Encrypt (free)', 'Cloudflare', 'Your hosting provider'], + ], + ]; + } +} diff --git a/app/Http/Controllers/Enterprise/DynamicAssetController.php b/app/Http/Controllers/Enterprise/DynamicAssetController.php new file mode 100644 index 00000000000..903874c73a0 --- /dev/null +++ b/app/Http/Controllers/Enterprise/DynamicAssetController.php @@ -0,0 +1,247 @@ +findOrganization($organization); + + if (! $organizationModel) { + return $this->errorResponse('not-found: Organization not found', 404); + } + + // 2. Check authorization + // This ensures that only authorized users can access the branding. + if (! $this->canAccessBranding($organizationModel)) { + return $this->unauthorizedResponse(); + } + + // 3. Get white-label configuration (eager loaded) + // The 'whiteLabelConfig' relation is eager loaded in findOrganization(). + $config = $organizationModel->whiteLabelConfig; + if (! $config) { + return $this->errorResponse('not-found: Branding configuration not found', 404); + } + + // 4. Check cache + // We use the organization slug and the last update timestamp to create a unique cache key. + $cacheKey = $this->getCacheKey($organizationModel->slug, $config->updated_at?->timestamp ?? 0); + $etag = $this->generateEtag($config); + + // Handle If-None-Match header for 304 responses + // This is a browser-level cache that avoids re-downloading the CSS if it hasn't changed. + $request = request(); + if ($request->header('If-None-Match') === $etag) { + return response('', 304); + } + + // Try to get from cache + // If the CSS is not in the cache, we build it and store it. + $ttl = config('coolify.white_label_cache_ttl', 3600); + $css = Cache::remember($cacheKey, $ttl, fn () => $this->buildCssResponse($config)); + + // 6. Return response with caching headers + // We add the ETag and a custom header to indicate if the response was served from cache. + return response($css, 200) + ->header('Content-Type', 'text/css; charset=UTF-8') + ->header('ETag', $etag) + ->header('X-Cache-Hit', Cache::has($cacheKey) ? 'true' : 'false'); + + } catch (SassException $e) { + // Handle SASS compilation errors specifically. + Log::error('SASS compilation failed', [ + 'organization' => $organization ?? 'unknown', + 'error' => $e->getMessage(), + ]); + + return $this->errorResponse('internal-server-error: Failed to compile branding styles', 500, "/* SASS Error: {$e->getMessage()} */"); + } catch (\Exception $e) { + // Handle all other exceptions. + Log::error('Branding CSS generation failed', [ + 'organization' => $organization ?? 'unknown', + 'error' => $e->getMessage(), + ]); + + if (app()->bound('sentry')) { + app('sentry')->captureException($e); + } + + return $this->errorResponse('internal-server-error: Failed to compile branding styles', 500, "/* Error: {$e->getMessage()} */"); + } + } + + /** + * @throws \Exception + */ + private function buildCssResponse(WhiteLabelConfig $config): string + { + $css = $this->sassService->compile($config); + $darkModeCss = $this->sassService->compileDarkMode(); + $customCss = $this->cssValidator->sanitize($config->custom_css ?? ''); + + $finalCss = [$css]; + if (! empty($darkModeCss)) { + $finalCss[] = $darkModeCss; + } + if (! empty($customCss)) { + $finalCss[] = self::CUSTOM_CSS_COMMENT; + $finalCss[] = $customCss; + } + + $cssString = implode("\n\n", $finalCss); + + return app()->environment('production') ? $this->minifyCss($cssString) : $cssString; + } + + /** + * Find organization by ID or slug (with caching) + */ + private function findOrganization(string $identifier): ?Organization + { + $cacheKey = "org:lookup:{$identifier}"; + + return Cache::remember($cacheKey, self::ORG_LOOKUP_CACHE_TTL, function () use ($identifier) { + // Single optimized query + return Organization::with('whiteLabelConfig') + ->where(function ($query) use ($identifier) { + if (Str::isUuid($identifier)) { + $query->where('id', $identifier); + } else { + $query->where('slug', $identifier); + } + }) + ->first(); + }); + } + + /** + * Check if user can access organization branding + */ + private function canAccessBranding(Organization $org): bool + { + // Public access allowed + if ($org->whitelabel_public_access) { + return true; + } + + // Require authentication for private branding + if (! auth()->check()) { + return false; + } + + // Check organization membership directly + $user = auth()->user(); + if (! $user) { + return false; + } + + // Check if user is a member of the organization + return $org->users()->where('user_id', $user->id)->exists(); + } + + /** + * Return unauthorized response + */ + private function unauthorizedResponse(): Response + { + return $this->errorResponse('unauthorized: Branding access requires authentication', 403); + } + + /** + * Generate consistent error response + */ + private function errorResponse(string $message, int $status, ?string $fallbackCss = null): Response + { + $messageParts = explode(':', $message, 2); + $cleanMessage = trim($messageParts[1] ?? $messageParts[0]); + + $css = $fallbackCss ?? sprintf( + "/* Coolify Branding Error: %s (HTTP %d) */\n:root { --error: true; }", + $cleanMessage, + $status + ); + + return response($css, $status) + ->header('Content-Type', 'text/css; charset=UTF-8') + ->header('X-Branding-Error', strtolower(str_replace([' ', ':'], ['-', ''], $message))) + ->header('Cache-Control', 'no-cache, no-store, must-revalidate'); + } + + /** + * Get cache key for organization CSS + */ + private function getCacheKey(string $organizationSlug, int $updatedTimestamp = 0): string + { + return sprintf( + '%s:%s:css:%s:%d', + self::CACHE_PREFIX, + $organizationSlug, + self::CACHE_VERSION, + $updatedTimestamp + ); + } + + /** + * Generate ETag for cache validation + */ + private function generateEtag(WhiteLabelConfig $config): string + { + $content = json_encode($config->theme_config).($config->custom_css ?? ''); + $hash = md5($content); + + return '"'.$hash.'"'; + } + + /** + * Minify CSS for production + */ + private function minifyCss(string $css): string + { + // Remove comments (preserving license comments) + $css = preg_replace('/\/\*(?![!*])(.*?)\*\//s', '', $css); + + // Remove unnecessary whitespace + $css = str_replace(["\r\n", "\r", "\n", "\t"], '', $css); + $css = preg_replace('/\s+/', ' ', $css); + $css = preg_replace('/\s*([{}:;,])\s*/', '$1', $css); + + return trim($css); + } +} diff --git a/app/Http/Middleware/DynamicBrandingMiddleware.php b/app/Http/Middleware/DynamicBrandingMiddleware.php new file mode 100644 index 00000000000..030abb99d86 --- /dev/null +++ b/app/Http/Middleware/DynamicBrandingMiddleware.php @@ -0,0 +1,71 @@ +getHost(); + + // Find branding configuration for this domain + $branding = WhiteLabelConfig::findByDomain($domain); + + if ($branding) { + // Set branding context for the entire request lifecycle + app()->instance('current.branding', $branding); + app()->instance('current.organization', $branding->organization); + + // Share branding data with all views + View::share([ + 'branding' => $branding, + 'platformName' => $branding->getPlatformName(), + 'customLogo' => $branding->getLogoUrl(), + 'hideDefaultBranding' => $branding->shouldHideCoolifyBranding(), + 'themeVariables' => $branding->getThemeVariables(), + ]); + + // Add branding to request attributes for controllers + $request->attributes->set('branding', $branding); + $request->attributes->set('organization', $branding->organization); + + // Log domain-based branding for debugging + if (config('app.debug')) { + logger()->info('Domain-based branding applied', [ + 'domain' => $domain, + 'platform_name' => $branding->getPlatformName(), + 'organization_id' => $branding->organization_id, + ]); + } + } else { + // No custom branding found - use default Coolify branding + View::share([ + 'branding' => null, + 'platformName' => 'Coolify', + 'customLogo' => null, + 'hideDefaultBranding' => false, + 'themeVariables' => WhiteLabelConfig::createDefault('')->getDefaultThemeVariables(), + ]); + + if (config('app.debug')) { + logger()->info('Default branding applied', [ + 'domain' => $domain, + 'reason' => 'No custom branding configuration found', + ]); + } + } + + return $next($request); + } +} diff --git a/app/Models/WhiteLabelConfig.php b/app/Models/WhiteLabelConfig.php new file mode 100644 index 00000000000..d1f804ee19c --- /dev/null +++ b/app/Models/WhiteLabelConfig.php @@ -0,0 +1,234 @@ + 'array', + 'custom_domains' => 'array', + 'custom_email_templates' => 'array', + 'hide_coolify_branding' => 'boolean', + ]; + + // Relationships + public function organization() + { + return $this->belongsTo(Organization::class); + } + + // Theme Configuration Methods + public function getThemeVariable(string $variable, $default = null) + { + return $this->theme_config[$variable] ?? $default; + } + + public function setThemeVariable(string $variable, $value): void + { + $config = $this->theme_config ?? []; + $config[$variable] = $value; + $this->theme_config = $config; + } + + public function getThemeVariables(): array + { + $defaults = $this->getDefaultThemeVariables(); + + return array_merge($defaults, $this->theme_config ?? []); + } + + public function getDefaultThemeVariables(): array + { + return [ + 'primary_color' => '#3b82f6', + 'secondary_color' => '#1f2937', + 'accent_color' => '#10b981', + 'background_color' => '#ffffff', + 'text_color' => '#1f2937', + 'sidebar_color' => '#f9fafb', + 'border_color' => '#e5e7eb', + 'success_color' => '#10b981', + 'warning_color' => '#f59e0b', + 'error_color' => '#ef4444', + 'info_color' => '#3b82f6', + ]; + } + + public function generateCssVariables(): string + { + $variables = $this->getThemeVariables(); + $css = ':root {'.PHP_EOL; + + foreach ($variables as $key => $value) { + $cssVar = '--'.str_replace('_', '-', $key); + $css .= " {$cssVar}: {$value};".PHP_EOL; + } + + $css .= '}'.PHP_EOL; + + if ($this->custom_css) { + $css .= PHP_EOL.$this->custom_css; + } + + return $css; + } + + // Domain Management Methods + public function addCustomDomain(string $domain): void + { + $domains = $this->custom_domains ?? []; + if (! in_array($domain, $domains)) { + $domains[] = $domain; + $this->custom_domains = $domains; + } + } + + public function removeCustomDomain(string $domain): void + { + $domains = $this->custom_domains ?? []; + $this->custom_domains = array_values(array_filter($domains, fn ($d) => $d !== $domain)); + } + + public function hasCustomDomain(string $domain): bool + { + return in_array($domain, $this->custom_domains ?? []); + } + + public function getCustomDomains(): array + { + return $this->custom_domains ?? []; + } + + // Email Template Methods + public function getEmailTemplate(string $templateName): ?array + { + return $this->custom_email_templates[$templateName] ?? null; + } + + public function setEmailTemplate(string $templateName, array $template): void + { + $templates = $this->custom_email_templates ?? []; + $templates[$templateName] = $template; + $this->custom_email_templates = $templates; + } + + public function hasCustomEmailTemplate(string $templateName): bool + { + return isset($this->custom_email_templates[$templateName]); + } + + public function getAvailableEmailTemplates(): array + { + return [ + 'welcome' => 'Welcome Email', + 'password_reset' => 'Password Reset', + 'email_verification' => 'Email Verification', + 'invitation' => 'Team Invitation', + 'deployment_success' => 'Deployment Success', + 'deployment_failure' => 'Deployment Failure', + 'server_unreachable' => 'Server Unreachable', + 'backup_success' => 'Backup Success', + 'backup_failure' => 'Backup Failure', + ]; + } + + // Branding Methods + public function getPlatformName(): string + { + return $this->platform_name ?: 'Coolify'; + } + + public function getLogoUrl(): ?string + { + return $this->logo_url; + } + + public function hasCustomLogo(): bool + { + return ! empty($this->logo_url); + } + + public function shouldHideCoolifyBranding(): bool + { + return $this->hide_coolify_branding; + } + + // Validation Methods + public function isValidThemeColor(string $color): bool + { + // Check if it's a valid hex color + return preg_match('/^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/', $color) === 1; + } + + public function isValidDomain(string $domain): bool + { + return filter_var($domain, FILTER_VALIDATE_DOMAIN, FILTER_FLAG_HOSTNAME) !== false; + } + + public function isValidLogoUrl(string $url): bool + { + if (! filter_var($url, FILTER_VALIDATE_URL)) { + return false; + } + + // Check if it's an image URL (basic check) + $imageExtensions = ['jpg', 'jpeg', 'png', 'gif', 'svg', 'webp']; + $extension = strtolower(pathinfo(parse_url($url, PHP_URL_PATH), PATHINFO_EXTENSION)); + + return in_array($extension, $imageExtensions); + } + + // Factory Methods + public static function createDefault(string $organizationId): self + { + return self::create([ + 'organization_id' => $organizationId, + 'platform_name' => 'Coolify', + 'theme_config' => [], + 'custom_domains' => [], + 'hide_coolify_branding' => false, + 'custom_email_templates' => [], + ]); + } + + public function resetToDefaults(): void + { + $this->update([ + 'platform_name' => 'Coolify', + 'logo_url' => null, + 'theme_config' => [], + 'custom_domains' => [], + 'hide_coolify_branding' => false, + 'custom_email_templates' => [], + 'custom_css' => null, + ]); + } + + // Domain Detection for Multi-Tenant Branding + public static function findByDomain(string $domain): ?self + { + return self::whereJsonContains('custom_domains', $domain)->first(); + } + + public static function findByOrganization(string $organizationId): ?self + { + return self::where('organization_id', $organizationId)->first(); + } +} diff --git a/app/Services/Enterprise/BrandingCacheService.php b/app/Services/Enterprise/BrandingCacheService.php new file mode 100644 index 00000000000..93436acedc8 --- /dev/null +++ b/app/Services/Enterprise/BrandingCacheService.php @@ -0,0 +1,386 @@ +getThemeCacheKey($organizationId); + + Cache::put($key, $css, self::CACHE_TTL); + + // Also store in Redis for faster retrieval + if ($this->isRedisAvailable()) { + Redis::setex($key, self::CACHE_TTL, $css); + } + + // Store a hash for version tracking + $this->cacheThemeVersion($organizationId, md5($css)); + } + + /** + * Get cached compiled theme + */ + public function getCachedTheme(string $organizationId): ?string + { + $key = $this->getThemeCacheKey($organizationId); + + // Try Redis first for better performance + if ($this->isRedisAvailable()) { + $cached = Redis::get($key); + if ($cached) { + return $cached; + } + } + + return Cache::get($key); + } + + /** + * Cache theme version hash for validation + */ + protected function cacheThemeVersion(string $organizationId, string $hash): void + { + $key = self::CACHE_PREFIX.'version:'.$organizationId; + Cache::put($key, $hash, self::CACHE_TTL); + } + + /** + * Get cached theme version + */ + public function getThemeVersion(string $organizationId): ?string + { + $key = self::CACHE_PREFIX.'version:'.$organizationId; + + return Cache::get($key); + } + + /** + * Cache logo and asset URLs + */ + public function cacheAssetUrl(string $organizationId, string $assetType, string $url): void + { + $key = $this->getAssetCacheKey($organizationId, $assetType); + Cache::put($key, $url, self::CACHE_TTL); + } + + /** + * Get cached asset URL + */ + public function getCachedAssetUrl(string $organizationId, string $assetType): ?string + { + $key = $this->getAssetCacheKey($organizationId, $assetType); + + return Cache::get($key); + } + + /** + * Cache domain-to-organization mapping + */ + public function cacheDomainMapping(string $domain, string $organizationId): void + { + $key = self::DOMAIN_CACHE_PREFIX.$domain; + + Cache::put($key, $organizationId, self::CACHE_TTL); + + // Also store in Redis for faster domain resolution + if ($this->isRedisAvailable()) { + Redis::setex($key, self::CACHE_TTL, $organizationId); + } + } + + /** + * Get organization ID from domain + */ + public function getOrganizationByDomain(string $domain): ?string + { + $key = self::DOMAIN_CACHE_PREFIX.$domain; + + // Try Redis first + if ($this->isRedisAvailable()) { + $orgId = Redis::get($key); + if ($orgId) { + return $orgId; + } + } + + return Cache::get($key); + } + + /** + * Cache branding configuration + */ + public function cacheBrandingConfig(string $organizationId, array $config): void + { + $key = self::CACHE_PREFIX.'config:'.$organizationId; + + Cache::put($key, $config, self::CACHE_TTL); + + // Store individual config elements for partial retrieval + foreach ($config as $configKey => $value) { + $elementKey = self::CACHE_PREFIX."config:{$organizationId}:{$configKey}"; + Cache::put($elementKey, $value, self::CACHE_TTL); + } + } + + /** + * Get cached branding configuration + */ + public function getCachedBrandingConfig(string $organizationId, ?string $configKey = null): mixed + { + if ($configKey) { + $key = self::CACHE_PREFIX."config:{$organizationId}:{$configKey}"; + + return Cache::get($key); + } + + $key = self::CACHE_PREFIX.'config:'.$organizationId; + + return Cache::get($key); + } + + /** + * Clear all cache for an organization + */ + public function clearOrganizationCache(string $organizationId): void + { + $themeKey = $this->getThemeCacheKey($organizationId); + $versionKey = self::CACHE_PREFIX.'version:'.$organizationId; + $configKey = self::CACHE_PREFIX.'config:'.$organizationId; + + // Clear theme cache from Laravel Cache + Cache::forget($themeKey); + Cache::forget($versionKey); + Cache::forget($configKey); + + // Clear individual config element caches + $configKeys = [ + self::CACHE_PREFIX."config:{$organizationId}:platform_name", + self::CACHE_PREFIX."config:{$organizationId}:primary_color", + self::CACHE_PREFIX."config:{$organizationId}:secondary_color", + self::CACHE_PREFIX."config:{$organizationId}:accent_color", + ]; + foreach ($configKeys as $key) { + Cache::forget($key); + } + + // Clear asset caches + $this->clearAssetCache($organizationId); + + // Clear from Redis if available - must clear specific keys used by getCachedTheme + if ($this->isRedisAvailable()) { + // Clear specific keys that getCachedTheme checks + Redis::del($themeKey); + Redis::del($versionKey); + Redis::del($configKey); + + // Also clear any pattern-matched keys + $pattern = self::CACHE_PREFIX."*{$organizationId}*"; + $keys = Redis::keys($pattern); + if (! empty($keys)) { + Redis::del($keys); + } + + // Clear asset keys + $assetTypes = ['logo', 'favicon', 'favicon-16', 'favicon-32', 'favicon-64', 'favicon-128', 'favicon-192']; + foreach ($assetTypes as $type) { + $assetKey = $this->getAssetCacheKey($organizationId, $type); + Redis::del($assetKey); + } + } + + // Trigger cache warming in background (skip in testing to avoid interference) + if (! app()->environment('testing')) { + $this->warmCache($organizationId); + } + } + + /** + * Clear cache for a specific domain + */ + public function clearDomainCache(string $domain): void + { + $key = self::DOMAIN_CACHE_PREFIX.$domain; + + Cache::forget($key); + + if ($this->isRedisAvailable()) { + Redis::del($key); + } + } + + /** + * Clear asset cache for organization + */ + protected function clearAssetCache(string $organizationId): void + { + $assetTypes = ['logo', 'favicon', 'favicon-16', 'favicon-32', 'favicon-64', 'favicon-128', 'favicon-192']; + + foreach ($assetTypes as $type) { + Cache::forget($this->getAssetCacheKey($organizationId, $type)); + } + } + + /** + * Warm cache for organization (background job) + */ + public function warmCache(string $organizationId): void + { + // This would typically dispatch a background job + // to pre-generate and cache theme CSS and assets + dispatch(function () use ($organizationId) { + // Fetch WhiteLabelConfig and regenerate cache + $config = \App\Models\WhiteLabelConfig::where('organization_id', $organizationId)->first(); + if ($config) { + app(WhiteLabelService::class)->compileTheme($config); + } + })->afterResponse(); + } + + /** + * Get cache statistics for monitoring + */ + public function getCacheStats(string $organizationId): array + { + $stats = [ + 'theme_cached' => (bool) $this->getCachedTheme($organizationId), + 'theme_version' => $this->getThemeVersion($organizationId), + 'logo_cached' => (bool) $this->getCachedAssetUrl($organizationId, 'logo'), + 'config_cached' => (bool) $this->getCachedBrandingConfig($organizationId), + 'cache_size' => 0, + ]; + + // Calculate approximate cache size + if ($theme = $this->getCachedTheme($organizationId)) { + $stats['cache_size'] += strlen($theme); + } + + if ($config = $this->getCachedBrandingConfig($organizationId)) { + $stats['cache_size'] += strlen(serialize($config)); + } + + $stats['cache_size_formatted'] = $this->formatBytes($stats['cache_size']); + + return $stats; + } + + /** + * Invalidate cache based on patterns + */ + public function invalidateByPattern(string $pattern): int + { + $count = 0; + + if ($this->isRedisAvailable()) { + $keys = Redis::keys(self::CACHE_PREFIX.$pattern); + if (! empty($keys)) { + $count = Redis::del($keys); + } + } + + // Also clear from Laravel cache + // Note: This requires cache tags support + if (method_exists(Cache::getStore(), 'tags')) { + Cache::tags(['branding'])->flush(); + } + + return $count; + } + + /** + * Cache compiled CSS with versioning + */ + public function cacheCompiledCss(string $organizationId, string $css, array $metadata = []): void + { + $version = $metadata['version'] ?? time(); + $key = self::THEME_CACHE_PREFIX."{$organizationId}:v{$version}"; + + // Store with version + Cache::put($key, $css, self::CACHE_TTL); + + // Update current version pointer + Cache::put(self::THEME_CACHE_PREFIX."{$organizationId}:current", $version, self::CACHE_TTL); + + // Store metadata + if (! empty($metadata)) { + Cache::put(self::THEME_CACHE_PREFIX."{$organizationId}:meta", $metadata, self::CACHE_TTL); + } + } + + /** + * Get current CSS version + */ + public function getCurrentCssVersion(string $organizationId): ?string + { + $version = Cache::get(self::THEME_CACHE_PREFIX."{$organizationId}:current"); + + if ($version) { + return Cache::get(self::THEME_CACHE_PREFIX."{$organizationId}:v{$version}"); + } + + return null; + } + + /** + * Helper: Get theme cache key + */ + protected function getThemeCacheKey(string $organizationId): string + { + return self::THEME_CACHE_PREFIX.$organizationId; + } + + /** + * Helper: Get asset cache key + */ + protected function getAssetCacheKey(string $organizationId, string $assetType): string + { + return self::ASSET_CACHE_PREFIX."{$organizationId}:{$assetType}"; + } + + /** + * Helper: Check if Redis is available + */ + protected function isRedisAvailable(): bool + { + try { + Redis::ping(); + + return true; + } catch (\Exception $e) { + return false; + } + } + + /** + * Helper: Format bytes to human readable + */ + protected function formatBytes(int $bytes): string + { + $units = ['B', 'KB', 'MB', 'GB']; + $i = 0; + + while ($bytes >= 1024 && $i < count($units) - 1) { + $bytes /= 1024; + $i++; + } + + return round($bytes, 2).' '.$units[$i]; + } +} diff --git a/app/Services/Enterprise/CssValidationService.php b/app/Services/Enterprise/CssValidationService.php new file mode 100644 index 00000000000..59cba6e9986 --- /dev/null +++ b/app/Services/Enterprise/CssValidationService.php @@ -0,0 +1,113 @@ +stripDangerousPatterns($css); + + // 2. Parse and validate CSS (if sabberworm is available) + try { + if (class_exists(\Sabberworm\CSS\Parser::class)) { + $parsed = $this->parseAndValidate($sanitized); + + return $parsed; + } + + // Fallback: return sanitized CSS if parser not available + return $sanitized; + } catch (\Exception $e) { + Log::warning('Invalid custom CSS provided', [ + 'error' => $e->getMessage(), + 'css_length' => strlen($css), + ]); + + return '/* Invalid CSS removed - please check syntax */'; + } + } + + private function stripDangerousPatterns(string $css): string + { + // Remove HTML tags + $css = strip_tags($css); + + // Remove dangerous CSS patterns + foreach (self::DANGEROUS_PATTERNS as $pattern) { + $css = str_ireplace($pattern, '', $css); + } + + // Remove potential XSS vectors + $css = preg_replace('/]*>.*?<\/script>/is', '', $css); + $css = preg_replace('/on\w+\s*=\s*["\'].*?["\']/is', '', $css); + + return $css; + } + + private function parseAndValidate(string $css): string + { + if (! class_exists(\Sabberworm\CSS\Parser::class)) { + return $css; + } + + $parser = new \Sabberworm\CSS\Parser($css); + $document = $parser->parse(); + + // Remove any @import rules that might have slipped through + $this->removeImports($document); + + return $document->render(); + } + + private function removeImports(\Sabberworm\CSS\CSSList\Document $document): void + { + foreach ($document->getContents() as $item) { + if ($item instanceof \Sabberworm\CSS\RuleSet\AtRuleSet) { + if (stripos($item->atRuleName(), 'import') !== false) { + $document->remove($item); + } + } + } + } + + public function validate(string $css): array + { + $errors = []; + + // Check for dangerous patterns + foreach (self::DANGEROUS_PATTERNS as $pattern) { + if (stripos($css, $pattern) !== false) { + $errors[] = "Dangerous pattern detected: {$pattern}"; + } + } + + // Validate CSS syntax (if parser available) + if (class_exists(\Sabberworm\CSS\Parser::class)) { + try { + $parser = new \Sabberworm\CSS\Parser($css); + $parser->parse(); + } catch (\Exception $e) { + $errors[] = "CSS syntax error: {$e->getMessage()}"; + } + } + + return [ + 'valid' => empty($errors), + 'errors' => $errors, + ]; + } +} diff --git a/app/Services/Enterprise/DomainValidationService.php b/app/Services/Enterprise/DomainValidationService.php new file mode 100644 index 00000000000..e6a38c83a30 --- /dev/null +++ b/app/Services/Enterprise/DomainValidationService.php @@ -0,0 +1,495 @@ + false, + 'records' => [], + 'errors' => [], + 'warnings' => [], + ]; + + try { + // Check various DNS record types + foreach (self::DNS_RECORD_TYPES as $type) { + $records = $this->getDnsRecords($domain, $type); + if (! empty($records)) { + $results['records'][$type] = $records; + } + } + + // Check if domain resolves to an IP + $ip = gethostbyname($domain); + if ($ip !== $domain) { + $results['valid'] = true; + $results['resolved_ip'] = $ip; + + // Verify the IP points to our servers (if configured) + $this->verifyServerPointing($ip, $results); + } else { + $results['errors'][] = 'Domain does not resolve to any IP address'; + } + + // Check for wildcard DNS if subdomain + if (substr_count($domain, '.') > 1) { + $this->checkWildcardDns($domain, $results); + } + + // Check nameservers + $this->checkNameservers($domain, $results); + + } catch (\Exception $e) { + $results['errors'][] = 'DNS validation error: '.$e->getMessage(); + Log::error('DNS validation failed', [ + 'domain' => $domain, + 'error' => $e->getMessage(), + ]); + } + + return $results; + } + + /** + * Get DNS records for a domain + */ + protected function getDnsRecords(string $domain, string $type): array + { + $records = []; + + switch ($type) { + case 'A': + $dnsRecords = dns_get_record($domain, DNS_A); + break; + case 'AAAA': + $dnsRecords = dns_get_record($domain, DNS_AAAA); + break; + case 'CNAME': + $dnsRecords = dns_get_record($domain, DNS_CNAME); + break; + default: + $dnsRecords = []; + } + + foreach ($dnsRecords as $record) { + $records[] = [ + 'type' => $type, + 'value' => $record['ip'] ?? $record['ipv6'] ?? $record['target'] ?? null, + 'ttl' => $record['ttl'] ?? null, + ]; + } + + return $records; + } + + /** + * Verify if IP points to our servers + */ + protected function verifyServerPointing(string $ip, array &$results): void + { + // Get configured server IPs from environment or config + $serverIps = config('whitelabel.server_ips', []); + + if (empty($serverIps)) { + $results['warnings'][] = 'Server IP verification not configured'; + + return; + } + + if (in_array($ip, $serverIps)) { + $results['server_pointing'] = true; + $results['info'][] = 'Domain correctly points to application servers'; + } else { + $results['warnings'][] = 'Domain does not point to application servers'; + $results['server_pointing'] = false; + } + } + + /** + * Check wildcard DNS configuration + */ + protected function checkWildcardDns(string $domain, array &$results): void + { + $parts = explode('.', $domain); + array_shift($parts); // Remove subdomain + $parentDomain = implode('.', $parts); + + $wildcardDomain = '*.'.$parentDomain; + $ip = gethostbyname('test-'.uniqid().'.'.$parentDomain); + + if ($ip !== 'test-'.uniqid().'.'.$parentDomain) { + $results['wildcard_dns'] = true; + $results['info'][] = 'Wildcard DNS is configured for parent domain'; + } + } + + /** + * Check nameservers + */ + protected function checkNameservers(string $domain, array &$results): void + { + $nsRecords = dns_get_record($domain, DNS_NS); + + if (! empty($nsRecords)) { + $results['nameservers'] = array_map(function ($record) { + return $record['target'] ?? null; + }, $nsRecords); + } + } + + /** + * Validate SSL certificate for a domain + */ + public function validateSsl(string $domain): array + { + $results = [ + 'valid' => false, + 'certificate' => [], + 'errors' => [], + 'warnings' => [], + ]; + + try { + // Get SSL certificate information + $certInfo = $this->getSslCertificate($domain); + + if ($certInfo) { + $results['certificate'] = $certInfo; + + // Validate certificate + $validation = $this->validateCertificate($certInfo, $domain); + $results = array_merge($results, $validation); + } else { + $results['errors'][] = 'Could not retrieve SSL certificate'; + } + + // Check SSL/TLS configuration + $this->checkSslConfiguration($domain, $results); + + } catch (\Exception $e) { + $results['errors'][] = 'SSL validation error: '.$e->getMessage(); + Log::error('SSL validation failed', [ + 'domain' => $domain, + 'error' => $e->getMessage(), + ]); + } + + return $results; + } + + /** + * Get SSL certificate information + */ + protected function getSslCertificate(string $domain): ?array + { + $context = stream_context_create([ + 'ssl' => [ + 'capture_peer_cert' => true, + 'verify_peer' => false, + 'verify_peer_name' => false, + 'allow_self_signed' => true, + ], + ]); + + $stream = @stream_socket_client( + "ssl://{$domain}:".self::SSL_PORT, + $errno, + $errstr, + self::SSL_TIMEOUT, + STREAM_CLIENT_CONNECT, + $context + ); + + if (! $stream) { + return null; + } + + $params = stream_context_get_params($stream); + fclose($stream); + + if (! isset($params['options']['ssl']['peer_certificate'])) { + return null; + } + + $cert = $params['options']['ssl']['peer_certificate']; + $certInfo = openssl_x509_parse($cert); + + if (! $certInfo) { + return null; + } + + return [ + 'subject' => $certInfo['subject']['CN'] ?? null, + 'issuer' => $certInfo['issuer']['O'] ?? null, + 'valid_from' => date('Y-m-d H:i:s', $certInfo['validFrom_time_t']), + 'valid_to' => date('Y-m-d H:i:s', $certInfo['validTo_time_t']), + 'san' => $this->extractSan($certInfo), + 'signature_algorithm' => $certInfo['signatureTypeSN'] ?? null, + ]; + } + + /** + * Extract Subject Alternative Names from certificate + */ + protected function extractSan(array $certInfo): array + { + $san = []; + + if (isset($certInfo['extensions']['subjectAltName'])) { + $sanString = $certInfo['extensions']['subjectAltName']; + $parts = explode(',', $sanString); + + foreach ($parts as $part) { + $part = trim($part); + if (strpos($part, 'DNS:') === 0) { + $san[] = substr($part, 4); + } + } + } + + return $san; + } + + /** + * Validate certificate details + */ + protected function validateCertificate(array $certInfo, string $domain): array + { + $results = [ + 'valid' => true, + 'checks' => [], + ]; + + // Check if certificate is valid for domain + $validForDomain = false; + if ($certInfo['subject'] === $domain || $certInfo['subject'] === '*.'.substr($domain, strpos($domain, '.') + 1)) { + $validForDomain = true; + } elseif (in_array($domain, $certInfo['san'])) { + $validForDomain = true; + } elseif (in_array('*.'.substr($domain, strpos($domain, '.') + 1), $certInfo['san'])) { + $validForDomain = true; + } + + $results['checks']['domain_match'] = $validForDomain; + if (! $validForDomain) { + $results['errors'][] = 'Certificate is not valid for this domain'; + $results['valid'] = false; + } + + // Check expiration + $validTo = strtotime($certInfo['valid_to']); + $now = time(); + $daysUntilExpiry = ($validTo - $now) / 86400; + + $results['checks']['days_until_expiry'] = round($daysUntilExpiry); + + if ($daysUntilExpiry < 0) { + $results['errors'][] = 'Certificate has expired'; + $results['valid'] = false; + } elseif ($daysUntilExpiry < 30) { + $results['warnings'][] = 'Certificate expires in less than 30 days'; + } + + // Check if certificate is not yet valid + $validFrom = strtotime($certInfo['valid_from']); + if ($validFrom > $now) { + $results['errors'][] = 'Certificate is not yet valid'; + $results['valid'] = false; + } + + // Check issuer (warn if self-signed) + if (isset($certInfo['issuer']) && stripos($certInfo['issuer'], 'Let\'s Encrypt') === false + && stripos($certInfo['issuer'], 'DigiCert') === false + && stripos($certInfo['issuer'], 'GlobalSign') === false + && stripos($certInfo['issuer'], 'Sectigo') === false) { + $results['warnings'][] = 'Certificate issuer is not a well-known CA'; + } + + return $results; + } + + /** + * Check SSL/TLS configuration + */ + protected function checkSslConfiguration(string $domain, array &$results): void + { + try { + // Test HTTPS connectivity + $response = Http::timeout(self::SSL_TIMEOUT) + ->withOptions(['verify' => false]) + ->get("https://{$domain}"); + + if ($response->successful()) { + $results['https_accessible'] = true; + + // Check for security headers + $this->checkSecurityHeaders($response->headers(), $results); + } else { + $results['warnings'][] = 'HTTPS endpoint returned non-200 status code'; + } + + } catch (\Exception $e) { + $results['warnings'][] = 'Could not test HTTPS connectivity'; + } + } + + /** + * Check security headers + */ + protected function checkSecurityHeaders(array $headers, array &$results): void + { + $securityHeaders = [ + 'Strict-Transport-Security' => 'HSTS', + 'X-Content-Type-Options' => 'X-Content-Type-Options', + 'X-Frame-Options' => 'X-Frame-Options', + 'Content-Security-Policy' => 'CSP', + ]; + + $results['security_headers'] = []; + + foreach ($securityHeaders as $header => $name) { + $headerLower = strtolower($header); + $found = false; + + foreach ($headers as $key => $value) { + if (strtolower($key) === $headerLower) { + $results['security_headers'][$name] = true; + $found = true; + break; + } + } + + if (! $found) { + $results['security_headers'][$name] = false; + $results['warnings'][] = "Missing security header: {$name}"; + } + } + } + + /** + * Verify domain ownership via DNS TXT record + */ + public function verifyDomainOwnership(string $domain, string $verificationToken): bool + { + $txtRecords = dns_get_record($domain, DNS_TXT); + + foreach ($txtRecords as $record) { + if (isset($record['txt']) && $record['txt'] === "coolify-verify={$verificationToken}") { + return true; + } + } + + return false; + } + + /** + * Generate domain verification token + */ + public function generateVerificationToken(string $domain, string $organizationId): string + { + return hash('sha256', $domain.$organizationId.config('app.key')); + } + + /** + * Check if domain is already in use + */ + public function isDomainAvailable(string $domain): bool + { + // Check if domain is already configured for another organization + $existing = \App\Models\WhiteLabelConfig::whereJsonContains('custom_domains', $domain)->first(); + + return $existing === null; + } + + /** + * Perform comprehensive domain validation + */ + public function performComprehensiveValidation(string $domain, string $organizationId): array + { + $results = [ + 'domain' => $domain, + 'timestamp' => now()->toIso8601String(), + 'checks' => [], + ]; + + // Check domain availability + $results['checks']['available'] = $this->isDomainAvailable($domain); + if (! $results['checks']['available']) { + $results['valid'] = false; + $results['errors'][] = 'Domain is already in use by another organization'; + + return $results; + } + + // Validate DNS + $dnsResults = $this->validateDns($domain); + $results['checks']['dns'] = $dnsResults; + + // Validate SSL + $sslResults = $this->validateSsl($domain); + $results['checks']['ssl'] = $sslResults; + + // Check domain ownership + $verificationToken = $this->generateVerificationToken($domain, $organizationId); + $results['checks']['ownership'] = $this->verifyDomainOwnership($domain, $verificationToken); + $results['verification_token'] = $verificationToken; + + // Determine overall validity + $results['valid'] = $dnsResults['valid'] && + $sslResults['valid'] && + $results['checks']['available']; + + // Add recommendations + $this->addRecommendations($results); + + return $results; + } + + /** + * Add recommendations based on validation results + */ + protected function addRecommendations(array &$results): void + { + $recommendations = []; + + if (! $results['checks']['ownership']) { + $recommendations[] = [ + 'type' => 'dns_txt', + 'message' => 'Add TXT record with value: coolify-verify='.$results['verification_token'], + ]; + } + + if (! $results['checks']['ssl']['valid']) { + $recommendations[] = [ + 'type' => 'ssl', + 'message' => 'Install a valid SSL certificate for the domain', + ]; + } + + if (isset($results['checks']['dns']['server_pointing']) && ! $results['checks']['dns']['server_pointing']) { + $recommendations[] = [ + 'type' => 'dns_a', + 'message' => 'Point domain A record to application servers', + ]; + } + + $results['recommendations'] = $recommendations; + } +} diff --git a/app/Services/Enterprise/EmailTemplateService.php b/app/Services/Enterprise/EmailTemplateService.php new file mode 100644 index 00000000000..272808f35a3 --- /dev/null +++ b/app/Services/Enterprise/EmailTemplateService.php @@ -0,0 +1,972 @@ +cssInliner = new CssToInlineStyles; + $this->setDefaultVariables(); + } + + /** + * Set default template variables + */ + protected function setDefaultVariables(): void + { + $this->defaultVariables = [ + 'app_name' => config('app.name', 'Coolify'), + 'app_url' => config('app.url'), + 'support_email' => config('mail.from.address'), + 'current_year' => date('Y'), + 'logo_url' => asset('images/logo.png'), + ]; + } + + /** + * Generate email template with branding + */ + public function generateTemplate(WhiteLabelConfig $config, string $templateName, array $data = []): string + { + // Merge branding variables with template data + $variables = $this->prepareBrandingVariables($config, $data); + + // Get template content + $template = $this->getTemplate($config, $templateName); + + // Process template with variables + $html = $this->processTemplate($template, $variables); + + // Apply branding styles + $html = $this->applyBrandingStyles($html, $config); + + // Inline CSS for email compatibility + $html = $this->inlineCss($html, $config); + + return $html; + } + + /** + * Prepare branding variables for template + */ + protected function prepareBrandingVariables(WhiteLabelConfig $config, array $data): array + { + $brandingVars = [ + 'platform_name' => $config->getPlatformName(), + 'logo_url' => $config->getLogoUrl() ?: $this->defaultVariables['logo_url'], + 'primary_color' => $config->getThemeVariable('primary_color', '#3b82f6'), + 'secondary_color' => $config->getThemeVariable('secondary_color', '#1f2937'), + 'accent_color' => $config->getThemeVariable('accent_color', '#10b981'), + 'text_color' => $config->getThemeVariable('text_color', '#1f2937'), + 'background_color' => $config->getThemeVariable('background_color', '#ffffff'), + 'hide_branding' => $config->shouldHideCoolifyBranding(), + ]; + + return array_merge($this->defaultVariables, $brandingVars, $data); + } + + /** + * Get template content + */ + protected function getTemplate(WhiteLabelConfig $config, string $templateName): string + { + // Check for custom template + if ($config->hasCustomEmailTemplate($templateName)) { + $customTemplate = $config->getEmailTemplate($templateName); + + return $customTemplate['content'] ?? $this->getDefaultTemplate($templateName); + } + + return $this->getDefaultTemplate($templateName); + } + + /** + * Get default template + */ + protected function getDefaultTemplate(string $templateName): string + { + $templates = [ + 'welcome' => $this->getWelcomeTemplate(), + 'password_reset' => $this->getPasswordResetTemplate(), + 'email_verification' => $this->getEmailVerificationTemplate(), + 'invitation' => $this->getInvitationTemplate(), + 'deployment_success' => $this->getDeploymentSuccessTemplate(), + 'deployment_failure' => $this->getDeploymentFailureTemplate(), + 'server_unreachable' => $this->getServerUnreachableTemplate(), + 'backup_success' => $this->getBackupSuccessTemplate(), + 'backup_failure' => $this->getBackupFailureTemplate(), + ]; + + return $templates[$templateName] ?? $this->getGenericTemplate(); + } + + /** + * Process template with variables + */ + protected function processTemplate(string $template, array $variables): string + { + // Replace variables in template + foreach ($variables as $key => $value) { + if (is_string($value) || is_numeric($value)) { + $template = str_replace( + ['{{'.$key.'}}', '{{ '.$key.' }}'], + $value, + $template + ); + } + } + + // Process conditionals + $template = $this->processConditionals($template, $variables); + + // Process loops + $template = $this->processLoops($template, $variables); + + return $template; + } + + /** + * Process conditional statements in template + */ + protected function processConditionals(string $template, array $variables): string + { + // Process @if statements + $pattern = '/@if\s*\((.*?)\)(.*?)@endif/s'; + $template = preg_replace_callback($pattern, function ($matches) use ($variables) { + $condition = $matches[1]; + $content = $matches[2]; + + // Simple variable check + if (isset($variables[$condition]) && $variables[$condition]) { + return $content; + } + + return ''; + }, $template); + + // Process @unless statements + $pattern = '/@unless\s*\((.*?)\)(.*?)@endunless/s'; + $template = preg_replace_callback($pattern, function ($matches) use ($variables) { + $condition = $matches[1]; + $content = $matches[2]; + + if (! isset($variables[$condition]) || ! $variables[$condition]) { + return $content; + } + + return ''; + }, $template); + + return $template; + } + + /** + * Process loops in template + */ + protected function processLoops(string $template, array $variables): string + { + // Process @foreach loops + $pattern = '/@foreach\s*\((.*?)\s+as\s+(.*?)\)(.*?)@endforeach/s'; + $template = preg_replace_callback($pattern, function ($matches) use ($variables) { + $arrayName = trim($matches[1]); + $itemName = trim($matches[2]); + $content = $matches[3]; + + if (! isset($variables[$arrayName]) || ! is_array($variables[$arrayName])) { + return ''; + } + + $output = ''; + foreach ($variables[$arrayName] as $item) { + $itemContent = $content; + if (is_array($item)) { + foreach ($item as $key => $value) { + if (is_string($value) || is_numeric($value)) { + $itemContent = str_replace( + ['{{'.$itemName.'.'.$key.'}}', '{{ '.$itemName.'.'.$key.' }}'], + $value, + $itemContent + ); + } + } + } else { + $itemContent = str_replace( + ['{{'.$itemName.'}}', '{{ '.$itemName.' }}'], + $item, + $itemContent + ); + } + $output .= $itemContent; + } + + return $output; + }, $template); + + return $template; + } + + /** + * Apply branding styles to HTML + */ + protected function applyBrandingStyles(string $html, WhiteLabelConfig $config): string + { + $styles = $this->generateEmailStyles($config); + + // Insert styles into head or create head if not exists + if (stripos($html, '') !== false) { + $html = str_ireplace('', "", $html); + } else { + $html = "{$html}"; + } + + return $html; + } + + /** + * Generate email-specific styles + */ + protected function generateEmailStyles(WhiteLabelConfig $config): string + { + $primaryColor = $config->getThemeVariable('primary_color', '#3b82f6'); + $secondaryColor = $config->getThemeVariable('secondary_color', '#1f2937'); + $textColor = $config->getThemeVariable('text_color', '#1f2937'); + $backgroundColor = $config->getThemeVariable('background_color', '#ffffff'); + + $styles = " + body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; + line-height: 1.6; + color: {$textColor}; + background-color: #f5f5f5; + margin: 0; + padding: 0; + } + .email-wrapper { + max-width: 600px; + margin: 0 auto; + background-color: {$backgroundColor}; + } + .email-header { + background-color: {$primaryColor}; + padding: 30px; + text-align: center; + } + .email-header img { + max-height: 50px; + max-width: 200px; + } + .email-body { + padding: 40px 30px; + } + .email-footer { + background-color: #f9fafb; + padding: 30px; + text-align: center; + font-size: 14px; + color: #6b7280; + } + h1, h2, h3 { + color: {$secondaryColor}; + margin-top: 0; + } + .btn { + display: inline-block; + padding: 12px 24px; + background-color: {$primaryColor}; + color: white; + text-decoration: none; + border-radius: 5px; + font-weight: 600; + } + .btn:hover { + opacity: 0.9; + } + .alert { + padding: 15px; + border-radius: 5px; + margin: 20px 0; + } + .alert-success { + background-color: #d4edda; + border-color: #c3e6cb; + color: #155724; + } + .alert-error { + background-color: #f8d7da; + border-color: #f5c6cb; + color: #721c24; + } + .alert-warning { + background-color: #fff3cd; + border-color: #ffeeba; + color: #856404; + } + .alert-info { + background-color: #d1ecf1; + border-color: #bee5eb; + color: #0c5460; + } + table { + width: 100%; + border-collapse: collapse; + margin: 20px 0; + } + th, td { + padding: 12px; + text-align: left; + border-bottom: 1px solid #e5e7eb; + } + th { + background-color: #f9fafb; + font-weight: 600; + color: {$secondaryColor}; + } + "; + + // Add custom CSS if provided + if ($config->custom_css) { + $styles .= "\n/* Custom CSS */\n".$config->custom_css; + } + + return $styles; + } + + /** + * Inline CSS for email compatibility + */ + protected function inlineCss(string $html, WhiteLabelConfig $config): string + { + // Extract styles from HTML + preg_match_all('/]*>(.*?)<\/style>/si', $html, $matches); + $css = implode("\n", $matches[1]); + + // Remove style tags + $html = preg_replace('/]*>.*?<\/style>/si', '', $html); + + // Inline the CSS + if (! empty($css)) { + $html = $this->cssInliner->convert($html, $css); + } + + return $html; + } + + /** + * Get welcome email template + */ + protected function getWelcomeTemplate(): string + { + return ' + + + + + + + + +'; + } + + /** + * Get password reset email template + */ + protected function getPasswordResetTemplate(): string + { + return ' + + + + + + + + +'; + } + + /** + * Get email verification template + */ + protected function getEmailVerificationTemplate(): string + { + return ' + + + + + + + + +'; + } + + /** + * Get invitation email template + */ + protected function getInvitationTemplate(): string + { + return ' + + + + + + + + +'; + } + + /** + * Get deployment success email template + */ + protected function getDeploymentSuccessTemplate(): string + { + return ' + + + + + + + + +'; + } + + /** + * Get deployment failure email template + */ + protected function getDeploymentFailureTemplate(): string + { + return ' + + + + + + + + +'; + } + + /** + * Get server unreachable email template + */ + protected function getServerUnreachableTemplate(): string + { + return ' + + + + + + + + +'; + } + + /** + * Get backup success email template + */ + protected function getBackupSuccessTemplate(): string + { + return ' + + + + + + + + +'; + } + + /** + * Get backup failure email template + */ + protected function getBackupFailureTemplate(): string + { + return ' + + + + + + + + +'; + } + + /** + * Get generic email template + */ + protected function getGenericTemplate(): string + { + return ' + + + + + + + + +'; + } + + /** + * Preview email template + */ + public function previewTemplate(WhiteLabelConfig $config, string $templateName, array $sampleData = []): array + { + // Generate sample data if not provided + if (empty($sampleData)) { + $sampleData = $this->getSampleData($templateName); + } + + // Generate HTML + $html = $this->generateTemplate($config, $templateName, $sampleData); + + // Generate text version + $text = $this->generateTextVersion($html); + + return [ + 'html' => $html, + 'text' => $text, + 'subject' => $this->getTemplateSubject($templateName, $sampleData), + ]; + } + + /** + * Generate text version of email + */ + protected function generateTextVersion(string $html): string + { + // Remove HTML tags + $text = strip_tags($html); + + // Clean up whitespace + $text = preg_replace('/\s+/', ' ', $text); + $text = preg_replace('/\s*\n\s*/', "\n", $text); + + return trim($text); + } + + /** + * Get template subject + */ + protected function getTemplateSubject(string $templateName, array $data): string + { + $subjects = [ + 'welcome' => 'Welcome to '.($data['platform_name'] ?? 'Our Platform'), + 'password_reset' => 'Password Reset Request', + 'email_verification' => 'Verify Your Email Address', + 'invitation' => 'You\'ve Been Invited to Join '.($data['organization_name'] ?? 'Our Organization'), + 'deployment_success' => 'Deployment Successful: '.($data['application_name'] ?? 'Your Application'), + 'deployment_failure' => 'Deployment Failed: '.($data['application_name'] ?? 'Your Application'), + 'server_unreachable' => 'Server Alert: '.($data['server_name'] ?? 'Server').' is Unreachable', + 'backup_success' => 'Backup Completed Successfully', + 'backup_failure' => 'Backup Failed: Action Required', + ]; + + return $subjects[$templateName] ?? 'Notification from '.($data['platform_name'] ?? 'Platform'); + } + + /** + * Get sample data for template preview + */ + protected function getSampleData(string $templateName): array + { + $baseData = [ + 'user_name' => 'John Doe', + 'platform_name' => 'Coolify Enterprise', + 'organization_name' => 'Acme Corporation', + 'current_year' => date('Y'), + ]; + + $templateSpecificData = [ + 'welcome' => [ + 'login_url' => 'https://example.com/login', + ], + 'password_reset' => [ + 'reset_url' => 'https://example.com/reset?token=abc123', + 'expiry_hours' => 24, + 'expiry_date' => now()->addHours(24)->format('F j, Y at g:i A'), + ], + 'email_verification' => [ + 'verification_url' => 'https://example.com/verify?token=xyz789', + ], + 'invitation' => [ + 'inviter_name' => 'Jane Smith', + 'invitee_name' => 'John Doe', + 'invitation_url' => 'https://example.com/invite?token=inv456', + 'expiry_date' => now()->addDays(7)->format('F j, Y'), + ], + 'deployment_success' => [ + 'application_name' => 'My Awesome App', + 'environment' => 'Production', + 'version' => 'v1.2.3', + 'deployed_at' => now()->format('F j, Y at g:i A'), + 'deploy_duration' => '2 minutes 15 seconds', + 'application_url' => 'https://myapp.example.com', + ], + 'deployment_failure' => [ + 'application_name' => 'My Awesome App', + 'environment' => 'Production', + 'failed_at' => now()->format('F j, Y at g:i A'), + 'error_message' => 'Build failed: npm install exited with code 1', + 'error_log' => 'npm ERR! code ERESOLVE...', + 'deployment_logs_url' => 'https://example.com/deployments/123/logs', + ], + 'server_unreachable' => [ + 'server_name' => 'Production Server 1', + 'server_ip' => '192.168.1.100', + 'last_seen' => now()->subMinutes(30)->format('F j, Y at g:i A'), + 'affected_applications' => '3', + 'server_dashboard_url' => 'https://example.com/servers/1', + ], + 'backup_success' => [ + 'resource_name' => 'Production Database', + 'backup_type' => 'Full Backup', + 'backup_size' => '2.5 GB', + 'completed_at' => now()->format('F j, Y at g:i A'), + 'backup_duration' => '5 minutes 30 seconds', + 'storage_location' => 'Amazon S3', + ], + 'backup_failure' => [ + 'resource_name' => 'Production Database', + 'backup_type' => 'Full Backup', + 'failed_at' => now()->format('F j, Y at g:i A'), + 'error_message' => 'Storage quota exceeded', + 'backup_dashboard_url' => 'https://example.com/backups', + ], + ]; + + return array_merge($baseData, $templateSpecificData[$templateName] ?? []); + } +} diff --git a/app/Services/Enterprise/SassCompilationService.php b/app/Services/Enterprise/SassCompilationService.php new file mode 100644 index 00000000000..2581d5c8d4e --- /dev/null +++ b/app/Services/Enterprise/SassCompilationService.php @@ -0,0 +1,132 @@ +compiler = new Compiler; + $this->compiler->setOutputStyle(OutputStyle::COMPRESSED); + // Set import paths to allow `@import 'variables';` + $this->compiler->setImportPaths(resource_path('sass/branding')); + } + + /** + * Compiles the main branding SASS file with theme variables. + * + * @param WhiteLabelConfig $config The white-label configuration. + * @return string The compiled CSS. + * + * @throws \Exception If the SASS template file is not found or compilation fails. + */ + public function compile(WhiteLabelConfig $config): string + { + $templatePath = resource_path('sass/branding/theme.scss'); + if (! File::exists($templatePath)) { + throw new \Exception("SASS template not found at {$templatePath}"); + } + + $sassVariables = $this->generateSassVariables($config->theme_config); + $sassInput = $sassVariables."\n".File::get($templatePath); + + try { + return $this->compiler->compileString($sassInput)->getCss(); + } catch (\Exception $e) { + Log::error('SASS compilation failed.', ['error' => $e->getMessage()]); + throw new \Exception('SASS compilation failed.', 0, $e); + } + } + + /** + * Compiles the dark mode SASS file. + * + * @return string The compiled dark mode CSS. + * + * @throws \Exception If the dark mode SASS file is not found or compilation fails. + */ + public function compileDarkMode(): string + { + $darkModePath = resource_path('sass/branding/dark.scss'); + if (! File::exists($darkModePath)) { + throw new \Exception("Dark mode SASS file not found at {$darkModePath}"); + } + + try { + return $this->compiler->compileString(File::get($darkModePath))->getCss(); + } catch (\Exception $e) { + Log::error('Dark mode SASS compilation failed.', ['error' => $e->getMessage()]); + throw new \Exception('Dark mode SASS compilation failed.', 0, $e); + } + } + + /** + * Generates a SASS-compatible variable string from a theme config array. + */ + private function generateSassVariables(?array $themeConfig): string + { + if (empty($themeConfig)) { + return ''; + } + + $sassLines = []; + foreach ($themeConfig as $key => $value) { + if (is_string($value) && ! empty($value)) { + // Format key from snake_case to kebab-case for SASS variable + $sassKey = str_replace('_', '-', $key); + $sassLines[] = "\${$sassKey}: ".$this->formatSassValue($value).';'; + } + } + + return implode("\n", $sassLines); + } + + /** + * Formats a value for use in a SASS variable declaration. + * Ensures colors are treated as literals and other strings are quoted if necessary. + * @throws \Exception + */ + private function formatSassValue(string $value): string + { + $value = trim($value); + + // If it's a hex, rgb, rgba, hsl, hsla, or a CSS variable, return as is. + if ( + preg_match('/^#([a-f0-9]{3}){1,2}$/i', $value) || + preg_match('/^(rgb|rgba|hsl|hsla)\(/i', $value) || + preg_match('/^var\(--.*\)$/i', $value) + ) { + return $value; + } + + // If it's a named color, it's also a valid literal + $namedColors = ['transparent', 'currentColor', 'white', 'black', 'red', 'blue']; // Add more if needed + if (in_array(strtolower($value), $namedColors)) { + return $value; + } + + // For font families or other string values that might contain spaces, + // quote them if they aren't already. + if (str_contains($value, ' ') && ! preg_match('/^".*"$/', $value) && ! preg_match("/^'.*'$/", $value)) { + return '"'.$value.'"'; + } + + if (preg_match('/^[a-zA-Z0-9 #,.-]+$/', $value)) { + return $value; + } + + throw new \Exception("Invalid SASS value: {$value}"); + } +} diff --git a/app/Services/Enterprise/WhiteLabelService.php b/app/Services/Enterprise/WhiteLabelService.php new file mode 100644 index 00000000000..325c17aa871 --- /dev/null +++ b/app/Services/Enterprise/WhiteLabelService.php @@ -0,0 +1,518 @@ +cacheService = $cacheService; + $this->domainService = $domainService; + $this->emailService = $emailService; + } + + /** + * Get or create white label config for organization + */ + public function getOrCreateConfig(Organization $organization): WhiteLabelConfig + { + return WhiteLabelConfig::firstOrCreate( + ['organization_id' => $organization->id], + [ + 'platform_name' => $organization->name, + 'theme_config' => [], + 'custom_domains' => [], + 'hide_coolify_branding' => false, + 'custom_email_templates' => [], + ] + ); + } + + /** + * Get organization theme variables for SASS compilation + * + * @return array + */ + public function getOrganizationThemeVariables(Organization $organization): array + { + $config = $this->getOrCreateConfig($organization); + $themeVariables = $config->getThemeVariables(); + $defaults = config('enterprise.white_label.default_theme', []); + + // Merge with defaults, ensuring all required variables are present + $variables = array_merge($defaults, $themeVariables); + + // Ensure font_family is set + if (empty($variables['font_family'])) { + $variables['font_family'] = $defaults['font_family'] ?? 'Inter, sans-serif'; + } + + return $variables; + } + + /** + * Process and upload logo with validation and optimization + */ + public function processLogo(UploadedFile $file, Organization $organization): string + { + // Validate image file + $this->validateLogoFile($file); + + // Generate unique filename + $filename = $this->generateLogoFilename($organization, $file); + + // Process and optimize image + $image = Image::read($file); + + // Resize to maximum dimensions while maintaining aspect ratio + $image->scaleDown(width: 500, height: 200); + + // Store original logo + $path = "branding/logos/{$organization->id}/{$filename}"; + Storage::disk('public')->put($path, (string) $image->encode()); + + // Generate favicon versions + $this->generateFavicons($image, $organization); + + // Generate SVG version if applicable + if ($file->getClientOriginalExtension() !== 'svg') { + $this->generateSvgVersion($image, $organization); + } + + // Clear cache for this organization + $this->cacheService->clearOrganizationCache($organization->id); + + return Storage::url($path); + } + + /** + * Validate logo file + */ + protected function validateLogoFile(UploadedFile $file): void + { + $allowedMimes = ['image/jpeg', 'image/png', 'image/gif', 'image/svg+xml', 'image/webp']; + + if (! in_array($file->getMimeType(), $allowedMimes)) { + throw new \InvalidArgumentException('Invalid file type. Allowed types: JPG, PNG, GIF, SVG, WebP'); + } + + // Maximum file size: 5MB + if ($file->getSize() > 5 * 1024 * 1024) { + throw new \InvalidArgumentException('File size exceeds 5MB limit'); + } + } + + /** + * Generate unique logo filename + */ + protected function generateLogoFilename(Organization $organization, UploadedFile $file): string + { + $extension = $file->getClientOriginalExtension(); + $timestamp = now()->format('YmdHis'); + $hash = substr(md5($organization->id.$timestamp), 0, 8); + + return "logo_{$timestamp}_{$hash}.{$extension}"; + } + + /** + * Generate favicon versions from logo + */ + protected function generateFavicons($image, Organization $organization): void + { + $sizes = [16, 32, 64, 128, 192]; + + foreach ($sizes as $size) { + $favicon = clone $image; + $favicon->cover($size, $size); + + $path = "branding/favicons/{$organization->id}/favicon-{$size}x{$size}.png"; + Storage::disk('public')->put($path, (string) $favicon->toPng()); + } + + // Generate ICO file with multiple sizes + $this->generateIcoFile($organization); + } + + /** + * Generate ICO file with multiple sizes + */ + protected function generateIcoFile(Organization $organization): void + { + // This would require a specialized ICO library + // For now, we'll use the 32x32 PNG as a fallback + $source = Storage::disk('public')->get("branding/favicons/{$organization->id}/favicon-32x32.png"); + Storage::disk('public')->put("branding/favicons/{$organization->id}/favicon.ico", $source); + } + + /** + * Generate SVG version of logo for theming + */ + protected function generateSvgVersion($image, Organization $organization): void + { + // This would require image tracing library + // Placeholder for SVG generation logic + $path = "branding/logos/{$organization->id}/logo.svg"; + // Storage::disk('public')->put($path, $svgContent); + } + + /** + * Compile theme with SASS preprocessing + */ + public function compileTheme(WhiteLabelConfig $config): string + { + // Get theme variables + $variables = $config->getThemeVariables(); + + // Start with CSS variables + $css = $this->generateCssVariables($variables); + + // Add component-specific styles + $css .= $this->generateComponentStyles($variables); + + // Add dark mode styles if configured + if ($config->getThemeVariable('enable_dark_mode', false)) { + $css .= $this->generateDarkModeStyles($variables); + } + + // Add custom CSS if provided + if ($config->custom_css) { + $css .= "\n/* Custom CSS */\n".$config->custom_css; + } + + // Minify CSS in production + if (app()->environment('production')) { + $css = $this->minifyCss($css); + } + + // Cache compiled theme + $this->cacheService->cacheCompiledTheme($config->organization_id, $css); + + return $css; + } + + /** + * Generate CSS variables from theme config + */ + protected function generateCssVariables(array $variables): string + { + $css = ":root {\n"; + + foreach ($variables as $key => $value) { + $cssVar = '--'.str_replace('_', '-', $key); + $css .= " {$cssVar}: {$value};\n"; + + // Generate RGB versions for opacity support + if ($this->isHexColor($value)) { + $rgb = $this->hexToRgb($value); + $css .= " {$cssVar}-rgb: {$rgb};\n"; + } + } + + // Add derived colors + $css .= $this->generateDerivedColors($variables); + + $css .= "}\n"; + + return $css; + } + + /** + * Generate component-specific styles + */ + protected function generateComponentStyles(array $variables): string + { + $css = "\n/* Component Styles */\n"; + + // Button styles + $css .= ".btn-primary {\n"; + $css .= " background-color: var(--primary-color);\n"; + $css .= " border-color: var(--primary-color);\n"; + $css .= "}\n"; + + $css .= ".btn-primary:hover {\n"; + $css .= " background-color: var(--primary-color-dark);\n"; + $css .= " border-color: var(--primary-color-dark);\n"; + $css .= "}\n"; + + // Navigation styles + $css .= ".navbar {\n"; + $css .= " background-color: var(--sidebar-color);\n"; + $css .= " border-color: var(--border-color);\n"; + $css .= "}\n"; + + // Add more component styles as needed + + return $css; + } + + /** + * Generate dark mode styles + */ + protected function generateDarkModeStyles(array $variables): string + { + $css = "\n/* Dark Mode */\n"; + $css .= "@media (prefers-color-scheme: dark) {\n"; + $css .= " :root {\n"; + + // Invert or adjust colors for dark mode + $darkVariables = $this->generateDarkModeVariables($variables); + foreach ($darkVariables as $key => $value) { + $cssVar = '--'.str_replace('_', '-', $key); + $css .= " {$cssVar}: {$value};\n"; + } + + $css .= " }\n"; + $css .= "}\n"; + + $css .= ".dark {\n"; + foreach ($darkVariables as $key => $value) { + $cssVar = '--'.str_replace('_', '-', $key); + $css .= " {$cssVar}: {$value};\n"; + } + $css .= "}\n"; + + return $css; + } + + /** + * Generate dark mode color variables + */ + protected function generateDarkModeVariables(array $variables): array + { + $darkVariables = []; + + // Invert background and text colors + $darkVariables['background_color'] = '#1a1a1a'; + $darkVariables['text_color'] = '#f0f0f0'; + $darkVariables['sidebar_color'] = '#2a2a2a'; + $darkVariables['border_color'] = '#3a3a3a'; + + // Keep accent colors but adjust brightness + foreach (['primary', 'secondary', 'accent', 'success', 'warning', 'error', 'info'] as $colorName) { + $key = $colorName.'_color'; + if (isset($variables[$key])) { + $darkVariables[$key] = $this->adjustColorBrightness($variables[$key], 20); + } + } + + return $darkVariables; + } + + /** + * Generate derived colors (hover, focus, disabled states) + */ + protected function generateDerivedColors(array $variables): string + { + $css = " /* Derived Colors */\n"; + + foreach (['primary', 'secondary', 'accent'] as $colorName) { + $key = $colorName.'_color'; + if (isset($variables[$key])) { + $baseColor = $variables[$key]; + + // Generate lighter and darker variants + $css .= " --{$colorName}-color-light: ".$this->adjustColorBrightness($baseColor, 20).";\n"; + $css .= " --{$colorName}-color-dark: ".$this->adjustColorBrightness($baseColor, -20).";\n"; + $css .= " --{$colorName}-color-alpha: ".$this->addAlphaToColor($baseColor, 0.1).";\n"; + } + } + + return $css; + } + + /** + * Minify CSS for production + */ + protected function minifyCss(string $css): string + { + // Remove comments + $css = preg_replace('!/\*[^*]*\*+([^/][^*]*\*+)*/!', '', $css); + + // Remove unnecessary whitespace + $css = str_replace(["\r\n", "\r", "\n", "\t"], '', $css); + $css = preg_replace('/\s+/', ' ', $css); + $css = str_replace([' {', '{ ', ' }', '} ', ': ', ' ;'], ['{', '{', '}', '}', ':', ';'], $css); + + return trim($css); + } + + /** + * Validate and set custom domain + */ + public function setCustomDomain(WhiteLabelConfig $config, string $domain): array + { + // Validate domain format + if (! $config->isValidDomain($domain)) { + throw new \InvalidArgumentException('Invalid domain format'); + } + + // Check DNS configuration + $dnsValidation = $this->domainService->validateDns($domain); + if (! $dnsValidation['valid']) { + return [ + 'success' => false, + 'message' => 'DNS validation failed', + 'details' => $dnsValidation, + ]; + } + + // Check SSL certificate + $sslValidation = $this->domainService->validateSsl($domain); + if (! $sslValidation['valid'] && app()->environment('production')) { + return [ + 'success' => false, + 'message' => 'SSL validation failed', + 'details' => $sslValidation, + ]; + } + + // Add domain to config + $config->addCustomDomain($domain); + $config->save(); + + // Clear cache for domain-based branding + $this->cacheService->clearDomainCache($domain); + + return [ + 'success' => true, + 'message' => 'Domain configured successfully', + 'dns' => $dnsValidation, + 'ssl' => $sslValidation, + ]; + } + + /** + * Generate email template with branding + */ + public function generateEmailTemplate(WhiteLabelConfig $config, string $templateName, array $data = []): string + { + return $this->emailService->generateTemplate($config, $templateName, $data); + } + + /** + * Export branding configuration + */ + public function exportConfiguration(WhiteLabelConfig $config): array + { + return [ + 'platform_name' => $config->platform_name, + 'theme_config' => $config->theme_config, + 'custom_css' => $config->custom_css, + 'email_templates' => $config->custom_email_templates, + 'hide_coolify_branding' => $config->hide_coolify_branding, + 'exported_at' => now()->toIso8601String(), + 'version' => '1.0', + ]; + } + + /** + * Import branding configuration + */ + public function importConfiguration(WhiteLabelConfig $config, array $data): void + { + // Validate import data + $this->validateImportData($data); + + // Import configuration + $config->update([ + 'platform_name' => $data['platform_name'] ?? $config->platform_name, + 'theme_config' => $data['theme_config'] ?? $config->theme_config, + 'custom_css' => $data['custom_css'] ?? $config->custom_css, + 'custom_email_templates' => $data['email_templates'] ?? $config->custom_email_templates, + 'hide_coolify_branding' => $data['hide_coolify_branding'] ?? $config->hide_coolify_branding, + ]); + + // Clear cache + $this->cacheService->clearOrganizationCache($config->organization_id); + } + + /** + * Validate import data structure + */ + protected function validateImportData(array $data): void + { + if (! isset($data['version'])) { + throw new \InvalidArgumentException('Invalid import file: missing version'); + } + + if (! isset($data['exported_at'])) { + throw new \InvalidArgumentException('Invalid import file: missing export timestamp'); + } + } + + /** + * Helper: Check if string is hex color + */ + protected function isHexColor(string $color): bool + { + return preg_match('/^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/', $color) === 1; + } + + /** + * Helper: Convert hex to RGB + */ + protected function hexToRgb(string $hex): string + { + $hex = ltrim($hex, '#'); + + if (strlen($hex) === 3) { + $hex = $hex[0].$hex[0].$hex[1].$hex[1].$hex[2].$hex[2]; + } + + $r = hexdec(substr($hex, 0, 2)); + $g = hexdec(substr($hex, 2, 2)); + $b = hexdec(substr($hex, 4, 2)); + + return "{$r}, {$g}, {$b}"; + } + + /** + * Helper: Adjust color brightness + */ + protected function adjustColorBrightness(string $hex, int $percent): string + { + $hex = ltrim($hex, '#'); + + if (strlen($hex) === 3) { + $hex = $hex[0].$hex[0].$hex[1].$hex[1].$hex[2].$hex[2]; + } + + $r = hexdec(substr($hex, 0, 2)); + $g = hexdec(substr($hex, 2, 2)); + $b = hexdec(substr($hex, 4, 2)); + + $r = max(0, min(255, $r + ($r * $percent / 100))); + $g = max(0, min(255, $g + ($g * $percent / 100))); + $b = max(0, min(255, $b + ($b * $percent / 100))); + + return '#'.str_pad(dechex($r), 2, '0', STR_PAD_LEFT) + .str_pad(dechex($g), 2, '0', STR_PAD_LEFT) + .str_pad(dechex($b), 2, '0', STR_PAD_LEFT); + } + + /** + * Helper: Add alpha channel to color + */ + protected function addAlphaToColor(string $hex, float $alpha): string + { + $rgb = $this->hexToRgb($hex); + + return "rgba({$rgb}, {$alpha})"; + } +} diff --git a/config/enterprise.php b/config/enterprise.php new file mode 100644 index 00000000000..640d2dbdc1a --- /dev/null +++ b/config/enterprise.php @@ -0,0 +1,22 @@ + [ + 'cache_ttl' => env('WHITE_LABEL_CACHE_TTL', 3600), + 'sass_debug' => env('WHITE_LABEL_SASS_DEBUG', false), + 'default_theme' => [ + 'primary_color' => '#3b82f6', + 'secondary_color' => '#1f2937', + 'accent_color' => '#10b981', + 'background_color' => '#ffffff', + 'text_color' => '#1f2937', + 'sidebar_color' => '#f9fafb', + 'border_color' => '#e5e7eb', + 'success_color' => '#10b981', + 'warning_color' => '#f59e0b', + 'error_color' => '#ef4444', + 'info_color' => '#3b82f6', + 'font_family' => 'Inter, sans-serif', + ], + ], +]; diff --git a/database/factories/WhiteLabelConfigFactory.php b/database/factories/WhiteLabelConfigFactory.php new file mode 100644 index 00000000000..f73c859f904 --- /dev/null +++ b/database/factories/WhiteLabelConfigFactory.php @@ -0,0 +1,72 @@ + + */ +class WhiteLabelConfigFactory extends Factory +{ + protected $model = WhiteLabelConfig::class; + + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'organization_id' => Organization::factory(), + 'platform_name' => $this->faker->company().' Platform', + 'theme_config' => [ + 'primary_color' => $this->faker->hexColor(), + 'secondary_color' => $this->faker->hexColor(), + 'accent_color' => $this->faker->hexColor(), + 'background_color' => '#ffffff', + 'text_color' => '#000000', + ], + 'logo_url' => $this->faker->imageUrl(200, 100, 'business'), + 'custom_css' => '', + 'custom_domains' => [ + $this->faker->domainName(), + ], + 'custom_email_templates' => [], + 'hide_coolify_branding' => false, + ]; + } + + /** + * Set custom theme colors. + */ + public function withTheme(array $colors): static + { + return $this->state(fn (array $attributes) => [ + 'theme_config' => array_merge($attributes['theme_config'] ?? [], $colors), + ]); + } + + /** + * Set custom domains. + */ + public function withDomains(array $domains): static + { + return $this->state(fn (array $attributes) => [ + 'custom_domains' => $domains, + ]); + } + + /** + * Set custom CSS. + */ + public function withCustomCss(string $css): static + { + return $this->state(fn (array $attributes) => [ + 'custom_css' => $css, + ]); + } +} diff --git a/database/migrations/2025_08_26_225748_create_white_label_configs_table.php b/database/migrations/2025_08_26_225748_create_white_label_configs_table.php new file mode 100644 index 00000000000..1663ec4323a --- /dev/null +++ b/database/migrations/2025_08_26_225748_create_white_label_configs_table.php @@ -0,0 +1,38 @@ +uuid('id')->primary(); + $table->uuid('organization_id'); + $table->string('platform_name')->default('Coolify'); + $table->text('logo_url')->nullable(); + $table->json('theme_config')->default('{}'); + $table->json('custom_domains')->default('[]'); + $table->boolean('hide_coolify_branding')->default(false); + $table->json('custom_email_templates')->default('{}'); + $table->text('custom_css')->nullable(); + $table->timestamps(); + + $table->foreign('organization_id')->references('id')->on('organizations')->onDelete('cascade'); + $table->unique('organization_id'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('white_label_configs'); + } +}; diff --git a/resources/sass/branding/dark.scss b/resources/sass/branding/dark.scss new file mode 100644 index 00000000000..4ef770c0b46 --- /dev/null +++ b/resources/sass/branding/dark.scss @@ -0,0 +1,4 @@ +// Dark mode specific styles +body.dark { + --primary-color: #0056b3; +} diff --git a/resources/sass/branding/theme.scss b/resources/sass/branding/theme.scss new file mode 100644 index 00000000000..0aa83b55445 --- /dev/null +++ b/resources/sass/branding/theme.scss @@ -0,0 +1,6 @@ +// Define default variables +$primary-color: #007bff !default; + +body { + --primary-color: #{$primary-color}; +} diff --git a/resources/sass/branding/variables.md b/resources/sass/branding/variables.md new file mode 100644 index 00000000000..4f9f7e9f5de --- /dev/null +++ b/resources/sass/branding/variables.md @@ -0,0 +1,22 @@ +# SASS Template Variables + +This document describes the SASS variables that can be used to customize the branding of the application. + +## Theme Variables + +These variables are defined in the `WhiteLabelConfig` model and are passed to the SASS compiler. + +- `$primary-color`: The primary color of the application. +- `$secondary-color`: The secondary color of the application. +- `$font-family`: The font family to use. + +## Example + +```php +// WhiteLabelConfig model +'theme_config' => [ + 'primary_color' => '#ff0000', + 'secondary_color' => '#00ff00', + 'font_family' => 'Roboto, sans-serif', +] +``` diff --git a/resources/sass/enterprise/dark-mode-template.scss b/resources/sass/enterprise/dark-mode-template.scss new file mode 100644 index 00000000000..214e36a7239 --- /dev/null +++ b/resources/sass/enterprise/dark-mode-template.scss @@ -0,0 +1,118 @@ +// Dark Mode Overrides +// This template provides dark mode variants for the white-label theme +// Variables are set via scssphp compiler's setVariables() method + +// Color Variables - set by DynamicAssetController +$primary_color: #3b82f6 !default; +$secondary_color: #1f2937 !default; +$accent_color: #10b981 !default; +$success_color: #10b981 !default; +$warning_color: #f59e0b !default; +$error_color: #ef4444 !default; +$info_color: #3b82f6 !default; + +@media (prefers-color-scheme: dark) { + :root { + // Invert background and text colors + --color-background: #1a1a1a; + --color-background-rgb: 26, 26, 26; + --color-sidebar: #2a2a2a; + --color-sidebar-rgb: 42, 42, 42; + --color-text: #f0f0f0; + --color-text-rgb: 240, 240, 240; + --color-text-muted: rgba(240, 240, 240, 0.6); + --color-text-light: rgba(240, 240, 240, 0.4); + --color-border: #3a3a3a; + --color-border-rgb: 58, 58, 58; + --color-border-light: #4a4a4a; + --color-border-dark: #2a2a2a; + + // Adjust accent colors for dark mode (slightly brighter) + --color-primary: #{adjust-color($primary_color, $lightness: 10%)}; + --color-primary-light: #{adjust-color($primary_color, $lightness: 20%)}; + --color-primary-dark: #{adjust-color($primary_color, $lightness: -10%)}; + + --color-secondary: #{adjust-color($secondary_color, $lightness: 10%)}; + --color-secondary-light: #{adjust-color($secondary_color, $lightness: 20%)}; + --color-secondary-dark: #{adjust-color($secondary_color, $lightness: -10%)}; + + --color-accent: #{adjust-color($accent_color, $lightness: 10%)}; + --color-accent-light: #{adjust-color($accent_color, $lightness: 20%)}; + --color-accent-dark: #{adjust-color($accent_color, $lightness: -10%)}; + + // Adjust status colors for dark mode + --color-success: #{adjust-color($success_color, $lightness: 10%)}; + --color-success-light: #{adjust-color($success_color, $lightness: 20%)}; + --color-success-dark: #{adjust-color($success_color, $lightness: -10%)}; + + --color-warning: #{adjust-color($warning_color, $lightness: 10%)}; + --color-warning-light: #{adjust-color($warning_color, $lightness: 20%)}; + --color-warning-dark: #{adjust-color($warning_color, $lightness: -10%)}; + + --color-error: #{adjust-color($error_color, $lightness: 10%)}; + --color-error-light: #{adjust-color($error_color, $lightness: 20%)}; + --color-error-dark: #{adjust-color($error_color, $lightness: -10%)}; + + --color-info: #{adjust-color($info_color, $lightness: 10%)}; + --color-info-light: #{adjust-color($info_color, $lightness: 20%)}; + --color-info-dark: #{adjust-color($info_color, $lightness: -10%)}; + + // Adjust shadows for dark mode + --shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.3); + --shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.4); + --shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.4); + --shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.5); + } +} + +// Dark mode class-based override (for manual dark mode toggle) +.dark { + --color-background: #1a1a1a; + --color-background-rgb: 26, 26, 26; + --color-sidebar: #2a2a2a; + --color-sidebar-rgb: 42, 42, 42; + --color-text: #f0f0f0; + --color-text-rgb: 240, 240, 240; + --color-text-muted: rgba(240, 240, 240, 0.6); + --color-text-light: rgba(240, 240, 240, 0.4); + --color-border: #3a3a3a; + --color-border-rgb: 58, 58, 58; + --color-border-light: #4a4a4a; + --color-border-dark: #2a2a2a; + + // Adjust accent colors + --color-primary: #{adjust-color($primary_color, $lightness: 10%)}; + --color-primary-light: #{adjust-color($primary_color, $lightness: 20%)}; + --color-primary-dark: #{adjust-color($primary_color, $lightness: -10%)}; + + --color-secondary: #{adjust-color($secondary_color, $lightness: 10%)}; + --color-secondary-light: #{adjust-color($secondary_color, $lightness: 20%)}; + --color-secondary-dark: #{adjust-color($secondary_color, $lightness: -10%)}; + + --color-accent: #{adjust-color($accent_color, $lightness: 10%)}; + --color-accent-light: #{adjust-color($accent_color, $lightness: 20%)}; + --color-accent-dark: #{adjust-color($accent_color, $lightness: -10%)}; + + // Adjust status colors + --color-success: #{adjust-color($success_color, $lightness: 10%)}; + --color-success-light: #{adjust-color($success_color, $lightness: 20%)}; + --color-success-dark: #{adjust-color($success_color, $lightness: -10%)}; + + --color-warning: #{adjust-color($warning_color, $lightness: 10%)}; + --color-warning-light: #{adjust-color($warning_color, $lightness: 20%)}; + --color-warning-dark: #{adjust-color($warning_color, $lightness: -10%)}; + + --color-error: #{adjust-color($error_color, $lightness: 10%)}; + --color-error-light: #{adjust-color($error_color, $lightness: 20%)}; + --color-error-dark: #{adjust-color($error_color, $lightness: -10%)}; + + --color-info: #{adjust-color($info_color, $lightness: 10%)}; + --color-info-light: #{adjust-color($info_color, $lightness: 20%)}; + --color-info-dark: #{adjust-color($info_color, $lightness: -10%)}; + + // Adjust shadows + --shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.3); + --shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.4); + --shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.4); + --shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.5); +} diff --git a/resources/sass/enterprise/white-label-template.scss b/resources/sass/enterprise/white-label-template.scss new file mode 100644 index 00000000000..63adbef5f0a --- /dev/null +++ b/resources/sass/enterprise/white-label-template.scss @@ -0,0 +1,202 @@ +// White Label Theme Template +// This template is compiled with organization-specific variables at runtime +// Variables are set via scssphp compiler's setVariables() method + +// Color Variables - set by DynamicAssetController +$primary_color: #3b82f6 !default; +$secondary_color: #1f2937 !default; +$accent_color: #10b981 !default; +$background_color: #ffffff !default; +$text_color: #1f2937 !default; +$sidebar_color: #f9fafb !default; +$border_color: #e5e7eb !default; +$success_color: #10b981 !default; +$warning_color: #f59e0b !default; +$error_color: #ef4444 !default; +$info_color: #3b82f6 !default; + +// Typography Variables +$font_family: 'Inter, sans-serif' !default; + +// Helper function to convert hex to RGB +@function hex-to-rgb($hex) { + @return red($hex), green($hex), blue($hex); +} + +// CSS Custom Properties (CSS Variables) +:root { + // Primary Colors + --color-primary: #{$primary_color}; + --color-primary-rgb: #{hex-to-rgb($primary_color)}; + --color-primary-light: #{lighten($primary_color, 10%)}; + --color-primary-dark: #{darken($primary_color, 10%)}; + --color-primary-alpha: #{rgba($primary_color, 0.1)}; + + // Secondary Colors + --color-secondary: #{$secondary_color}; + --color-secondary-rgb: #{hex-to-rgb($secondary_color)}; + --color-secondary-light: #{lighten($secondary_color, 10%)}; + --color-secondary-dark: #{darken($secondary_color, 10%)}; + + // Accent Colors + --color-accent: #{$accent_color}; + --color-accent-rgb: #{hex-to-rgb($accent_color)}; + --color-accent-light: #{lighten($accent_color, 10%)}; + --color-accent-dark: #{darken($accent_color, 10%)}; + + // Background Colors + --color-background: #{$background_color}; + --color-background-rgb: #{hex-to-rgb($background_color)}; + --color-sidebar: #{$sidebar_color}; + --color-sidebar-rgb: #{hex-to-rgb($sidebar_color)}; + + // Text Colors + --color-text: #{$text_color}; + --color-text-rgb: #{hex-to-rgb($text_color)}; + --color-text-muted: #{rgba($text_color, 0.6)}; + --color-text-light: #{rgba($text_color, 0.4)}; + + // Border Colors + --color-border: #{$border_color}; + --color-border-rgb: #{hex-to-rgb($border_color)}; + --color-border-light: #{lighten($border_color, 10%)}; + --color-border-dark: #{darken($border_color, 10%)}; + + // Status Colors + --color-success: #{$success_color}; + --color-success-rgb: #{hex-to-rgb($success_color)}; + --color-success-light: #{lighten($success_color, 10%)}; + --color-success-dark: #{darken($success_color, 10%)}; + + --color-warning: #{$warning_color}; + --color-warning-rgb: #{hex-to-rgb($warning_color)}; + --color-warning-light: #{lighten($warning_color, 10%)}; + --color-warning-dark: #{darken($warning_color, 10%)}; + + --color-error: #{$error_color}; + --color-error-rgb: #{hex-to-rgb($error_color)}; + --color-error-light: #{lighten($error_color, 10%)}; + --color-error-dark: #{darken($error_color, 10%)}; + + --color-info: #{$info_color}; + --color-info-rgb: #{hex-to-rgb($info_color)}; + --color-info-light: #{lighten($info_color, 10%)}; + --color-info-dark: #{darken($info_color, 10%)}; + + // Typography + --font-family-primary: #{$font_family}; + --font-family-mono: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace; + + // Spacing (using Tailwind-like scale) + --spacing-xs: 0.25rem; + --spacing-sm: 0.5rem; + --spacing-md: 1rem; + --spacing-lg: 1.5rem; + --spacing-xl: 2rem; + --spacing-2xl: 3rem; + + // Border Radius + --radius-sm: 0.25rem; + --radius-md: 0.5rem; + --radius-lg: 0.75rem; + --radius-xl: 1rem; + + // Shadows + --shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05); + --shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1); + --shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1); + --shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.1); +} + +// Component Styles using CSS Variables +.btn-primary { + background-color: var(--color-primary); + border-color: var(--color-primary); + color: white; + + &:hover { + background-color: var(--color-primary-dark); + border-color: var(--color-primary-dark); + } + + &:focus { + outline: 2px solid var(--color-primary-alpha); + outline-offset: 2px; + } + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } +} + +.btn-secondary { + background-color: var(--color-secondary); + border-color: var(--color-secondary); + color: white; + + &:hover { + background-color: var(--color-secondary-dark); + border-color: var(--color-secondary-dark); + } +} + +.btn-accent { + background-color: var(--color-accent); + border-color: var(--color-accent); + color: white; + + &:hover { + background-color: var(--color-accent-dark); + border-color: var(--color-accent-dark); + } +} + +// Navigation Styles +.navbar, +.sidebar { + background-color: var(--color-sidebar); + border-color: var(--color-border); + color: var(--color-text); +} + +// Card Styles +.card { + background-color: var(--color-background); + border-color: var(--color-border); + color: var(--color-text); +} + +// Input Styles +.input, +.form-input { + border-color: var(--color-border); + color: var(--color-text); + + &:focus { + border-color: var(--color-primary); + outline: 2px solid var(--color-primary-alpha); + } +} + +// Status Badges +.badge-success { + background-color: var(--color-success-light); + color: var(--color-success-dark); +} + +.badge-warning { + background-color: var(--color-warning-light); + color: var(--color-warning-dark); +} + +.badge-error { + background-color: var(--color-error-light); + color: var(--color-error-dark); +} + +.badge-info { + background-color: var(--color-info-light); + color: var(--color-info-dark); +} +