package api
import (
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"html/template"
"io"
"log/slog"
"net/http"
"net/url"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"time"
"github.com/adrian-lorenz/privacy-guard-proxy/internal/detector"
"github.com/adrian-lorenz/privacy-guard-proxy/internal/proxy"
)
// ─── Session auth ──────────────────────────────────────────────────────────────
const adminUser = "admin"
var (
cfgFile string
sessions = map[string]struct{}{}
sessionsMu sync.Mutex
)
// hashPassword returns hex(sha256(password)).
func hashPassword(pw string) string {
sum := sha256.Sum256([]byte(pw))
return hex.EncodeToString(sum[:])
}
// uiPasswordHash returns the stored password hash, or the hash of the default
// "admin" password if none has been set yet.
func uiPasswordHash() string {
root, _ := proxy.LoadConfigs(cfgFile)
if root.UIPasswordHash != "" {
return root.UIPasswordHash
}
return hashPassword("admin")
}
// uiMustChangePassword returns true when the admin password is still the
// factory default (no UIPasswordHash stored in config.json).
func uiMustChangePassword() bool {
root, _ := proxy.LoadConfigs(cfgFile)
return root.UIPasswordHash == ""
}
var allDetectors = []string{
"EMAIL", "PHONE", "IBAN", "CREDIT_CARD", "TAX_ID",
"SOCIAL_SECURITY", "KVNR", "VAT_ID", "PERSONAL_ID",
"LICENSE_PLATE", "DRIVER_LICENSE", "ADDRESS", "URL_SECRET", "SECRET",
}
func newSessionToken() string {
b := make([]byte, 16)
_, _ = rand.Read(b)
return hex.EncodeToString(b)
}
// renderTmpl writes a named template to w. On error it sends a 500 before any
// bytes are flushed (template errors are only possible at startup if a template
// file is malformed, which would have caused a panic already).
func renderTmpl(w http.ResponseWriter, name string, data any) {
if err := tmpl.ExecuteTemplate(w, name, data); err != nil {
http.Error(w, "template error: "+err.Error(), http.StatusInternalServerError)
}
}
func isAuthed(r *http.Request) bool {
c, err := r.Cookie("session")
if err != nil {
return false
}
sessionsMu.Lock()
defer sessionsMu.Unlock()
_, ok := sessions[c.Value]
return ok
}
func sameOrigin(r *http.Request) bool {
origin := r.Header.Get("Origin")
if origin != "" {
u, err := url.Parse(origin)
if err != nil {
return false
}
return strings.EqualFold(u.Host, r.Host)
}
ref := r.Header.Get("Referer")
if ref != "" {
u, err := url.Parse(ref)
if err != nil {
return false
}
return strings.EqualFold(u.Host, r.Host)
}
// Non-browser clients may not send Origin/Referer.
return true
}
func isHTTPS(r *http.Request) bool {
return r.TLS != nil || strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https")
}
// ─── Login / Logout ────────────────────────────────────────────────────────────
func handleLoginPage(w http.ResponseWriter, r *http.Request) {
if isAuthed(r) {
http.Redirect(w, r, "/", http.StatusFound)
return
}
renderTmpl(w, "login", map[string]string{"Error": r.URL.Query().Get("err")})
}
func handleLoginSubmit(w http.ResponseWriter, r *http.Request) {
_ = r.ParseForm()
if r.FormValue("username") == adminUser && hashPassword(r.FormValue("password")) == uiPasswordHash() {
tok := newSessionToken()
sessionsMu.Lock()
sessions[tok] = struct{}{}
sessionsMu.Unlock()
http.SetCookie(w, &http.Cookie{
Name: "session",
Value: tok,
Path: "/",
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
Secure: isHTTPS(r),
})
if uiMustChangePassword() {
http.Redirect(w, r, "/change-password", http.StatusFound)
} else {
http.Redirect(w, r, "/", http.StatusFound)
}
return
}
http.Redirect(w, r, "/login?err=Invalid+credentials", http.StatusFound)
}
// ─── Change password ───────────────────────────────────────────────────────────
func handleChangePasswordPage(w http.ResponseWriter, r *http.Request) {
if !isAuthed(r) {
http.Redirect(w, r, "/login", http.StatusFound)
return
}
renderTmpl(w, "change_password", map[string]string{"Error": r.URL.Query().Get("err")})
}
func handleChangePasswordSubmit(w http.ResponseWriter, r *http.Request) {
if !isAuthed(r) {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
if !sameOrigin(r) {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
_ = r.ParseForm()
pw := r.FormValue("password")
pw2 := r.FormValue("password2")
if pw == "" || pw != pw2 {
http.Redirect(w, r, "/change-password?err=Passwords+do+not+match", http.StatusFound)
return
}
if len(pw) < 8 {
http.Redirect(w, r, "/change-password?err=At+least+8+characters+required", http.StatusFound)
return
}
root, err := proxy.LoadConfigs(cfgFile)
if err != nil {
http.Redirect(w, r, "/change-password?err=Configuration+error", http.StatusFound)
return
}
root.UIPasswordHash = hashPassword(pw)
if err := saveConfig(root); err != nil {
http.Redirect(w, r, "/change-password?err=Save+error", http.StatusFound)
return
}
http.Redirect(w, r, "/", http.StatusFound)
}
func handleLogout(w http.ResponseWriter, r *http.Request) {
if c, err := r.Cookie("session"); err == nil {
sessionsMu.Lock()
delete(sessions, c.Value)
sessionsMu.Unlock()
}
http.SetCookie(w, &http.Cookie{
Name: "session",
Value: "",
Path: "/",
MaxAge: -1,
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
Secure: isHTTPS(r),
})
http.Redirect(w, r, "/login", http.StatusFound)
}
// ─── Config page ──────────────────────────────────────────────────────────────
func handleUI(w http.ResponseWriter, r *http.Request) {
if !isAuthed(r) {
http.Redirect(w, r, "/login", http.StatusFound)
return
}
if uiMustChangePassword() {
http.Redirect(w, r, "/change-password", http.StatusFound)
return
}
root, err := proxy.LoadConfigs(cfgFile)
if err != nil {
http.Error(w, "config error: "+err.Error(), 500)
return
}
renderTmpl(w, "config", buildUIData(root))
}
func handleSaveConfig(w http.ResponseWriter, r *http.Request) {
if !isAuthed(r) {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
if !sameOrigin(r) {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
_ = r.ParseForm()
root, err := proxy.LoadConfigs(cfgFile)
if err != nil {
_, _ = fmt.Fprintf(w, `
Fehler: %s
`, template.HTMLEscapeString(err.Error()))
return
}
// api_port
if v := r.FormValue("api_port"); v != "" {
if p, e := strconv.Atoi(v); e == nil {
root.APIPort = p
}
} else {
root.APIPort = 0
}
// default detectors
var defDets []string
for _, d := range allDetectors {
if r.FormValue("default_det_"+d) == "on" {
defDets = append(defDets, d)
}
}
root.DefaultDetectors = defDets
// default whitelist
raw := r.FormValue("default_whitelist")
var defWL []string
for _, s := range strings.FieldsFunc(raw, func(r rune) bool { return r == ',' || r == '\n' || r == '\r' }) {
if t := strings.TrimSpace(s); t != "" {
defWL = append(defWL, t)
}
}
root.DefaultWhitelist = defWL
// cors origins
var corsOrigins []string
for _, s := range strings.FieldsFunc(r.FormValue("cors_origins"), func(r rune) bool { return r == ',' || r == '\n' || r == '\r' }) {
if t := strings.TrimSpace(s); t != "" {
corsOrigins = append(corsOrigins, t)
}
}
root.CORSOrigins = corsOrigins
// per-proxy fields
for i := range root.Proxies {
idx := strconv.Itoa(i)
if v := r.FormValue("port_" + idx); v != "" {
if p, e := strconv.Atoi(v); e == nil {
root.Proxies[i].Port = p
}
}
if v := r.FormValue("upstream_" + idx); v != "" {
root.Proxies[i].Upstream = strings.TrimRight(v, "/")
}
var dets []string
for _, d := range allDetectors {
if r.FormValue("det_"+d+"_"+idx) == "on" {
dets = append(dets, d)
}
}
root.Proxies[i].PrivacyGuard.Detectors = dets
rawWL := r.FormValue("whitelist_" + idx)
var wl []string
for _, s := range strings.FieldsFunc(rawWL, func(r rune) bool { return r == ',' || r == '\n' || r == '\r' }) {
if t := strings.TrimSpace(s); t != "" {
wl = append(wl, t)
}
}
root.Proxies[i].PrivacyGuard.Whitelist = wl
root.Proxies[i].PrivacyGuard.DryRun = r.FormValue("dry_run_"+idx) == "on"
}
if err := saveConfig(root); err != nil {
_, _ = fmt.Fprintf(w, `
`, template.HTMLEscapeString(err.Error()))
return
}
filtered := root.APIKeys[:0]
for _, k := range root.APIKeys {
if k.ID != id {
filtered = append(filtered, k)
}
}
root.APIKeys = filtered
if err := saveConfig(root); err != nil {
_, _ = fmt.Fprintf(w, `
%s
`, template.HTMLEscapeString(err.Error()))
return
}
applyRuntimeConfig(root)
_, _ = fmt.Fprint(w, renderAPIKeySection(root.APIKeys, ""))
}
// renderAPIKeySection returns the innerHTML of #apikey-section.
// newKey is the full plaintext key to show once (empty = no flash).
func renderAPIKeySection(keys []proxy.APIKey, newKey string) string {
var sb strings.Builder
// Flash: new key just created — shown once
if newKey != "" {
esc := template.HTMLEscapeString(newKey)
js := template.JSEscapeString(newKey)
_, _ = fmt.Fprintf(&sb, `
Key erstellt — nur einmal sichtbar, jetzt kopieren!
`)
}
// Add form
sb.WriteString(`
`)
return sb.String()
}
// ─── Test page ────────────────────────────────────────────────────────────────
func handleTestPage(w http.ResponseWriter, r *http.Request) {
if !isAuthed(r) {
http.Redirect(w, r, "/login", http.StatusFound)
return
}
renderTmpl(w, "test", testPageData{ActivePage: "/test", Detectors: allDetectors})
}
func handleUIScan(w http.ResponseWriter, r *http.Request) {
if !isAuthed(r) {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
if !sameOrigin(r) {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
_ = r.ParseForm()
text := r.FormValue("text")
if strings.TrimSpace(text) == "" {
_, _ = fmt.Fprint(w, `
Kein Text eingegeben.
`)
return
}
var dets []string
for _, d := range allDetectors {
if r.FormValue("det_"+d) == "on" {
dets = append(dets, d)
}
}
anonymised, findings := detector.NewScanner(dets).Scan(text)
var sb strings.Builder
sb.WriteString(`