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} + +
+ +
+
+ + + + +
+

+ + {featuredCourse.title} + +

+ + {#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} +
+ {#each remainingCourses as course, index (course.id)} +
+ + {#if coverImageUrl(course)} + + {course.title} +
+ + {#if course.is_gated && !course.is_purchased && course.price} +
+ + ${(course.price / 100).toFixed(2)} +
+ {:else if course.access_tier === 'free'} +
+ {$t('public.courses.free')} +
+ {/if} + + {#if course.show_progress_bar !== false && course.enable_progress !== false && course.is_purchased && course.progress_percent > 0 && course.progress_percent < 100} +
+
+
+ {#if course.lessons_completed && course.total_lessons} +
+ {$t('public.courses.progress_count', { values: { completed: course.lessons_completed, total: course.total_lessons } })} +
+ {/if} + {/if} +
+ {:else} +
+ + {#if course.is_gated && !course.is_purchased && course.price} +
+ + ${(course.price / 100).toFixed(2)} +
+ {:else if course.access_tier === 'free'} +
+ {$t('public.courses.free')} +
+ {/if} +
+ {/if} + + +
+

+ + {course.title} + +

+ + {#if course.description} +
+ {@html parseMarkdown(course.description)} +
+ {/if} + + +
+ {#if course.difficulty} + + {formatDifficulty(course.difficulty)} + + {/if} + {#if course.estimated_hours} + + + {formatDuration(course.estimated_hours)} + + {/if} + {#if course.total_lessons} + + + {$t('public.courses.lessons_count', { values: { count: course.total_lessons } })} + + {/if} +
+ + + +
+
+ {/each} +
+ {/if} +
+ {:else if layout === 'list'} + +
+ {#each items as course, index (course.id)} +
+ + {#if coverImageUrl(course)} + + {course.title} +
+ + {#if course.is_gated && !course.is_purchased && course.price} +
+ + ${(course.price / 100).toFixed(2)} +
+ {:else if course.access_tier === 'free'} +
+ {$t('public.courses.free')} +
+ {/if} + + {#if course.show_progress_bar !== false && course.enable_progress !== false && course.is_purchased && course.progress_percent > 0 && course.progress_percent < 100} +
+
+
+ {#if course.lessons_completed && course.total_lessons} +
+ {$t('public.courses.progress_count', { values: { completed: course.lessons_completed, total: course.total_lessons } })} +
+ {/if} + {/if} +
+ {:else} +
+ + {#if course.is_gated && !course.is_purchased && course.price} +
+ + ${(course.price / 100).toFixed(2)} +
+ {:else if course.access_tier === 'free'} +
+ {$t('public.courses.free')} +
+ {/if} +
+ {/if} + + +
+

+ + {course.title} + +

+ + {#if course.description} +
+ {@html parseMarkdown(course.description)} +
+ {/if} + + +
+ {#if course.difficulty} + + {formatDifficulty(course.difficulty)} + + {/if} + {#if course.estimated_hours} + + + {formatDuration(course.estimated_hours)} + + {/if} + {#if course.total_lessons} + + + {$t('public.courses.lessons_count', { values: { count: course.total_lessons } })} + + {/if} +
+ + + +
+
+ {/each} +
+ {:else} + +
+ {#each items as course, index (course.id)} +
+ + {#if coverImageUrl(course)} + + {course.title} + +
+ + + {#if course.is_gated && !course.is_purchased && course.price} +
+ + ${(course.price / 100).toFixed(2)} +
+ {:else if course.access_tier === 'free'} +
+ {$t('public.courses.free')} +
+ {/if} + + + {#if course.show_progress_bar !== false && course.enable_progress !== false && course.is_purchased && course.progress_percent > 0 && course.progress_percent < 100} +
+
+
+ {#if course.lessons_completed && course.total_lessons} +
+ {$t('public.courses.progress_count', { values: { completed: course.lessons_completed, total: course.total_lessons } })} +
+ {/if} + {/if} +
+ {:else} + +
+ + {#if course.is_gated && !course.is_purchased && course.price} +
+ + ${(course.price / 100).toFixed(2)} +
+ {:else if course.access_tier === 'free'} +
+ {$t('public.courses.free')} +
+ {/if} +
+ {/if} + + +
+

+ + {course.title} + +

+ + {#if course.description} +
+ {@html parseMarkdown(course.description)} +
+ {/if} + + +
+ {#if course.difficulty} + + {formatDifficulty(course.difficulty)} + + {/if} + {#if course.estimated_hours} + + + {formatDuration(course.estimated_hours)} + + {/if} + {#if course.total_lessons} + + + {$t('public.courses.lessons_count', { values: { count: course.total_lessons } })} + + {/if} +
+ + + +
+
+ {/each} +
+ {/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 errorMessage} + + {/if} + + + {#if siteKey} + + {/if} +
+ {/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')}

-

+