feat: SMTP config, install prompts, API proxy, global settings page

This commit is contained in:
Claus Lohmar 2026-07-08 15:07:59 +01:00
parent 707eb9ec81
commit 3e04d8f1c0
4 changed files with 369 additions and 6 deletions

View file

@ -119,5 +119,14 @@ storage:
path: /data/db.sqlite path: /data/db.sqlite
notifier: notifier:
filesystem: smtp:
filename: /config/notification.yml host: "{SMTP_HOST}"
port: {SMTP_PORT}
username: "{SMTP_USER}"
password: "{SMTP_PASS}"
sender: "{SMTP_USER}"
subject: "NextWorkspace - {DOMAIN}"
disable_require_tls: false
disable_starttls: false
tls:
skip_verify: false

View file

@ -96,6 +96,10 @@ if [ "$GREENFIELD" = true ]; then
sed -e "s|{DOMAIN}|$DOMAIN|g" \ sed -e "s|{DOMAIN}|$DOMAIN|g" \
-e "s|{JWT_SECRET}|$JWT_SECRET|g" \ -e "s|{JWT_SECRET}|$JWT_SECRET|g" \
-e "s|{SESSION_SECRET}|$SESSION_SECRET|g" \ -e "s|{SESSION_SECRET}|$SESSION_SECRET|g" \
-e "s|{SMTP_HOST}|${SMTP_HOST:-smtp.openxchange.eu}|g" \
-e "s|{SMTP_PORT}|${SMTP_PORT:-587}|g" \
-e "s|{SMTP_USER}|${SMTP_USER:-post@nextwks.eu}|g" \
-e "s|{SMTP_PASS}|${SMTP_PASS}|g" \
"$SCRIPT_DIR/config/authelia/configuration.yml" > "$TARGET_DIR/config/authelia/configuration.yml" "$SCRIPT_DIR/config/authelia/configuration.yml" > "$TARGET_DIR/config/authelia/configuration.yml"
# Generate users database # Generate users database

View file

@ -43,6 +43,29 @@ done
# Generate 12-char alphanumeric password (easy to type) # Generate 12-char alphanumeric password (easy to type)
ADMIN_PASSWORD=$(tr -dc 'A-Za-z0-9' < /dev/urandom | head -c 12 2>/dev/null || date +%s | head -c 12) ADMIN_PASSWORD=$(tr -dc 'A-Za-z0-9' < /dev/urandom | head -c 12 2>/dev/null || date +%s | head -c 12)
# --- SMTP prompts ---
read -p "SMTP host [smtp.openxchange.eu]: " SMTP_HOST
SMTP_HOST="${SMTP_HOST:-smtp.openxchange.eu}"
read -p "SMTP port [587]: " SMTP_PORT
SMTP_PORT="${SMTP_PORT:-587}"
read -p "SMTP user [post@nextwks.eu]: " SMTP_USER
SMTP_USER="${SMTP_USER:-post@nextwks.eu}"
read -sp "SMTP password: " SMTP_PASS
echo ""
if [ -z "$SMTP_PASS" ]; then
echo "[ERROR] SMTP password is required."
exit 1
fi
read -p "IMAP host [imap.openxchange.eu]: " IMAP_HOST
IMAP_HOST="${IMAP_HOST:-imap.openxchange.eu}"
read -p "IMAP port [993]: " IMAP_PORT
IMAP_PORT="${IMAP_PORT:-993}"
echo "" echo ""
echo "========================================" echo "========================================"
echo " Domain: $DOMAIN" echo " Domain: $DOMAIN"
@ -62,6 +85,12 @@ DOMAIN=$DOMAIN
TLS_EMAIL=$TLS_EMAIL TLS_EMAIL=$TLS_EMAIL
ADMIN_USERNAME=$ADMIN_USERNAME ADMIN_USERNAME=$ADMIN_USERNAME
ADMIN_PASSWORD=$ADMIN_PASSWORD ADMIN_PASSWORD=$ADMIN_PASSWORD
SMTP_HOST=$SMTP_HOST
SMTP_PORT=$SMTP_PORT
SMTP_USER=$SMTP_USER
SMTP_PASS=$SMTP_PASS
IMAP_HOST=$IMAP_HOST
IMAP_PORT=$IMAP_PORT
EOF EOF
chmod 600 "$ENV_FILE" chmod 600 "$ENV_FILE"

329
main.go
View file

@ -10,7 +10,9 @@ import (
"net/http/httputil" "net/http/httputil"
"net/url" "net/url"
"os" "os"
"os/exec"
"path/filepath" "path/filepath"
"strconv"
"strings" "strings"
"gopkg.in/yaml.v3" "gopkg.in/yaml.v3"
@ -46,6 +48,20 @@ type AppsFile struct {
Apps []AppEntry `yaml:"apps"` Apps []AppEntry `yaml:"apps"`
} }
type Settings struct {
Company struct {
Name string `yaml:"name"`
Language string `yaml:"language"`
Timezone string `yaml:"timezone"`
} `yaml:"company"`
SMTP struct {
Host string `yaml:"host"`
Port int `yaml:"port"`
User string `yaml:"user"`
Sender string `yaml:"sender"`
} `yaml:"smtp"`
}
// --- Config loading --- // --- Config loading ---
func loadConfig(configDir string) (*Config, error) { func loadConfig(configDir string) (*Config, error) {
@ -77,6 +93,55 @@ func loadApps(configDir string) ([]AppEntry, error) {
return appsFile.Apps, nil return appsFile.Apps, nil
} }
// --- Settings loading ---
func loadSettings(configDir string) (*Settings, error) {
path := filepath.Join(configDir, "settings.yaml")
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return &Settings{}, nil
}
return nil, fmt.Errorf("reading settings: %w", err)
}
var s Settings
if err := yaml.Unmarshal(data, &s); err != nil {
return nil, fmt.Errorf("parsing settings: %w", err)
}
return &s, nil
}
func saveSettings(configDir string, s *Settings) error {
path := filepath.Join(configDir, "settings.yaml")
data, err := yaml.Marshal(s)
if err != nil {
return fmt.Errorf("marshaling settings: %w", err)
}
return os.WriteFile(path, data, 0644)
}
func updateEnvFile(path string, values map[string]string) error {
data, err := os.ReadFile(path)
if err != nil {
return err
}
lines := strings.Split(string(data), "\n")
for k, v := range values {
found := false
for i, line := range lines {
if strings.HasPrefix(line, k+"=") {
lines[i] = k + "=" + v
found = true
break
}
}
if !found {
lines = append(lines, k+"="+v)
}
}
return os.WriteFile(path, []byte(strings.Join(lines, "\n")), 0600)
}
// --- Helpers --- // --- Helpers ---
func userHasAnyGroup(userGroups []string, requiredGroups []string) bool { func userHasAnyGroup(userGroups []string, requiredGroups []string) bool {
@ -238,6 +303,112 @@ func settingsHandler(w http.ResponseWriter, r *http.Request) {
}) })
} }
// --- API proxy for authelia-api ---
func apiProxyHandler(w http.ResponseWriter, r *http.Request) {
target, _ := url.Parse("http://127.0.0.1:8080")
proxy := httputil.NewSingleHostReverseProxy(target)
r.URL.Path = strings.TrimPrefix(r.URL.Path, "/api")
proxy.ServeHTTP(w, r)
}
// --- Global settings handlers ---
func globalSettingsHandler(w http.ResponseWriter, r *http.Request) {
configDir := os.Getenv("CONFIG_DIR")
if configDir == "" {
configDir = "/opt/nextworkspace/config/nextworkspace"
}
settings, err := loadSettings(configDir)
if err != nil {
settings = &Settings{}
}
domain := os.Getenv("DOMAIN")
adminEmail := os.Getenv("TLS_EMAIL")
smtpHost := os.Getenv("SMTP_HOST")
smtpPort := os.Getenv("SMTP_PORT")
// Check Authelia health
autheliaUp := false
if resp, err := http.Get("http://127.0.0.1:9091/api/health"); err == nil {
autheliaUp = resp.StatusCode == 200
resp.Body.Close()
}
tmpl := template.Must(template.New("global").Parse(globalSettingsHTML))
w.Header().Set("Content-Type", "text/html; charset=utf-8")
tmpl.Execute(w, map[string]interface{}{
"CompanyName": settings.Company.Name,
"Language": settings.Company.Language,
"Timezone": settings.Company.Timezone,
"SMTPHost": smtpHost,
"SMTPPort": smtpPort,
"SMTPUser": settings.SMTP.User,
"SMTPSender": settings.SMTP.Sender,
"Domain": domain,
"AdminEmail": adminEmail,
"AutheliaUp": autheliaUp,
"Saved": r.URL.Query().Get("saved") == "ok",
})
}
func globalSettingsSaveHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
configDir := os.Getenv("CONFIG_DIR")
if configDir == "" {
configDir = "/opt/nextworkspace/config/nextworkspace"
}
settings, _ := loadSettings(configDir)
if settings == nil {
settings = &Settings{}
}
settings.Company.Name = r.FormValue("company_name")
settings.Company.Language = r.FormValue("language")
settings.Company.Timezone = r.FormValue("timezone")
settings.SMTP.Host = r.FormValue("smtp_host")
settings.SMTP.Port, _ = strconv.Atoi(r.FormValue("smtp_port"))
settings.SMTP.User = r.FormValue("smtp_user")
settings.SMTP.Sender = r.FormValue("smtp_sender")
saveSettings(configDir, settings)
// Update .env with SMTP values
envPath := os.Getenv("ENV_FILE")
if envPath == "" {
envPath = "/opt/backup/.env"
}
envUpdates := map[string]string{
"SMTP_HOST": r.FormValue("smtp_host"),
"SMTP_PORT": r.FormValue("smtp_port"),
"SMTP_USER": r.FormValue("smtp_user"),
"SMTP_SENDER": r.FormValue("smtp_sender"),
}
if pwd := r.FormValue("smtp_password"); pwd != "" {
envUpdates["SMTP_PASS"] = pwd
}
updateEnvFile(envPath, envUpdates)
http.Redirect(w, r, "/config?s=global&saved=ok", http.StatusFound)
}
func applySettingsHandler(w http.ResponseWriter, r *http.Request) {
cmd := exec.Command("/opt/NextWks/deploy.sh")
output, err := cmd.CombinedOutput()
if err != nil {
http.Error(w, fmt.Sprintf("Apply failed: %v\n%s", err, string(output)), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
fmt.Fprintf(w, `{"success":true,"output":"%s"}`, strings.ReplaceAll(string(output), `"`, `\"`))
}
// --- Templates --- // --- Templates ---
const landingPageHTML = `<!DOCTYPE html> const landingPageHTML = `<!DOCTYPE html>
@ -392,6 +563,140 @@ const settingsHTML = `<!DOCTYPE html>
</body> </body>
</html>` </html>`
const globalSettingsHTML = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Global Settings NextWorkspace</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f0f2f5; color: #1a1a2e; min-height: 100vh; }
.topbar { background: linear-gradient(135deg, #1a1a2e, #16213e); color: #fff; padding: 0.75rem 1.5rem; display: flex; justify-content: space-between; align-items: center; }
.topbar h1 { font-size: 1.1rem; font-weight: 600; }
.topbar a { color: #a0aec0; text-decoration: none; font-size: 0.85rem; }
.topbar a:hover { color: #fff; }
.container { max-width: 720px; margin: 2rem auto; padding: 0 1rem; }
.banner { padding: 0.75rem 1rem; border-radius: 8px; margin-bottom: 1.25rem; font-size: 0.88rem; }
.banner-success { background: #c6f6d5; color: #276749; border: 1px solid #9ae6b4; }
.card { background: #fff; border-radius: 10px; padding: 1.5rem; margin-bottom: 1.25rem; box-shadow: 0 1px 3px rgba(0,0,0,0.05); border: 1px solid #edf2f7; }
.card h2 { font-size: 1rem; font-weight: 600; color: #2d3748; margin-bottom: 1rem; padding-bottom: 0.5rem; border-bottom: 1px solid #edf2f7; }
.field { margin-bottom: 0.9rem; }
.field label { display: block; font-size: 0.82rem; font-weight: 500; color: #4a5568; margin-bottom: 0.25rem; }
.field input, .field select { width: 100%; padding: 0.5rem 0.7rem; border: 1px solid #e2e8f0; border-radius: 6px; font-size: 0.88rem; color: #4a5568; background: #fff; outline: none; }
.field input:focus, .field select:focus { border-color: #63b3ed; box-shadow: 0 0 0 2px rgba(99,179,237,0.15); }
.field .note { color: #a0aec0; font-size: 0.78rem; margin-top: 0.2rem; }
.field-row { display: flex; gap: 1rem; }
.field-row .field { flex: 1; }
.info-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 0.5rem; }
.info-item { padding: 0.5rem 0; border-bottom: 1px solid #f7fafc; }
.info-item .label { font-size: 0.78rem; color: #a0aec0; }
.info-item .value { font-size: 0.9rem; color: #4a5568; }
.status-dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 0.35rem; }
.status-up { background: #48bb78; }
.status-down { background: #fc8181; }
.btn { display: inline-flex; align-items: center; gap: 0.35rem; padding: 0.5rem 1rem; border-radius: 6px; font-size: 0.88rem; font-weight: 500; cursor: pointer; border: none; text-decoration: none; }
.btn-primary { background: #1a1a2e; color: #fff; }
.btn-primary:hover { background: #2d3748; }
.btn-ghost { background: transparent; color: #718096; border: 1px solid #e2e8f0; }
.btn-ghost:hover { background: #f7fafc; }
.actions { display: flex; gap: 0.75rem; margin-top: 1.5rem; }
</style>
</head>
<body>
<div class="topbar">
<h1>NextWorkspace · Global Settings</h1>
<a href="/config">Back to Admin</a>
</div>
<div class="container">
{{if .Saved}}<div class="banner banner-success">Settings saved. SMTP changes require applying click "Apply Settings" below.</div>{{end}}
<form method="POST" action="/config/global/save">
<div class="card">
<h2>Company</h2>
<div class="field">
<label>Company Name</label>
<input name="company_name" value="{{.CompanyName}}" placeholder="NextWorkspace">
</div>
<div class="field-row">
<div class="field">
<label>Default Language</label>
<select name="language">
<option value="en" {{if eq .Language "en"}}selected{{end}}>English</option>
<option value="de" {{if eq .Language "de"}}selected{{end}}>Deutsch</option>
</select>
</div>
<div class="field">
<label>Timezone</label>
<select name="timezone">
<option value="UTC" {{if eq .Timezone "UTC"}}selected{{end}}>UTC</option>
<option value="Europe/London" {{if eq .Timezone "Europe/London"}}selected{{end}}>Europe/London</option>
<option value="Europe/Berlin" {{if eq .Timezone "Europe/Berlin"}}selected{{end}}>Europe/Berlin</option>
<option value="America/New_York" {{if eq .Timezone "America/New_York"}}selected{{end}}>America/New_York</option>
<option value="America/Los_Angeles" {{if eq .Timezone "America/Los_Angeles"}}selected{{end}}>America/Los_Angeles</option>
<option value="Asia/Tokyo" {{if eq .Timezone "Asia/Tokyo"}}selected{{end}}>Asia/Tokyo</option>
</select>
</div>
</div>
</div>
<div class="card">
<h2>SMTP</h2>
<div class="field">
<label>SMTP Host</label>
<input name="smtp_host" value="{{.SMTPHost}}" placeholder="smtp.openxchange.eu">
</div>
<div class="field-row">
<div class="field">
<label>SMTP Port</label>
<input name="smtp_port" value="{{.SMTPPort}}" placeholder="587">
</div>
<div class="field">
<label>SMTP User</label>
<input name="smtp_user" value="{{.SMTPUser}}" placeholder="post@nextwks.eu">
</div>
</div>
<div class="field-row">
<div class="field">
<label>SMTP Password</label>
<input name="smtp_password" type="password" placeholder="Leave blank to keep current">
<div class="note">Leave blank unless changing the password.</div>
</div>
<div class="field">
<label>Sender Email</label>
<input name="smtp_sender" value="{{.SMTPSender}}" placeholder="post@nextwks.eu">
</div>
</div>
</div>
<div class="card">
<h2>System (read-only)</h2>
<div class="info-grid">
<div class="info-item"><div class="label">Domain</div><div class="value">{{.Domain}}</div></div>
<div class="info-item"><div class="label">Admin Email</div><div class="value">{{.AdminEmail}}</div></div>
<div class="info-item">
<div class="label">Authelia</div>
<div class="value">{{if .AutheliaUp}}<span class="status-dot status-up"></span>Running{{else}}<span class="status-dot status-down"></span>Unreachable{{end}}</div>
</div>
<div class="info-item"><div class="label">Version</div><div class="value">0.1.0.0011</div></div>
</div>
</div>
<div class="actions">
<button type="submit" class="btn btn-primary">Save Settings</button>
<button type="button" class="btn btn-ghost" onclick="applySettings()">Apply Settings</button>
</div>
</form>
</div>
<script>
async function applySettings() {
const btn = event.target; btn.textContent = 'Applying...'; btn.disabled = true;
const resp = await fetch('/config/apply', {method:'POST'});
const result = await resp.json();
if (result.success) { alert('Settings applied. Containers restarted.'); }
else { alert('Error: ' + JSON.stringify(result)); }
btn.textContent = 'Apply Settings'; btn.disabled = false;
}
</script>
</body>
</html>`
const adminHTML = `<!DOCTYPE html> const adminHTML = `<!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
@ -558,7 +863,7 @@ const adminHTML = `<!DOCTYPE html>
} }
async function loadUsers() { async function loadUsers() {
const resp = await fetch('/config?format=json'); const resp = await fetch('/api/users');
if (!resp.ok) return; if (!resp.ok) return;
const users = await resp.json(); const users = await resp.json();
const tbody = document.getElementById('usersTable'); const tbody = document.getElementById('usersTable');
@ -582,7 +887,7 @@ const adminHTML = `<!DOCTYPE html>
const data = Object.fromEntries(new FormData(form)); const data = Object.fromEntries(new FormData(form));
data.groups = Array.from(form.querySelector('[name=groups]').selectedOptions).map(o => o.value); data.groups = Array.from(form.querySelector('[name=groups]').selectedOptions).map(o => o.value);
if (!data.groups.length) data.groups = ['users']; if (!data.groups.length) data.groups = ['users'];
const resp = await fetch('/config', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({users:[data]})}); const resp = await fetch('/api/users/bulk', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({users:[data]})});
const result = await resp.json(); const result = await resp.json();
const div = document.getElementById('createResult'); const div = document.getElementById('createResult');
if (result.success) { if (result.success) {
@ -597,7 +902,7 @@ const adminHTML = `<!DOCTYPE html>
async function deleteUser(username) { async function deleteUser(username) {
if (!confirm('Delete user "' + username + '"?')) return; if (!confirm('Delete user "' + username + '"?')) return;
const resp = await fetch('/config?username=' + username, {method:'DELETE'}); const resp = await fetch('/api/users/' + username, {method:'DELETE'});
if (resp.ok) loadUsers(); if (resp.ok) loadUsers();
else alert('Delete failed: ' + await resp.text()); else alert('Delete failed: ' + await resp.text());
} }
@ -626,6 +931,12 @@ func main() {
log.Fatalf("Failed to load apps: %v", err) log.Fatalf("Failed to load apps: %v", err)
} }
settings, _ := loadSettings(configDir)
companyName := "NextWorkspace"
if settings != nil && settings.Company.Name != "" {
companyName = settings.Company.Name
}
addr := fmt.Sprintf("%s:%d", cfg.Server.Host, cfg.Server.Port) addr := fmt.Sprintf("%s:%d", cfg.Server.Host, cfg.Server.Port)
mux := http.NewServeMux() mux := http.NewServeMux()
@ -633,7 +944,10 @@ func main() {
// Public // Public
mux.HandleFunc("/health", healthHandler) mux.HandleFunc("/health", healthHandler)
// Protected: launcher // Protectected: launcher
if companyName != "" {
cfg.App.Name = companyName
}
mux.Handle("/home/", authMiddleware(launcherHandler(cfg, apps))) mux.Handle("/home/", authMiddleware(launcherHandler(cfg, apps)))
mux.Handle("/home", authMiddleware(launcherHandler(cfg, apps))) mux.Handle("/home", authMiddleware(launcherHandler(cfg, apps)))
@ -645,6 +959,13 @@ func main() {
mux.Handle("/config", adminGroupMiddleware(authMiddleware(adminHandler))) mux.Handle("/config", adminGroupMiddleware(authMiddleware(adminHandler)))
mux.Handle("/config/", adminGroupMiddleware(authMiddleware(adminHandler))) mux.Handle("/config/", adminGroupMiddleware(authMiddleware(adminHandler)))
// Global settings save — admins only
mux.HandleFunc("/config/global/save", adminGroupMiddleware(authMiddleware(globalSettingsSaveHandler)))
mux.HandleFunc("/config/apply", adminGroupMiddleware(authMiddleware(applySettingsHandler)))
// API proxy — authelia-api (authenticated users only)
mux.Handle("/api/", authMiddleware(apiProxyHandler))
// Protected: upstream app proxies // Protected: upstream app proxies
for _, app := range apps { for _, app := range apps {
if app.Path != "" && app.Upstream != "" && app.Path != "/config" && app.Path != "/home" { if app.Path != "" && app.Upstream != "" && app.Path != "/config" && app.Path != "/home" {