diff --git a/config/authelia/configuration.yml b/config/authelia/configuration.yml index e4ae3f2..1d2d32f 100644 --- a/config/authelia/configuration.yml +++ b/config/authelia/configuration.yml @@ -119,5 +119,14 @@ storage: path: /data/db.sqlite notifier: - filesystem: - filename: /config/notification.yml + smtp: + 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 diff --git a/deploy.sh b/deploy.sh index 17b2aae..c333681 100755 --- a/deploy.sh +++ b/deploy.sh @@ -96,6 +96,10 @@ if [ "$GREENFIELD" = true ]; then sed -e "s|{DOMAIN}|$DOMAIN|g" \ -e "s|{JWT_SECRET}|$JWT_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" # Generate users database diff --git a/install.sh b/install.sh index 654dea8..4eb8507 100755 --- a/install.sh +++ b/install.sh @@ -43,6 +43,29 @@ done # 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) +# --- 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 " Domain: $DOMAIN" @@ -62,6 +85,12 @@ DOMAIN=$DOMAIN TLS_EMAIL=$TLS_EMAIL ADMIN_USERNAME=$ADMIN_USERNAME 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 chmod 600 "$ENV_FILE" diff --git a/main.go b/main.go index 44527e3..11c4bdd 100644 --- a/main.go +++ b/main.go @@ -10,7 +10,9 @@ import ( "net/http/httputil" "net/url" "os" + "os/exec" "path/filepath" + "strconv" "strings" "gopkg.in/yaml.v3" @@ -46,6 +48,20 @@ type AppsFile struct { 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 --- func loadConfig(configDir string) (*Config, error) { @@ -77,6 +93,55 @@ func loadApps(configDir string) ([]AppEntry, error) { 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 --- 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 --- const landingPageHTML = ` @@ -392,6 +563,140 @@ const settingsHTML = ` ` +const globalSettingsHTML = ` + + + + + Global Settings — NextWorkspace + + + +
+

NextWorkspace · Global Settings

+ Back to Admin +
+
+ {{if .Saved}}{{end}} +
+
+

Company

+
+ + +
+
+
+ + +
+
+ + +
+
+
+
+

SMTP

+
+ + +
+
+
+ + +
+
+ + +
+
+
+
+ + +
Leave blank unless changing the password.
+
+
+ + +
+
+
+
+

System (read-only)

+
+
Domain
{{.Domain}}
+
Admin Email
{{.AdminEmail}}
+
+
Authelia
+
{{if .AutheliaUp}}Running{{else}}Unreachable{{end}}
+
+
Version
0.1.0.0011
+
+
+
+ + +
+
+
+ + +` + const adminHTML = ` @@ -558,7 +863,7 @@ const adminHTML = ` } async function loadUsers() { - const resp = await fetch('/config?format=json'); + const resp = await fetch('/api/users'); if (!resp.ok) return; const users = await resp.json(); const tbody = document.getElementById('usersTable'); @@ -582,7 +887,7 @@ const adminHTML = ` const data = Object.fromEntries(new FormData(form)); data.groups = Array.from(form.querySelector('[name=groups]').selectedOptions).map(o => o.value); 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 div = document.getElementById('createResult'); if (result.success) { @@ -597,7 +902,7 @@ const adminHTML = ` async function deleteUser(username) { 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(); else alert('Delete failed: ' + await resp.text()); } @@ -626,6 +931,12 @@ func main() { 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) mux := http.NewServeMux() @@ -633,7 +944,10 @@ func main() { // Public 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))) @@ -645,6 +959,13 @@ func main() { 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 for _, app := range apps { if app.Path != "" && app.Upstream != "" && app.Path != "/config" && app.Path != "/home" {