2026-08-12 21:14:19 +01:00
package webui
import (
"fmt"
"net/http"
"os"
"path/filepath"
"strings"
"time"
2026-08-15 12:35:44 +01:00
"mailgoserver/internal/acmecert"
2026-08-12 21:14:19 +01:00
)
2026-08-15 12:35:44 +01:00
// leAlwaysOverwriteFields are plain (non-secret) [LetsEncrypt] (DNS-01) settings —
// always persisted from the submitted form, same as any other settings.html field.
2026-08-12 21:14:19 +01:00
var leAlwaysOverwriteFields = [] string {
"enabled" , "staging" , "contact_email" , "domains" , "dns_provider" ,
"route53_region" , "route53_hosted_zone_id" , "gcloud_project" ,
}
2026-08-15 12:35:44 +01:00
// leHTTPFields are the [LetsEncryptHTTP] (HTTP-01) settings, all plain — no secrets to
// redact, unlike the DNS-01 providers' API credentials.
var leHTTPFields = [] string {
"enabled" , "staging" , "contact_email" , "domains" , "include_ip" , "ip_override" ,
}
2026-08-12 21:14:19 +01:00
// leSecretFields hold DNS provider credentials. They're never rendered back into the
// form (always blank) and the save handler only overwrites the stored value when the
// submitted field is non-empty — "leave blank to keep the current value", the same
// idiom edit_sender.html already uses for its password field.
var leSecretFields = [] string {
"cloudflare_api_token" , "route53_access_key_id" , "route53_secret_access_key" ,
"digitalocean_api_token" , "gcloud_service_account_json_path" ,
}
2026-08-15 12:35:44 +01:00
// letsEncryptPage shows the current Let's Encrypt status and configuration forms for
// both the DNS-01 and HTTP-01 managers. Secret fields are always blank in the rendered
// form — see leSecretFields.
2026-08-12 21:14:19 +01:00
func ( a * App ) letsEncryptPage ( w http . ResponseWriter , r * http . Request ) {
sec := a . Cfg . Section ( "LetsEncrypt" )
kv := M {}
for _ , k := range leAlwaysOverwriteFields {
kv [ k ] = sec . Key ( k ). String ()
}
for _ , k := range leSecretFields {
kv [ k ] = ""
}
2026-08-15 12:35:44 +01:00
httpSec := a . Cfg . Section ( "LetsEncryptHTTP" )
httpKV := M {}
for _ , k := range leHTTPFields {
httpKV [ k ] = httpSec . Key ( k ). String ()
}
a . render ( w , r , "letsencrypt.html" , M {
"active" : "letsencrypt" ,
"le" : kv , "status" : a . ACME . Status (),
"leHTTP" : httpKV , "statusHTTP" : a . ACMEHTTP . Status (),
})
2026-08-12 21:14:19 +01:00
}
// letsEncryptSave is a dedicated handler (not the generic settingsUpdate reflection)
// specifically because of leSecretFields' blank-means-keep-existing semantics —
// settingsUpdate would otherwise blank out a stored credential whenever this form is
// submitted with a secret field left empty.
func ( a * App ) letsEncryptSave ( w http . ResponseWriter , r * http . Request ) {
if err := r . ParseForm (); err != nil {
setFlash ( w , "error" , "Invalid form data" )
http . Redirect ( w , r , Prefix + "/letsencrypt" , http . StatusFound )
return
}
sec := a . Cfg . Section ( "LetsEncrypt" )
for _ , k := range leAlwaysOverwriteFields {
sec . Key ( k ). SetValue ( r . FormValue ( k ))
}
for _ , k := range leSecretFields {
if v := r . FormValue ( k ); v != "" {
sec . Key ( k ). SetValue ( v )
}
}
if err := a . Cfg . SaveTo ( a . ConfigPath ); err != nil {
setFlash ( w , "error" , "Error saving settings: " + err . Error ())
http . Redirect ( w , r , Prefix + "/letsencrypt" , http . StatusFound )
return
}
setFlash ( w , "success" , `Let's Encrypt settings saved. Use "Obtain / Renew Now" to test the configuration.` )
http . Redirect ( w , r , Prefix + "/letsencrypt" , http . StatusFound )
}
// letsEncryptObtainNow triggers an immediate obtain/renew — separate from Save, since
// saving configuration must never silently kick off an ACME transaction as a side
// effect. This is also how the very first certificate actually gets obtained.
func ( a * App ) letsEncryptObtainNow ( w http . ResponseWriter , r * http . Request ) {
if err := a . ACME . ObtainOrRenew ( r . Context ()); err != nil {
setFlash ( w , "error" , "Could not obtain certificate: " + err . Error ())
} else {
setFlash ( w , "success" , "Certificate obtained successfully" )
}
http . Redirect ( w , r , Prefix + "/letsencrypt" , http . StatusFound )
}
2026-08-15 12:35:44 +01:00
// letsEncryptHTTPSave mirrors letsEncryptSave for the [LetsEncryptHTTP] section — a
// separate handler (not the generic settingsUpdate reflection) purely so it can
// redirect back to /letsencrypt like its DNS-01 sibling; every field here is plain, so
// unlike letsEncryptSave there's no secret-redaction concern.
func ( a * App ) letsEncryptHTTPSave ( w http . ResponseWriter , r * http . Request ) {
if err := r . ParseForm (); err != nil {
setFlash ( w , "error" , "Invalid form data" )
http . Redirect ( w , r , Prefix + "/letsencrypt" , http . StatusFound )
return
}
sec := a . Cfg . Section ( "LetsEncryptHTTP" )
for _ , k := range leHTTPFields {
sec . Key ( k ). SetValue ( r . FormValue ( k ))
}
if err := a . Cfg . SaveTo ( a . ConfigPath ); err != nil {
setFlash ( w , "error" , "Error saving settings: " + err . Error ())
http . Redirect ( w , r , Prefix + "/letsencrypt" , http . StatusFound )
return
}
setFlash ( w , "success" , `Let's Encrypt HTTP-01 settings saved. If you just changed "Enable HTTP-01" or the port, restart the server before using "Obtain / Renew Now" — the challenge responder only starts at boot.` )
http . Redirect ( w , r , Prefix + "/letsencrypt" , http . StatusFound )
}
// letsEncryptHTTPObtainNow mirrors letsEncryptObtainNow for the HTTP-01 manager.
func ( a * App ) letsEncryptHTTPObtainNow ( w http . ResponseWriter , r * http . Request ) {
if err := a . ACMEHTTP . ObtainOrRenew ( r . Context ()); err != nil {
setFlash ( w , "error" , "Could not obtain certificate: " + err . Error ())
} else {
setFlash ( w , "success" , "Certificate obtained successfully" )
}
http . Redirect ( w , r , Prefix + "/letsencrypt" , http . StatusFound )
}
2026-08-12 21:14:19 +01:00
// uploadGCloudServiceAccount mirrors settings.go's uploadTLSFile two-step flow: upload
// the file, return its saved path as JSON, and the browser fills a sibling text input
// with that path — the path only actually persists once the surrounding form (Save)
// is submitted.
func ( a * App ) uploadGCloudServiceAccount ( w http . ResponseWriter , r * http . Request ) {
if err := r . ParseMultipartForm ( 10 << 20 ); err != nil {
writeJSON ( w , http . StatusBadRequest , M { "status" : "error" , "message" : "Invalid upload" })
return
}
file , header , err := r . FormFile ( "gcloud_key_file" )
if err != nil {
writeJSON ( w , http . StatusBadRequest , M { "status" : "error" , "message" : "No file provided" })
return
}
defer file . Close ()
if ext := strings . ToLower ( filepath . Ext ( header . Filename )); ext != ".json" {
writeJSON ( w , http . StatusBadRequest , M { "status" : "error" , "message" : "Expected a .json service account key file" })
return
}
acmeDir := filepath . Join ( filepath . Dir ( a . ConfigPath ), "server_data" , "acme" )
os . MkdirAll ( acmeDir , 0 o755 )
filePath := filepath . Join ( acmeDir , fmt . Sprintf ( "gcloud-sa-%d.json" , time . Now (). Unix ()))
out , err := os . Create ( filePath )
if err != nil {
writeJSON ( w , http . StatusInternalServerError , M { "status" : "error" , "message" : "Could not save file" })
return
}
defer out . Close ()
if _ , err := out . ReadFrom ( file ); err != nil {
writeJSON ( w , http . StatusInternalServerError , M { "status" : "error" , "message" : "Could not save file" })
return
}
writeJSON ( w , http . StatusOK , M { "status" : "success" , "filepath" : filePath })
}
2026-08-15 12:35:44 +01:00
// detectWANIP backs the "Detect" button next to the HTTP-01 manual IP override field —
// a live lookup, not persisted anywhere until the surrounding form is saved.
func ( a * App ) detectWANIP ( w http . ResponseWriter , r * http . Request ) {
ip , err := acmecert . DetectWANIP ( r . Context ())
if err != nil {
writeJSON ( w , http . StatusBadGateway , M { "status" : "error" , "message" : err . Error ()})
return
}
writeJSON ( w , http . StatusOK , M { "status" : "success" , "ip" : ip })
}