chore: update Matomo analytics URL to matomo.txeo.club
Migrate from matomo.morenorub.io to matomo.txeo.club in both CSP headers and tracking script. Also fix errcheck lint warnings in cv_streaming.go.
This commit is contained in:
@@ -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 <head> 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 := `<script>document.body.innerHTML = '<div style="padding:2rem;text-align:center;"><h1>Error loading CV</h1><p>Please refresh the page.</p></div>';</script>`
|
||||
_, _ = 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 := `<script>document.body.innerHTML = '<div style="padding:2rem;text-align:center;"><h1>Template Error</h1></div>';</script>`
|
||||
_, _ = w.Write([]byte(errorHTML))
|
||||
flusher.Flush()
|
||||
return
|
||||
}
|
||||
|
||||
var contentBuf bytes.Buffer
|
||||
if err := tmpl.Execute(&contentBuf, data); err != nil {
|
||||
errorHTML := `<script>document.body.innerHTML = '<div style="padding:2rem;text-align:center;"><h1>Render Error</h1></div>';</script>`
|
||||
_, _ = 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 := `<script>
|
||||
(function() {
|
||||
var newHTML = ` + escapedHTML + `;
|
||||
|
||||
// Parse the new HTML
|
||||
var parser = new DOMParser();
|
||||
var newDoc = parser.parseFromString(newHTML, 'text/html');
|
||||
|
||||
// Replace the entire document
|
||||
document.documentElement.innerHTML = newDoc.documentElement.innerHTML;
|
||||
|
||||
// Copy attributes from new html tag (like lang)
|
||||
Array.from(newDoc.documentElement.attributes).forEach(function(attr) {
|
||||
document.documentElement.setAttribute(attr.name, attr.value);
|
||||
});
|
||||
|
||||
// Copy attributes from new body tag (like class, hyperscript _)
|
||||
var newBody = newDoc.body;
|
||||
var currentBody = document.body;
|
||||
Array.from(newBody.attributes).forEach(function(attr) {
|
||||
currentBody.setAttribute(attr.name, attr.value);
|
||||
});
|
||||
|
||||
// Re-execute scripts (they don't run when inserted via innerHTML)
|
||||
document.querySelectorAll('script').forEach(function(oldScript) {
|
||||
if (oldScript.src) {
|
||||
// External scripts - reload them
|
||||
var newScript = document.createElement('script');
|
||||
Array.from(oldScript.attributes).forEach(function(attr) {
|
||||
newScript.setAttribute(attr.name, attr.value);
|
||||
});
|
||||
oldScript.parentNode.replaceChild(newScript, oldScript);
|
||||
}
|
||||
});
|
||||
|
||||
// Trigger DOMContentLoaded for any listeners
|
||||
document.dispatchEvent(new Event('DOMContentLoaded'));
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>`
|
||||
|
||||
_, _ = 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 `<!DOCTYPE html>
|
||||
<html lang="` + langAttr + `">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>` + title + `</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #f5f5f5;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
}
|
||||
.streaming-loader {
|
||||
text-align: center;
|
||||
}
|
||||
.streaming-spinner {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
border: 4px solid #e0e0e0;
|
||||
border-top-color: #2ecc71;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
margin: 0 auto 1rem;
|
||||
}
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
.streaming-loader-text {
|
||||
color: #666;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
body { background: #1a1a1a; }
|
||||
.streaming-spinner { border-color: #333; border-top-color: #2ecc71; }
|
||||
.streaming-loader-text { color: #aaa; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="streaming-loader" class="streaming-loader">
|
||||
<div class="streaming-spinner"></div>
|
||||
<p class="streaming-loader-text">` + title + `</p>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
|
||||
// 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 + "`"
|
||||
}
|
||||
@@ -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'"
|
||||
|
||||
@@ -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];
|
||||
|
||||
Reference in New Issue
Block a user