diff --git a/internal/handlers/cv_streaming.go b/internal/handlers/cv_streaming.go new file mode 100644 index 0000000..dbbb5a3 --- /dev/null +++ b/internal/handlers/cv_streaming.go @@ -0,0 +1,248 @@ +package handlers + +import ( + "bytes" + "log" + "net/http" + "strings" + "time" + + c "github.com/juanatsap/cv-site/internal/constants" + "github.com/juanatsap/cv-site/internal/httputil" + "github.com/juanatsap/cv-site/internal/middleware" +) + +// ============================================================================== +// STREAMING HANDLERS +// HTML Streaming for progressive page rendering +// ============================================================================== + +// HomeStreaming renders the CV page using HTTP streaming for faster perceived load +// Strategy: Stream the and initial skeleton immediately, then stream body content +// Falls back to classic Home handler if streaming not supported +func (h *CVHandler) HomeStreaming(w http.ResponseWriter, r *http.Request) { + startTime := time.Now() + + // Check if this is a shortcut URL request (cv-jamr-{year}-{lang}.pdf) + if strings.HasPrefix(r.URL.Path, "/cv-jamr-") && strings.HasSuffix(r.URL.Path, ".pdf") { + h.DefaultCVShortcut(w, r) + return + } + + // Detect text-based browsers and serve plain text version + if isTextBrowser(r) { + h.PlainText(w, r) + return + } + + // Check if response writer supports flushing + flusher, ok := w.(http.Flusher) + if !ok { + // Streaming not supported - fallback to classic rendering + log.Printf("[streaming] Flusher not supported, falling back to classic handler") + h.Home(w, r) + return + } + + // Validate language + lang, ok := httputil.LangOrError(r) + if !ok { + HandleError(w, r, BadRequestError("Unsupported language. Use 'en' or 'es'")) + return + } + + // Get user preferences from context + prefs := middleware.GetPreferences(r) + + // ================================================================== + // CHUNK 1: Send minimal loading indicator immediately + // ================================================================== + w.Header().Set(c.HeaderContentType, c.ContentTypeHTML) + w.Header().Set("Transfer-Encoding", "chunked") + w.Header().Set("X-Content-Type-Options", "nosniff") + w.Header().Set("X-Accel-Buffering", "no") + w.Header().Set("Cache-Control", "no-cache") + + // Send loading indicator that will be replaced + loadingHTML := generateLoadingHTML(lang) + if _, err := w.Write([]byte(loadingHTML)); err != nil { + log.Printf("[streaming] Error writing loading HTML: %v", err) + return + } + flusher.Flush() + + skeletonTime := time.Since(startTime) + log.Printf("[streaming] Loading indicator sent in %v", skeletonTime) + + // ================================================================== + // CHUNK 2: Prepare data (parallel git operations happen here) + // ================================================================== + data, err := h.prepareTemplateData(lang) + if err != nil { + // Error after headers sent - send inline error + errorHTML := `` + _, _ = w.Write([]byte(errorHTML)) + flusher.Flush() + return + } + + // Add preference-specific fields + cvLengthClass := "cv-short" + if prefs.CVLength == "long" { + cvLengthClass = "cv-long" + } + data["CVLengthClass"] = cvLengthClass + data["ShowIcons"] = (prefs.CVIcons == "show") + data["ThemeClean"] = (prefs.CVTheme == "clean") + + dataTime := time.Since(startTime) + log.Printf("[streaming] Data prepared in %v (delta: %v)", dataTime, dataTime-skeletonTime) + + // ================================================================== + // CHUNK 3: Render full template and stream it + // ================================================================== + tmpl, err := h.templates.Render("index.html") + if err != nil { + errorHTML := `` + _, _ = w.Write([]byte(errorHTML)) + flusher.Flush() + return + } + + var contentBuf bytes.Buffer + if err := tmpl.Execute(&contentBuf, data); err != nil { + errorHTML := `` + _, _ = w.Write([]byte(errorHTML)) + flusher.Flush() + return + } + + // Replace the entire document with the full rendered content + // This preserves all body attributes including hyperscript handlers + fullHTML := contentBuf.String() + + // Escape the HTML for JavaScript string + escapedHTML := escapeForJS(fullHTML) + + // Script inside body that replaces entire document + // We need to use a DOMParser approach since document.write() doesn't work reliably + // after the initial document has been parsed + replaceScript := ` + +` + + _, _ = w.Write([]byte(replaceScript)) + flusher.Flush() + + totalTime := time.Since(startTime) + log.Printf("[streaming] Complete in %v (loading: %v, data: %v)", totalTime, skeletonTime, dataTime-skeletonTime) +} + +// generateLoadingHTML creates a minimal loading page that shows instantly +func generateLoadingHTML(lang string) string { + langAttr := "en" + title := "Loading CV..." + if lang == "es" { + langAttr = "es" + title = "Cargando CV..." + } + + // Note: We intentionally leave body and html tags OPEN + // The streaming handler will append the replace script and close them + return ` + + + + + ` + title + ` + + + +
+
+

` + title + `

+
+` +} + +// escapeForJS escapes a string to be safely embedded in JavaScript +func escapeForJS(s string) string { + // Use backtick template literal with proper escaping + s = strings.ReplaceAll(s, "\\", "\\\\") + s = strings.ReplaceAll(s, "`", "\\`") + s = strings.ReplaceAll(s, "${", "\\${") + return "`" + s + "`" +} diff --git a/internal/middleware/security.go b/internal/middleware/security.go index 9f3552a..437e138 100644 --- a/internal/middleware/security.go +++ b/internal/middleware/security.go @@ -32,11 +32,11 @@ func SecurityHeaders(next http.Handler) http.Handler { // Content Security Policy (comprehensive) csp := "default-src 'self'; " + - "script-src 'self' 'unsafe-inline' https://unpkg.com https://cdn.jsdelivr.net https://esm.sh https://matomo.morenorub.io; " + + "script-src 'self' 'unsafe-inline' https://unpkg.com https://cdn.jsdelivr.net https://esm.sh https://matomo.txeo.club; " + "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; " + "font-src 'self' https://fonts.gstatic.com; " + "img-src 'self' data: https:; " + - "connect-src 'self' https://api.iconify.design https://matomo.morenorub.io; " + + "connect-src 'self' https://api.iconify.design https://matomo.txeo.club; " + "frame-ancestors 'self'; " + "base-uri 'self'; " + "form-action 'self'" diff --git a/templates/partials/layout/body-scripts.html b/templates/partials/layout/body-scripts.html index 774bf43..1531162 100644 --- a/templates/partials/layout/body-scripts.html +++ b/templates/partials/layout/body-scripts.html @@ -80,7 +80,7 @@ _paq.push(['trackPageView']); _paq.push(['enableLinkTracking']); (function() { - var u="https://matomo.morenorub.io/"; + var u="https://matomo.txeo.club/"; _paq.push(['setTrackerUrl', u+'matomo.php']); _paq.push(['setSiteId', '4']); var d=document, g=d.createElement('script'), s=d.getElementsByTagName('script')[0];