diff --git a/backend/hooks/ratelimit.go b/backend/hooks/ratelimit.go
index f5bab695..cc792517 100644
--- a/backend/hooks/ratelimit.go
+++ b/backend/hooks/ratelimit.go
@@ -20,6 +20,11 @@ import (
func RateLimitMiddleware(rl *services.RateLimitService, tier string) func(func(*core.RequestEvent) error) func(*core.RequestEvent) error {
return func(handler func(*core.RequestEvent) error) func(*core.RequestEvent) error {
return func(e *core.RequestEvent) error {
+ // Bypass rate limiting for SSR internal requests (same-container fetch)
+ if e.Request.Header.Get("X-Internal") == "true" {
+ return handler(e)
+ }
+
info := rl.AllowWithInfo(e.Request, tier)
// Always set rate limit headers (allows clients to monitor quota)
diff --git a/docker/Caddyfile b/docker/Caddyfile
index b602be8f..d09d5acb 100644
--- a/docker/Caddyfile
+++ b/docker/Caddyfile
@@ -21,18 +21,40 @@
# Disable browser features not used by Facet
Permissions-Policy "geolocation=(), microphone=(), camera=(), payment=(), usb=()"
+ # HSTS - safe because all traffic goes through Cloudflare (TLS-terminated at edge)
+ Strict-Transport-Security "max-age=31536000; includeSubDomains"
+
# Remove server identification
-Server
+ # Content Security Policy (enforcing)
+ # Restricts script/style/frame sources to mitigate XSS.
+ # 'unsafe-inline' kept for scripts/styles due to Svelte's inline injection pattern.
+ Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' https://static.cloudflareinsights.com; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; img-src 'self' data: blob: https:; font-src 'self' data: https://fonts.gstatic.com; frame-src https://www.youtube.com https://www.youtube-nocookie.com https://player.vimeo.com https://www.loom.com https://w.soundcloud.com https://open.spotify.com https://codepen.io https://www.figma.com; connect-src 'self'; media-src 'self' blob:; object-src 'none'; base-uri 'self'; form-action 'self'"
+
# INTENTIONALLY OMITTED:
# - X-XSS-Protection: Deprecated since 2023, can cause vulnerabilities
- # - HSTS: TLS terminates at edge proxy (Cloudflare), not here
# - COOP/COEP/CORP: Phase 5B - requires testing with frontend
- # - CSP: Phase 5B - requires report-only rollout first
+ # - CSP nonce-based: Would allow removing 'unsafe-inline'; requires SSR nonce injection
+ }
+
+ # Request body size limit
+ request_body {
+ max_size 25MB
+ }
+
+ # Uploaded files (images, etc.) — cacheable, served by PocketBase
+ handle /api/files/* {
+ header Cache-Control "public, max-age=86400"
+ reverse_proxy localhost:8090
}
- # API and PocketBase routes go to backend
+ # API and PocketBase routes go to backend (no caching)
handle /api/* {
+ header Cache-Control "no-store, no-cache"
+ # Strip X-Internal header from external requests to prevent spoofing.
+ # SSR bypasses Caddy (goes direct to :8090), so legitimate internal requests are unaffected.
+ request_header -X-Internal
reverse_proxy localhost:8090
}
@@ -64,6 +86,12 @@
reverse_proxy localhost:8090
}
+ # SvelteKit immutable assets (content-hashed filenames, safe to cache forever)
+ handle /_app/immutable/* {
+ header Cache-Control "public, max-age=31536000, immutable"
+ reverse_proxy localhost:3000
+ }
+
# Everything else goes to SvelteKit frontend
handle {
reverse_proxy localhost:3000
diff --git a/frontend/src/app.html b/frontend/src/app.html
index 71c0160c..efa62f80 100644
--- a/frontend/src/app.html
+++ b/frontend/src/app.html
@@ -1,12 +1,21 @@
-
+
-
+
+
%sveltekit.head%
diff --git a/frontend/src/components/public/ATSContent.svelte b/frontend/src/components/public/ATSContent.svelte
index 301de31d..08ee9e94 100644
--- a/frontend/src/components/public/ATSContent.svelte
+++ b/frontend/src/components/public/ATSContent.svelte
@@ -84,7 +84,7 @@
Location: {profile.location}
{/if}
{#if emailContact}
- Email: {emailContact.value}
+ Email: {emailContact.value}
{/if}
{#if phoneContact}
Phone: {phoneContact.value}
@@ -99,7 +99,7 @@
Website: {websiteContact.value}
{/if}
{#if !hasAnyContact && profile?.contact_email}
- Email: {profile.contact_email}
+ Email: {profile.contact_email}
{/if}
diff --git a/frontend/src/components/public/CoursesSection.svelte b/frontend/src/components/public/CoursesSection.svelte
new file mode 100644
index 00000000..bb524164
--- /dev/null
+++ b/frontend/src/components/public/CoursesSection.svelte
@@ -0,0 +1,587 @@
+
+
+
+ {#if showHeader}
+ {$t('public.sections.courses')}
+ {/if}
+
+ {#if layout === 'featured' && items.length > 0}
+
+
+
+
+
+
+
+
+
+
+
+
+ {#if featuredCourse.description}
+
+ {@html parseMarkdown(featuredCourse.description)}
+
+ {/if}
+
+
+
+ {#if featuredCourse.difficulty}
+
+ {formatDifficulty(featuredCourse.difficulty)}
+
+ {/if}
+ {#if featuredCourse.estimated_hours}
+
+
+ {formatDuration(featuredCourse.estimated_hours)}
+
+ {/if}
+ {#if featuredCourse.total_lessons}
+
+
+ {$t('public.courses.lessons_count', { values: { count: featuredCourse.total_lessons } })}
+
+ {/if}
+
+
+
+
+
+
+
+
+
+ {#if remainingCourses.length > 0}
+
+ {/if}
+
+ {:else if layout === 'list'}
+
+
+ {:else}
+
+
+ {/if}
+
diff --git a/frontend/src/components/public/NewsletterSection.svelte b/frontend/src/components/public/NewsletterSection.svelte
new file mode 100644
index 00000000..430f671d
--- /dev/null
+++ b/frontend/src/components/public/NewsletterSection.svelte
@@ -0,0 +1,286 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
{$t('public.sections.newsletter_heading')}
+
{$t('public.sections.newsletter_description')}
+
+ {#if status === 'success'}
+
+
+
{$t('public.sections.newsletter_success')}
+
+ {:else}
+
+ {/if}
+
+
+
+
+
diff --git a/frontend/src/components/public/SiteNav.svelte b/frontend/src/components/public/SiteNav.svelte
index 4ca230a9..ab0c552f 100644
--- a/frontend/src/components/public/SiteNav.svelte
+++ b/frontend/src/components/public/SiteNav.svelte
@@ -171,7 +171,8 @@
{item.label}
{/each}
-
+
+
{/if}
diff --git a/frontend/src/params/slug.ts b/frontend/src/params/slug.ts
index 562d8550..1b313519 100644
--- a/frontend/src/params/slug.ts
+++ b/frontend/src/params/slug.ts
@@ -28,6 +28,7 @@ const RESERVED_SLUGS = new Set([
'projects',
'posts',
'talks',
+ 'courses',
// SvelteKit internal
'_app',
'_',
@@ -48,11 +49,17 @@ const RESERVED_SLUGS = new Set([
'auth',
'oauth',
'callback',
+ // Public pages
+ 'unsubscribe',
+ 'testimonial',
// Prevent confusion
'home',
'index',
'default',
- 'profile'
+ 'profile',
+ // Product terminology
+ 'facet',
+ 'facets'
]);
export const match: ParamMatcher = (param) => {
diff --git a/frontend/src/routes/+layout.server.ts b/frontend/src/routes/+layout.server.ts
index 84a8b878..04545647 100644
--- a/frontend/src/routes/+layout.server.ts
+++ b/frontend/src/routes/+layout.server.ts
@@ -1,3 +1,12 @@
+/**
+ * Root layout server load: Preload plan config server-side to eliminate FOUC
+ *
+ * Fetches plan configuration from PocketBase before first render, ensuring
+ * the "Powered by Facet" badge visibility state is correct on initial paint.
+ *
+ * Pro/Creator users (managed, !badge_forced) will never see the badge flash.
+ */
+
import type { LayoutServerLoad } from './$types';
import type { PlanConfig } from '$lib/stores/plan';
import { logger } from '$lib/logger';
@@ -23,7 +32,7 @@ export const load: LayoutServerLoad = async ({ fetch }) => {
logger.debug('[LAYOUT SSR] Failed to load site settings:', error);
}
- // Fetch plan config server-side (FOUC fix)
+ // Fetch plan config server-side (new functionality - FOUC fix)
let planConfig: PlanConfig | null = null;
try {
const planResponse = await fetch(`${pbUrl}/api/plan`, {
@@ -31,11 +40,19 @@ export const load: LayoutServerLoad = async ({ fetch }) => {
});
if (planResponse.ok) {
planConfig = await planResponse.json();
+ logger.debug('[LAYOUT SSR] Loaded plan config:', planConfig?.plan);
+ } else {
+ logger.debug('[LAYOUT SSR] Plan API returned non-OK status:', planResponse.status);
}
} catch (error) {
logger.debug('[LAYOUT SSR] Failed to load plan config:', error);
}
+ // Pass APP_URL to the client for canonical/OG URLs.
+ // SSR $page.url.origin reflects the internal HTTP origin (behind Caddy proxy),
+ // not the public HTTPS URL. APP_URL is authoritative.
+ const appUrl = process.env.APP_URL || '';
+
// Fetch site-nav server-side to eliminate nav pop-in on page load
let siteNav = { enabled: false, items: [] as Array<{ viewId: string; slug: string; label: string; name: string }> };
try {
@@ -45,6 +62,9 @@ export const load: LayoutServerLoad = async ({ fetch }) => {
if (navResponse.ok) {
const navData = await navResponse.json();
siteNav = { enabled: navData.enabled === true, items: navData.items || [] };
+ logger.debug('[LAYOUT SSR] Loaded site nav:', siteNav.enabled, siteNav.items.length, 'items');
+ } else {
+ logger.debug('[LAYOUT SSR] Site nav API returned:', navResponse.status);
}
} catch (error) {
logger.debug('[LAYOUT SSR] Failed to load site nav:', error);
@@ -71,6 +91,7 @@ export const load: LayoutServerLoad = async ({ fetch }) => {
return {
faviconUrl,
planConfig,
+ appUrl,
siteNav,
accentColor,
customHexColor,
diff --git a/frontend/src/routes/+layout.svelte b/frontend/src/routes/+layout.svelte
index 45d57949..5615b17b 100644
--- a/frontend/src/routes/+layout.svelte
+++ b/frontend/src/routes/+layout.svelte
@@ -1,6 +1,4 @@
- {data.profile?.name || 'Profile'} | Facet
+ {data.profile?.name || 'Profile'} | {$brandName}
@@ -430,20 +505,20 @@
{#if data.homepageDisabled}
-
+
-
-
{$t('public.homepage.profile_private')}
-
{landingMessage}
-
+
+
{$t('public.homepage.profile_private')}
+
{landingMessage}
+
{$t('public.homepage.views_accessible')}
{:else if !data.profile && !data.experience?.length && !data.projects?.length && !isNavigating && !hasHydratedData}
-
+
{:else}
@@ -455,26 +530,28 @@
{#if showPrintMenu}
-
-
-
+
+
{#if showGenerateModal}
-
-
showGenerateModal = false)}>
-
-
-
{$t('public.ai_resume.title')}
-
+
+
+
+
{$t('public.ai_resume.title')}
+
{$t('public.ai_resume.description')}
@@ -689,7 +780,7 @@
{:else}
-
+
-
+
-
+
-
+
{ showGenerateModal = false; generatedUrl = null; }}
+ onclick={closeGenerateModal}
>
{generatedUrl ? $t('shared.close') : $t('shared.cancel')}
@@ -732,7 +823,7 @@
disabled={generating}
>
{#if generating}
-
+
diff --git a/frontend/src/routes/[slug=slug]/+page.server.ts b/frontend/src/routes/[slug=slug]/+page.server.ts
index 8379d1ca..e4985199 100644
--- a/frontend/src/routes/[slug=slug]/+page.server.ts
+++ b/frontend/src/routes/[slug=slug]/+page.server.ts
@@ -187,13 +187,10 @@ export const load: PageServerLoad = async ({ params, cookies, url, fetch, locals
shareToken: effectiveShareToken || null,
isPublicView: accessInfo.visibility === 'public',
siteCtaEnabled: viewData.site_cta_enabled !== false,
+ siteNavEnabled: viewData.site_nav_enabled === true,
siteNav: (await parent()).siteNav
};
} catch (err) {
- // If navigation was cancelled (user clicked another link), let SvelteKit handle it
- if (err instanceof Error && err.name === 'AbortError') {
- throw err;
- }
if ((err as { status?: number }).status === 404) {
throw err;
}
diff --git a/frontend/src/routes/[slug=slug]/+page.svelte b/frontend/src/routes/[slug=slug]/+page.svelte
index 1a2f0df3..ede8ef70 100644
--- a/frontend/src/routes/[slug=slug]/+page.svelte
+++ b/frontend/src/routes/[slug=slug]/+page.svelte
@@ -5,7 +5,7 @@
import type { PageData } from './$types';
import { enhance } from '$app/forms';
import { invalidateAll } from '$app/navigation';
- import { navigating } from '$app/stores';
+ import { navigating, page } from '$app/stores';
import { onMount } from 'svelte';
import { browser } from '$app/environment';
import ProfileHero from '$components/public/ProfileHero.svelte';
@@ -18,9 +18,12 @@
import SkillsSection from '$components/public/SkillsSection.svelte';
import PostsSection from '$components/public/PostsSection.svelte';
import TalksSection from '$components/public/TalksSection.svelte';
+ import CoursesSection from '$components/public/CoursesSection.svelte';
import TestimonialsSection from '$components/public/TestimonialsSection.svelte';
import ContactMethodsList from '$components/public/ContactMethodsList.svelte';
import CustomContentSection from '$components/public/CustomContentSection.svelte';
+ import NewsletterSection from '$components/public/NewsletterSection.svelte';
+ import { checkFeature } from '$lib/stores/plan';
import SiteNav from '$components/public/SiteNav.svelte';
import ATSContent from '$components/public/ATSContent.svelte';
@@ -34,7 +37,7 @@
import PasswordPrompt from '$components/public/PasswordPrompt.svelte';
import { ACCENT_COLORS, type AccentColor } from '$lib/colors';
import { getFontPack, DEFAULT_FONT_PACK } from '$lib/fonts';
- import { pb } from '$lib/pocketbase';
+ import { pb, getSectionLayout as getDefaultSectionLayout } from '$lib/pocketbase';
import { generatePersonJsonLd, serializeJsonLd } from '$lib/seo';
interface Props {
@@ -53,6 +56,9 @@
let showPrintMenu = $state(false);
let showGenerateModal = $state(false);
let generating = $state(false);
+ let modalEl: HTMLDivElement | undefined = $state();
+ let printMenuTriggerEl: HTMLButtonElement | undefined = $state();
+ let previousActiveElement: HTMLElement | null = $state(null);
let aiPrintStatus = $state({
available: false,
ai_configured: false,
@@ -68,7 +74,7 @@
// Floating buttons visibility - hide when nav is pinned (sticky)
let navPinned = $state(false);
- let sentinelEl: HTMLDivElement | null = null;
+ let sentinelEl: HTMLDivElement | null = $state(null);
// Apply view-specific accent color (or profile default)
function applyAccentColor(colorName: AccentColor) {
@@ -92,8 +98,11 @@
}
onMount(() => {
- // View accent color takes priority over profile accent color
- const accentColor = data.view?.accent_color || data.profile?.accent_color;
+ // In site-mode, use profile accent for visual coherence across Facet tabs.
+ // View accent only applies when site nav is off or view has an explicit override.
+ const accentColor = (data.siteNavEnabled && !data.view?.accent_color)
+ ? data.profile?.accent_color
+ : (data.view?.accent_color || data.profile?.accent_color);
if (accentColor) {
applyAccentColor(accentColor as AccentColor);
}
@@ -108,6 +117,7 @@
root.style.setProperty('--font-heading', `'${pack.heading}', ${pack.headingFallback}`);
root.style.setProperty('--font-body', `'${pack.body}', ${pack.bodyFallback}`);
root.style.setProperty('--font-code', `'${pack.code}', ${pack.codeFallback}`);
+ // Load Google Fonts
const existing = document.getElementById('view-google-fonts');
if (existing) existing.remove();
const link = document.createElement('link');
@@ -220,6 +230,70 @@
showPrintMenu = false;
}
+ function closeGenerateModal() {
+ showGenerateModal = false;
+ generatedUrl = null;
+ if (previousActiveElement && typeof previousActiveElement.focus === 'function') {
+ previousActiveElement.focus();
+ }
+ }
+
+ // Modal focus trap, Escape key, and body scroll lock
+ function handleModalKeydown(event: KeyboardEvent) {
+ if (!showGenerateModal) return;
+
+ if (event.key === 'Escape') {
+ event.preventDefault();
+ closeGenerateModal();
+ return;
+ }
+
+ if (event.key === 'Tab') {
+ const focusableElements = modalEl?.querySelectorAll(
+ 'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
+ );
+ if (!focusableElements?.length) return;
+
+ const firstElement = focusableElements[0];
+ const lastElement = focusableElements[focusableElements.length - 1];
+
+ if (event.shiftKey && document.activeElement === firstElement) {
+ event.preventDefault();
+ lastElement.focus();
+ } else if (!event.shiftKey && document.activeElement === lastElement) {
+ event.preventDefault();
+ firstElement.focus();
+ }
+ }
+ }
+
+ // Lock body scroll and manage focus when modal opens/closes
+ $effect(() => {
+ if (typeof document === 'undefined') return;
+ if (showGenerateModal) {
+ previousActiveElement = document.activeElement as HTMLElement;
+ document.body.style.overflow = 'hidden';
+ requestAnimationFrame(() => {
+ const firstFocusable = modalEl?.querySelector(
+ 'select, button:not([disabled]), [href], input:not([disabled])'
+ );
+ firstFocusable?.focus();
+ });
+ } else {
+ document.body.style.overflow = '';
+ }
+ });
+
+ // Print menu keyboard handler
+ function handlePrintMenuKeydown(event: KeyboardEvent) {
+ if (!showPrintMenu) return;
+ if (event.key === 'Escape') {
+ event.preventDefault();
+ closePrintMenu();
+ printMenuTriggerEl?.focus();
+ }
+ }
+
// Default section order (fallback when no custom order is specified)
const DEFAULT_SECTION_ORDER = ['experience', 'projects', 'education', 'certifications', 'awards', 'skills', 'posts', 'talks', 'testimonials', 'contacts'];
@@ -245,7 +319,11 @@
// Get layout for a section (from API response or default)
function getSectionLayout(sectionKey: string): string {
- return data.sectionLayouts?.[sectionKey] || 'default';
+ if (data.sectionLayouts?.[sectionKey]) {
+ return data.sectionLayouts[sectionKey];
+ }
+ // Use the section-specific default (e.g. 'wall' for testimonials, 'grouped' for certifications)
+ return getDefaultSectionLayout(sectionKey);
}
type ContactLayoutType = 'vertical' | 'horizontal' | 'grid';
@@ -302,9 +380,8 @@
{data.view?.name || 'View'} | {data.profile?.name || 'Profile'}
-
-
-
+
+
{#if personJsonLd}
{@html ``}
@@ -338,8 +415,8 @@
-
Not Found
-
This page doesn't exist.
+
Not Found
+
This page doesn't exist.
Go Home
@@ -353,26 +430,28 @@
showPrintMenu = !showPrintMenu}
- class="p-2 rounded-lg bg-white/80 dark:bg-gray-800/80 backdrop-blur-sm shadow-sm border border-gray-200 dark:border-gray-700 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
+ class="p-2 rounded-lg bg-white/80 dark:bg-stone-800/80 backdrop-blur-sm shadow-sm border border-stone-200 dark:border-stone-700 hover:bg-stone-100 dark:hover:bg-stone-700 transition-colors"
title="Print options"
aria-label={$t('public.aria.print_options')}
aria-expanded={showPrintMenu}
+ aria-haspopup="menu"
>
-
+
{#if showPrintMenu}
-
-
-
+
+
{ window.print(); closePrintMenu(); }}
- class="w-full px-4 py-2 text-left text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 flex items-center gap-2"
+ class="w-full px-4 py-2 text-left text-sm text-stone-700 dark:text-stone-200 hover:bg-stone-100 dark:hover:bg-stone-700 flex items-center gap-2"
+ role="menuitem"
>
-
+
Simple Print
@@ -380,9 +459,10 @@
{#if aiPrintStatus.ai_configured}
{ showGenerateModal = true; closePrintMenu(); }}
- class="w-full px-4 py-2 text-left text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 flex items-center gap-2"
+ class="w-full px-4 py-2 text-left text-sm text-stone-700 dark:text-stone-200 hover:bg-stone-100 dark:hover:bg-stone-700 flex items-center gap-2"
+ role="menuitem"
>
-
+
AI Resume
@@ -402,11 +482,11 @@
{#if pb.authStore.isValid}
-
+
@@ -446,7 +526,7 @@
href={data.view.cta_url}
target="_blank"
rel="noopener noreferrer"
- class="btn bg-white text-primary-600 hover:bg-gray-100"
+ class="btn bg-white text-primary-600 hover:bg-stone-100"
>
{data.view.cta_button_text || 'Learn More'}
@@ -470,8 +550,10 @@
hasSkills={data.sections?.skills?.length > 0}
hasPosts={data.sections?.posts?.length > 0}
hasTalks={data.sections?.talks?.length > 0}
+ hasCourses={data.sections?.courses?.length > 0}
hasTestimonials={data.sections?.testimonials?.length > 0}
hasContacts={data.sections?.contacts?.length > 0}
+ hasNewsletter={effectiveSectionOrder.includes('newsletter') && checkFeature('newsletter')}
viewSlug={data.view?.slug || ''}
sectionOrder={effectiveSectionOrder}
customContent={customContentForNav}
@@ -548,6 +630,10 @@
{/if}
+ {:else if sectionKey === 'courses' && data.sections?.courses?.length > 0}
+
+
+
{:else if sectionKey === 'testimonials' && data.sections?.testimonials?.length > 0}
@@ -556,6 +642,10 @@
+ {:else if sectionKey === 'newsletter' && checkFeature('newsletter')}
+
+
+
{:else if isCustomSection(sectionKey) && data.sections?.[sectionKey]?.[0]}
@@ -581,12 +671,22 @@
{#if showGenerateModal}
-
-
showGenerateModal = false)}>
-
-
-
Generate AI Resume
-
+
+
+
+
Generate AI Resume
+
Create a professionally formatted resume from this view.
@@ -605,7 +705,7 @@
{:else}
-
+
-
+
-
+
-
+
{ showGenerateModal = false; generatedUrl = null; }}
+ onclick={closeGenerateModal}
>
{generatedUrl ? 'Close' : 'Cancel'}
@@ -648,7 +748,7 @@
disabled={generating}
>
{#if generating}
-
+