Files
webnews/main.go
Sudo-Ivan 28273473e1
Some checks failed
OSV-Scanner Scheduled Scan / scan-scheduled (push) Successful in 28s
CI / build-backend (push) Failing after 31s
CI / build-frontend (push) Successful in 50s
0.1.0
2025-12-26 21:31:05 -06:00

221 lines
5.8 KiB
Go

package main
import (
"embed"
"encoding/json"
"flag"
"io/fs"
"log"
"net"
"net/http"
"os"
"strings"
"time"
"git.quad4.io/Quad4-Software/webnews/internal/api"
)
//go:embed build/*
var buildAssets embed.FS
func corsMiddleware(allowedOrigins []string) func(http.HandlerFunc) http.HandlerFunc {
return func(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
origin := r.Header.Get("Origin")
if origin == "" {
next.ServeHTTP(w, r)
return
}
allowed := false
if len(allowedOrigins) == 0 {
allowed = true
} else {
for _, o := range allowedOrigins {
if o == "*" || o == origin {
allowed = true
break
}
}
}
if allowed {
w.Header().Set("Access-Control-Allow-Origin", origin)
w.Header().Set("Access-Control-Allow-Methods", "GET, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
}
if r.Method == "OPTIONS" {
if allowed {
w.WriteHeader(http.StatusOK)
} else {
w.WriteHeader(http.StatusForbidden)
}
return
}
if !allowed && len(allowedOrigins) > 0 {
log.Printf("Blocked CORS request from origin: %s", origin)
http.Error(w, "CORS Origin Not Allowed", http.StatusForbidden)
return
}
next.ServeHTTP(w, r)
}
}
}
func main() {
frontendPath := flag.String("frontend", "", "Path to custom frontend build directory (overrides embedded assets)")
host := flag.String("host", "0.0.0.0", "Host to bind the server to")
port := flag.String("port", "", "Port to listen on (overrides PORT env var)")
allowedOriginsStr := flag.String("allowed-origins", os.Getenv("ALLOWED_ORIGINS"), "Comma-separated list of allowed CORS origins")
// Auth flags
authMode := flag.String("auth-mode", "none", "Authentication mode: none, token, multi")
authToken := flag.String("auth-token", os.Getenv("AUTH_TOKEN"), "Master token for 'token' auth mode")
authFile := flag.String("auth-file", "accounts.json", "File to store accounts for 'multi' auth mode")
allowReg := flag.Bool("allow-registration", true, "Allow new account generation in 'multi' mode")
hashesFile := flag.String("hashes-file", "client_hashes.json", "File to store IP+UA hashes for rate limiting")
disableProtection := flag.Bool("disable-protection", false, "Disable rate limiting and bot protection")
flag.Parse()
if *hashesFile != "" {
api.Limiter.File = *hashesFile
api.Limiter.LoadHashes()
}
am := api.NewAuthManager(*authMode, *authToken, *authFile, *allowReg)
var allowedOrigins []string
if *allowedOriginsStr != "" {
origins := strings.Split(*allowedOriginsStr, ",")
for _, o := range origins {
allowedOrigins = append(allowedOrigins, strings.TrimSpace(o))
}
}
if *port == "" {
*port = os.Getenv("PORT")
if *port == "" {
*port = "8080"
}
}
// Middleware chains
cors := corsMiddleware(allowedOrigins)
auth := func(h http.HandlerFunc) http.HandlerFunc {
return api.AuthMiddleware(am, h)
}
// Setup handlers with optional protection
bot := func(h http.HandlerFunc) http.HandlerFunc {
if *disableProtection {
return h
}
return api.BotBlockerMiddleware(h)
}
limit := func(h http.HandlerFunc) http.HandlerFunc {
if *disableProtection {
return h
}
return api.LimitMiddleware(h)
}
apiHandler := cors(auth(bot(limit(api.HandleFeedProxy))))
proxyHandler := cors(auth(bot(limit(api.HandleProxy))))
fullTextHandler := cors(auth(bot(limit(api.HandleFullText))))
pingHandler := cors(bot(limit(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
// Include auth info in ping if no specific origin check is needed
authRequired := am.Mode != "none"
canRegister := am.Mode == "multi" && am.AllowRegistration
if err := json.NewEncoder(w).Encode(map[string]any{
"status": "ok",
"auth": map[string]any{
"required": authRequired,
"mode": am.Mode,
"canReg": canRegister,
},
}); err != nil {
log.Printf("Error encoding ping response: %v", err)
}
})))
// Auth Routes
http.HandleFunc("/api/auth/register", cors(func(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
token, err := am.Register()
if err != nil {
http.Error(w, "Registration disabled", http.StatusForbidden)
return
}
if err := json.NewEncoder(w).Encode(map[string]string{"accountNumber": token}); err != nil {
log.Printf("Error encoding registration response: %v", err)
}
}))
http.HandleFunc("/api/auth/verify", cors(auth(func(w http.ResponseWriter, r *http.Request) {
if err := json.NewEncoder(w).Encode(map[string]bool{"valid": true}); err != nil {
log.Printf("Error encoding verification response: %v", err)
}
})))
http.HandleFunc("/api/feed", apiHandler)
http.HandleFunc("/api/proxy", proxyHandler)
http.HandleFunc("/api/fulltext", fullTextHandler)
http.HandleFunc("/api/ping", pingHandler)
// Static Assets
var staticFS fs.FS
if *frontendPath != "" {
log.Printf("Using custom frontend from: %s\n", *frontendPath)
staticFS = os.DirFS(*frontendPath)
} else {
sub, err := fs.Sub(buildAssets, "build")
if err != nil {
log.Fatal(err)
}
staticFS = sub
}
fileServer := http.FileServer(http.FS(staticFS))
// SPA Handler
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
path := strings.TrimPrefix(r.URL.Path, "/")
if path == "" {
path = "index.html"
}
_, err := staticFS.Open(path)
if err != nil {
r.URL.Path = "/"
}
fileServer.ServeHTTP(w, r)
})
addr := net.JoinHostPort(*host, *port)
log.Printf("Web News server starting on %s...\n", addr)
server := &http.Server{
Addr: addr,
Handler: nil,
ReadTimeout: 15 * time.Second,
WriteTimeout: 15 * time.Second,
IdleTimeout: 60 * time.Second,
}
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatal(err)
}
}