init
Some checks failed
release-tag / release-image (push) Failing after 38s
build-binaries / build (, amd64, linux) (push) Has been skipped
build-binaries / build (, arm, 7, linux) (push) Has been skipped
build-binaries / build (, arm64, linux) (push) Has been skipped
build-binaries / build (.exe, amd64, windows) (push) Has been skipped
build-binaries / release (push) Has been skipped
Some checks failed
release-tag / release-image (push) Failing after 38s
build-binaries / build (, amd64, linux) (push) Has been skipped
build-binaries / build (, arm, 7, linux) (push) Has been skipped
build-binaries / build (, arm64, linux) (push) Has been skipped
build-binaries / build (.exe, amd64, windows) (push) Has been skipped
build-binaries / release (push) Has been skipped
This commit is contained in:
274
main.go
Normal file
274
main.go
Normal file
@@ -0,0 +1,274 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"encoding/json"
|
||||
"html/template"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
type App struct {
|
||||
Title string `json:"title"`
|
||||
URL string `json:"url"`
|
||||
Icon string `json:"icon"`
|
||||
Category string `json:"category"`
|
||||
Color string `json:"color"`
|
||||
}
|
||||
|
||||
type PageData struct {
|
||||
Title string
|
||||
Apps []App
|
||||
Categories []string
|
||||
Now time.Time
|
||||
}
|
||||
|
||||
func getenv(key, def string) string {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
return v
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
func must(err error) {
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func loadApps(path string) ([]App, []string, error) {
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
var apps []App
|
||||
if err := json.Unmarshal(b, &apps); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
sort.Slice(apps, func(i, j int) bool {
|
||||
return strings.ToLower(apps[i].Title) < strings.ToLower(apps[j].Title)
|
||||
})
|
||||
seen := map[string]bool{}
|
||||
var cats []string
|
||||
for _, a := range apps {
|
||||
if a.Category == "" {
|
||||
continue
|
||||
}
|
||||
if !seen[a.Category] {
|
||||
seen[a.Category] = true
|
||||
cats = append(cats, a.Category)
|
||||
}
|
||||
}
|
||||
sort.Strings(cats)
|
||||
return apps, cats, nil
|
||||
}
|
||||
|
||||
func withSecurityHeaders(next http.Handler, hsts bool) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
w.Header().Set("X-Frame-Options", "DENY")
|
||||
w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
|
||||
w.Header().Set("X-XSS-Protection", "0")
|
||||
w.Header().Set("Content-Security-Policy",
|
||||
"default-src 'self'; img-src 'self' data: https:; style-src 'self' https://fonts.googleapis.com; font-src https://fonts.gstatic.com; script-src 'self';")
|
||||
|
||||
if hsts && r.TLS != nil {
|
||||
w.Header().Set("Strict-Transport-Security", "max-age=15552000; includeSubDomains")
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func redirectToHTTPS(httpsAddr string) http.Handler {
|
||||
hostPort := func(host string) string {
|
||||
h, _, err := net.SplitHostPort(host)
|
||||
if err != nil {
|
||||
return host
|
||||
}
|
||||
return h
|
||||
}
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
host := r.Host
|
||||
httpsPort := ":8443"
|
||||
if httpsAddr != "" && httpsAddr != ":8443" {
|
||||
httpsPort = httpsAddr
|
||||
}
|
||||
targetHost := hostPort(host)
|
||||
if _, _, err := net.SplitHostPort(host); err == nil {
|
||||
targetHost = hostPort(host) + httpsPort
|
||||
} else {
|
||||
if httpsPort != ":8443" {
|
||||
targetHost = host + httpsPort
|
||||
}
|
||||
}
|
||||
target := "https://" + targetHost + r.URL.RequestURI()
|
||||
http.Redirect(w, r, target, http.StatusMovedPermanently)
|
||||
})
|
||||
}
|
||||
|
||||
func main() {
|
||||
// App-Config
|
||||
appTitle := getenv("APP_TITLE", "Mein App-Store")
|
||||
jsonPath := getenv("APPS_JSON", "apps.json")
|
||||
staticDir := getenv("STATIC_DIR", "static")
|
||||
tmplDir := getenv("TEMPLATE_DIR", "templates")
|
||||
|
||||
// Server-Modus
|
||||
serverMode := strings.ToLower(getenv("SERVER_MODE", "http")) // "http" | "https"
|
||||
addr := getenv("ADDR", func() string {
|
||||
if serverMode == "https" {
|
||||
return ":8443"
|
||||
}
|
||||
return ":8080"
|
||||
}())
|
||||
|
||||
// HTTPS-spezifisch
|
||||
certFile := getenv("TLS_CERT_FILE", "")
|
||||
keyFile := getenv("TLS_KEY_FILE", "")
|
||||
hstsEnabled := getenv("HSTS", "true") == "true"
|
||||
|
||||
// Optionaler Redirect (nur wenn SERVER_MODE=https)
|
||||
redirectAddr := getenv("HTTP_REDIRECT_ADDR", ":8080")
|
||||
redirectEnabled := getenv("HTTP_REDIRECT_ENABLED", "true") == "true"
|
||||
|
||||
// Templates
|
||||
tmpl := template.Must(template.New("index.html").Funcs(template.FuncMap{
|
||||
"safeURL": func(u string) template.URL { return template.URL(u) },
|
||||
"hasPrefix": strings.HasPrefix,
|
||||
}).ParseFiles(filepath.Join(tmplDir, "index.html")))
|
||||
|
||||
readData := func() (PageData, error) {
|
||||
apps, cats, err := loadApps(jsonPath)
|
||||
if err != nil {
|
||||
return PageData{}, err
|
||||
}
|
||||
return PageData{
|
||||
Title: appTitle,
|
||||
Apps: apps,
|
||||
Categories: cats,
|
||||
Now: time.Now(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Handler
|
||||
mux := http.NewServeMux()
|
||||
mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir(staticDir))))
|
||||
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
data, err := readData()
|
||||
if err != nil {
|
||||
http.Error(w, "Fehler beim Laden der Apps: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
if err := tmpl.ExecuteTemplate(w, "index.html", data); err != nil {
|
||||
log.Println("template error:", err)
|
||||
}
|
||||
})
|
||||
|
||||
// Gemeinsame Server-Instanzen
|
||||
var mainSrv *http.Server
|
||||
var redirSrv *http.Server
|
||||
|
||||
// Graceful Shutdown Verkabelung
|
||||
stop := make(chan os.Signal, 1)
|
||||
signal.Notify(stop, os.Interrupt, syscall.SIGTERM)
|
||||
|
||||
switch serverMode {
|
||||
case "https":
|
||||
// HTTPS mit Security-Headern (inkl. HSTS)
|
||||
secureHandler := withSecurityHeaders(mux, hstsEnabled)
|
||||
|
||||
tlsCfg := &tls.Config{
|
||||
MinVersion: tls.VersionTLS12,
|
||||
PreferServerCipherSuites: true,
|
||||
CurvePreferences: []tls.CurveID{tls.X25519, tls.CurveP256, tls.CurveP384},
|
||||
CipherSuites: []uint16{
|
||||
tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256,
|
||||
tls.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384,
|
||||
tls.TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305,
|
||||
tls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256,
|
||||
tls.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384,
|
||||
tls.TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305,
|
||||
},
|
||||
}
|
||||
|
||||
if certFile == "" || keyFile == "" {
|
||||
log.Fatal("SERVER_MODE=https, aber TLS_CERT_FILE oder TLS_KEY_FILE fehlt.")
|
||||
}
|
||||
|
||||
mainSrv = &http.Server{
|
||||
Addr: addr,
|
||||
Handler: secureHandler,
|
||||
TLSConfig: tlsCfg,
|
||||
ReadTimeout: 15 * time.Second,
|
||||
WriteTimeout: 30 * time.Second,
|
||||
IdleTimeout: 60 * time.Second,
|
||||
}
|
||||
|
||||
// Optionaler HTTP→HTTPS Redirect
|
||||
if redirectEnabled && redirectAddr != "" {
|
||||
redirSrv = &http.Server{
|
||||
Addr: redirectAddr,
|
||||
Handler: redirectToHTTPS(addr),
|
||||
ReadTimeout: 5 * time.Second,
|
||||
WriteTimeout: 10 * time.Second,
|
||||
IdleTimeout: 10 * time.Second,
|
||||
}
|
||||
go func() {
|
||||
log.Printf("HTTP Redirect auf %s → HTTPS %s …", redirectAddr, addr)
|
||||
if err := redirSrv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
log.Printf("Redirect-Server Fehler: %v", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
go func() {
|
||||
log.Printf("HTTPS läuft auf %s …", addr)
|
||||
if err := mainSrv.ListenAndServeTLS(certFile, keyFile); err != nil && err != http.ErrServerClosed {
|
||||
log.Fatalf("HTTPS-Server Fehler: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
case "http":
|
||||
// HTTP ohne HSTS (Security-Header bleiben)
|
||||
handler := withSecurityHeaders(mux, false)
|
||||
mainSrv = &http.Server{
|
||||
Addr: addr,
|
||||
Handler: handler,
|
||||
ReadTimeout: 15 * time.Second,
|
||||
WriteTimeout: 30 * time.Second,
|
||||
IdleTimeout: 60 * time.Second,
|
||||
}
|
||||
go func() {
|
||||
log.Printf("HTTP läuft auf %s …", addr)
|
||||
if err := mainSrv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
log.Fatalf("HTTP-Server Fehler: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
default:
|
||||
log.Fatalf("Ungültiger SERVER_MODE: %q (erwartet: http oder https)", serverMode)
|
||||
}
|
||||
|
||||
// Warten auf Stop-Signal
|
||||
<-stop
|
||||
log.Println("Beende …")
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
if redirSrv != nil {
|
||||
_ = redirSrv.Shutdown(ctx)
|
||||
}
|
||||
if mainSrv != nil {
|
||||
_ = mainSrv.Shutdown(ctx)
|
||||
}
|
||||
log.Println("Sauber beendet.")
|
||||
}
|
||||
Reference in New Issue
Block a user