mirror of
https://github.com/Alexander-D-Karpov/about.git
synced 2026-03-16 22:06:08 +03:00
368 lines
9.4 KiB
Go
368 lines
9.4 KiB
Go
package handlers
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"embed"
|
|
"fmt"
|
|
"html/template"
|
|
"log"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
"unicode/utf8"
|
|
|
|
"github.com/Alexander-D-Karpov/about/internal/assets"
|
|
"github.com/Alexander-D-Karpov/about/internal/config"
|
|
"github.com/Alexander-D-Karpov/about/internal/plugins"
|
|
)
|
|
|
|
type MainHandler struct {
|
|
pluginManager *plugins.Manager
|
|
config *config.Config
|
|
template *template.Template
|
|
potatoTemplate *template.Template
|
|
bundler *assets.Bundler
|
|
}
|
|
|
|
type TemplateData struct {
|
|
Title string
|
|
Description string
|
|
Canonical string
|
|
OGTitle string
|
|
OGDescription string
|
|
OGImage string
|
|
PotatoMode bool
|
|
CSSHash string
|
|
JSHash string
|
|
Plugins []template.HTML
|
|
}
|
|
|
|
func NewMainHandler(pluginManager *plugins.Manager, cfg *config.Config, templateFiles embed.FS, bundler *assets.Bundler) *MainHandler {
|
|
funcs := template.FuncMap{
|
|
"default": defaultFunc,
|
|
}
|
|
|
|
tmpl, err := template.New("main.html").
|
|
Funcs(funcs).
|
|
ParseFS(templateFiles, "templates/main.html")
|
|
if err != nil {
|
|
log.Fatalf("Error loading main template: %v", err)
|
|
}
|
|
|
|
potatoTmpl, err := template.New("potato.html").
|
|
Funcs(funcs).
|
|
ParseFS(templateFiles, "templates/potato.html")
|
|
if err != nil {
|
|
log.Printf("Warning: potato template not found, using main template: %v", err)
|
|
potatoTmpl = tmpl
|
|
}
|
|
|
|
return &MainHandler{
|
|
pluginManager: pluginManager,
|
|
config: cfg,
|
|
template: tmpl,
|
|
potatoTemplate: potatoTmpl,
|
|
bundler: bundler,
|
|
}
|
|
}
|
|
|
|
func defaultFunc(v any, def string) string {
|
|
if s, ok := v.(string); ok && strings.TrimSpace(s) != "" {
|
|
return s
|
|
}
|
|
if v != nil {
|
|
switch x := v.(type) {
|
|
case bool:
|
|
if x {
|
|
return "true"
|
|
}
|
|
case int, int64, float64:
|
|
if fmt.Sprint(x) != "0" {
|
|
return fmt.Sprint(x)
|
|
}
|
|
}
|
|
}
|
|
return def
|
|
}
|
|
|
|
func (h *MainHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet {
|
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
if r.URL.Path != "/" {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
|
|
mainCtx, cancel := context.WithTimeout(r.Context(), 6*time.Second)
|
|
defer cancel()
|
|
|
|
userAgent := r.Header.Get("User-Agent")
|
|
isCurl := strings.Contains(strings.ToLower(userAgent), "curl")
|
|
|
|
if visitorsPlugin, exists := h.pluginManager.GetPlugin("visitors"); exists {
|
|
if visitors, ok := visitorsPlugin.(*plugins.VisitorsPlugin); ok {
|
|
visitors.RecordVisit(r.UserAgent(), getClientIP(r))
|
|
}
|
|
}
|
|
|
|
if isCurl {
|
|
h.renderTextResponse(w, r)
|
|
return
|
|
}
|
|
|
|
potatoMode := IsPotatoMode(r)
|
|
|
|
// Set cookie if potato mode activated via URL param
|
|
if _, hasParam := r.URL.Query()["potato"]; hasParam {
|
|
val := r.URL.Query().Get("potato")
|
|
cookieVal := "1"
|
|
if val == "0" || val == "false" {
|
|
cookieVal = "0"
|
|
}
|
|
http.SetCookie(w, &http.Cookie{
|
|
Name: "potato_mode",
|
|
Value: cookieVal,
|
|
Path: "/",
|
|
MaxAge: 365 * 24 * 60 * 60,
|
|
HttpOnly: false,
|
|
SameSite: http.SameSiteLaxMode,
|
|
})
|
|
}
|
|
|
|
type renderResult struct {
|
|
plugins []template.HTML
|
|
err error
|
|
}
|
|
|
|
done := make(chan renderResult, 1)
|
|
|
|
go func() {
|
|
defer func() {
|
|
if rec := recover(); rec != nil {
|
|
log.Printf("Plugin rendering panic: %v", rec)
|
|
done <- renderResult{nil, fmt.Errorf("rendering failed: %v", rec)}
|
|
}
|
|
}()
|
|
|
|
renderedPlugins := h.pluginManager.GetRenderedPlugins(mainCtx)
|
|
done <- renderResult{renderedPlugins, nil}
|
|
}()
|
|
|
|
var renderedPlugins []template.HTML
|
|
|
|
select {
|
|
case result := <-done:
|
|
if result.err != nil {
|
|
log.Printf("Plugin rendering error: %v", result.err)
|
|
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
renderedPlugins = result.plugins
|
|
|
|
case <-mainCtx.Done():
|
|
log.Printf("Main handler timeout - rendering taking too long")
|
|
h.serveMinimalPage(w)
|
|
return
|
|
|
|
case <-r.Context().Done():
|
|
log.Printf("Client disconnected during rendering")
|
|
return
|
|
}
|
|
|
|
var cssHash, jsHash string
|
|
if potatoMode {
|
|
_, cssHash = h.bundler.PotatoCSSBundle()
|
|
_, jsHash = h.bundler.PotatoJSBundle()
|
|
} else {
|
|
_, cssHash = h.bundler.CSSBundle()
|
|
_, jsHash = h.bundler.JSBundle()
|
|
}
|
|
|
|
data := TemplateData{
|
|
Title: "sanspie",
|
|
Description: "WebDev & DevSecOps",
|
|
PotatoMode: potatoMode,
|
|
CSSHash: cssHash,
|
|
JSHash: jsHash,
|
|
Plugins: renderedPlugins,
|
|
}
|
|
|
|
var buf bytes.Buffer
|
|
templateCtx, templateCancel := context.WithTimeout(mainCtx, 1*time.Second)
|
|
defer templateCancel()
|
|
|
|
var tmpl *template.Template
|
|
if potatoMode {
|
|
tmpl = h.potatoTemplate
|
|
} else {
|
|
tmpl = h.template
|
|
}
|
|
|
|
templateDone := make(chan error, 1)
|
|
go func() {
|
|
templateDone <- tmpl.Execute(&buf, data)
|
|
}()
|
|
|
|
select {
|
|
case err := <-templateDone:
|
|
if err != nil {
|
|
log.Printf("Error executing template: %v", err)
|
|
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
case <-templateCtx.Done():
|
|
log.Printf("Template execution timeout")
|
|
http.Error(w, "Request timeout", http.StatusRequestTimeout)
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
|
|
w.Header().Set("Pragma", "no-cache")
|
|
w.Header().Set("Expires", "0")
|
|
w.Header().Set("X-Content-Type-Options", "nosniff")
|
|
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = buf.WriteTo(w)
|
|
}
|
|
|
|
func (h *MainHandler) serveMinimalPage(w http.ResponseWriter) {
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
|
|
w.WriteHeader(http.StatusServiceUnavailable)
|
|
|
|
minimalHTML := `<!DOCTYPE html>
|
|
<html><head><title>Loading...</title>
|
|
<style>body{font-family:system-ui;background:#0a0a0a;color:#e0e0e0;display:flex;align-items:center;justify-content:center;min-height:100vh;margin:0}
|
|
.c{text-align:center;padding:40px}.s{width:40px;height:40px;border:3px solid #333;border-top-color:#6a9fff;border-radius:50%;animation:s 1s linear infinite;margin:0 auto 20px}
|
|
@keyframes s{to{transform:rotate(360deg)}}</style></head>
|
|
<body><div class="c"><div class="s"></div><h1>Loading...</h1><p>Please wait or refresh in a moment.</p></div>
|
|
<script>setTimeout(()=>location.reload(),3000)</script></body></html>`
|
|
|
|
w.Write([]byte(minimalHTML))
|
|
}
|
|
|
|
func (h *MainHandler) renderTextResponse(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
|
w.WriteHeader(http.StatusOK)
|
|
|
|
ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second)
|
|
defer cancel()
|
|
|
|
if visitorsPlugin, exists := h.pluginManager.GetPlugin("visitors"); exists {
|
|
if visitors, ok := visitorsPlugin.(*plugins.VisitorsPlugin); ok {
|
|
visitors.RecordVisit(r.UserAgent(), getClientIP(r))
|
|
}
|
|
}
|
|
|
|
textPlugins := h.pluginManager.GetTextRenderedPlugins(ctx)
|
|
systemSummary := h.pluginManager.GetSystemTextSummary()
|
|
|
|
width := 63
|
|
innerWidth := width - 4
|
|
headerText := "sanspie - About Page"
|
|
centeredHeader := centerText(headerText, innerWidth)
|
|
|
|
fmt.Fprintf(w, "┌%s┐\n", strings.Repeat("─", width-2))
|
|
fmt.Fprintf(w, "│ %s │\n", centeredHeader)
|
|
fmt.Fprintf(w, "├%s┤\n", strings.Repeat("─", width-2))
|
|
|
|
for _, pluginText := range textPlugins {
|
|
if pluginText != "" {
|
|
lines := wrapText(pluginText, innerWidth)
|
|
for _, line := range lines {
|
|
paddedLine := padTextToWidth(line, innerWidth)
|
|
fmt.Fprintf(w, "│ %s │\n", paddedLine)
|
|
}
|
|
}
|
|
}
|
|
|
|
if systemSummary != "" {
|
|
fmt.Fprintf(w, "├%s┤\n", strings.Repeat("─", width-2))
|
|
paddedSummary := padTextToWidth(systemSummary, innerWidth)
|
|
fmt.Fprintf(w, "│ %s │\n", paddedSummary)
|
|
}
|
|
|
|
fmt.Fprintf(w, "├%s┤\n", strings.Repeat("─", width-2))
|
|
fmt.Fprintf(w, "│ %s │\n", padTextToWidth("Access:", innerWidth))
|
|
fmt.Fprintf(w, "│ %s │\n", padTextToWidth(" Web: https://about.akarpov.ru", innerWidth))
|
|
fmt.Fprintf(w, "│ %s │\n", padTextToWidth(" API: curl /health, /status", innerWidth))
|
|
fmt.Fprintf(w, "└%s┘\n", strings.Repeat("─", width-2))
|
|
}
|
|
|
|
func centerText(text string, width int) string {
|
|
textLen := utf8.RuneCountInString(text)
|
|
if textLen >= width {
|
|
runes := []rune(text)
|
|
return string(runes[:width])
|
|
}
|
|
|
|
padding := width - textLen
|
|
leftPad := padding / 2
|
|
rightPad := padding - leftPad
|
|
|
|
return strings.Repeat(" ", leftPad) + text + strings.Repeat(" ", rightPad)
|
|
}
|
|
|
|
func padTextToWidth(text string, width int) string {
|
|
textLen := utf8.RuneCountInString(text)
|
|
if textLen >= width {
|
|
runes := []rune(text)
|
|
return string(runes[:width])
|
|
}
|
|
return text + strings.Repeat(" ", width-textLen)
|
|
}
|
|
|
|
func wrapText(text string, width int) []string {
|
|
textLen := utf8.RuneCountInString(text)
|
|
if textLen <= width {
|
|
return []string{text}
|
|
}
|
|
|
|
var lines []string
|
|
words := strings.Fields(text)
|
|
var currentLine string
|
|
|
|
for _, word := range words {
|
|
testLine := currentLine
|
|
if testLine != "" {
|
|
testLine += " "
|
|
}
|
|
testLine += word
|
|
|
|
if utf8.RuneCountInString(testLine) <= width {
|
|
currentLine = testLine
|
|
} else {
|
|
if currentLine != "" {
|
|
lines = append(lines, currentLine)
|
|
}
|
|
currentLine = word
|
|
if utf8.RuneCountInString(currentLine) > width {
|
|
runes := []rune(currentLine)
|
|
lines = append(lines, string(runes[:width-3])+"...")
|
|
currentLine = ""
|
|
}
|
|
}
|
|
}
|
|
|
|
if currentLine != "" {
|
|
lines = append(lines, currentLine)
|
|
}
|
|
|
|
return lines
|
|
}
|
|
|
|
func getClientIP(r *http.Request) string {
|
|
if forwarded := r.Header.Get("X-Forwarded-For"); forwarded != "" {
|
|
parts := strings.Split(forwarded, ",")
|
|
return strings.TrimSpace(parts[0])
|
|
}
|
|
if realIP := r.Header.Get("X-Real-IP"); realIP != "" {
|
|
return realIP
|
|
}
|
|
return r.RemoteAddr
|
|
}
|