Global Settings
General system configuration for your workspace.
package main import ( "bytes" "fmt" "html/template" "io" "log" "net/http" "net/http/httputil" "net/url" "os" "os/exec" "path/filepath" "strconv" "strings" "gopkg.in/yaml.v3" ) // --- Config types --- type ServerConfig struct { Port int `yaml:"port"` Host string `yaml:"host"` } type AppConfig struct { Name string `yaml:"name"` Description string `yaml:"description"` } type Config struct { Server ServerConfig `yaml:"server"` App AppConfig `yaml:"app"` } type AppEntry struct { Name string `yaml:"name"` Subtitle string `yaml:"subtitle"` Path string `yaml:"path"` Upstream string `yaml:"upstream"` Icon string `yaml:"icon"` Groups []string `yaml:"groups"` } type AppsFile struct { Apps []AppEntry `yaml:"apps"` } type Settings struct { Company struct { Name string `yaml:"name"` Subtitle string `yaml:"subtitle"` Logo string `yaml:"logo"` 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) { path := filepath.Join(configDir, "config.yaml") data, err := os.ReadFile(path) if err != nil { return nil, fmt.Errorf("reading config: %w", err) } var cfg Config if err := yaml.Unmarshal(data, &cfg); err != nil { return nil, fmt.Errorf("parsing config: %w", err) } return &cfg, nil } func loadApps(configDir string) ([]AppEntry, error) { path := filepath.Join(configDir, "apps.yaml") data, err := os.ReadFile(path) if err != nil { if os.IsNotExist(err) { return []AppEntry{}, nil } return nil, fmt.Errorf("reading apps: %w", err) } var appsFile AppsFile if err := yaml.Unmarshal(data, &appsFile); err != nil { return nil, fmt.Errorf("parsing apps: %w", err) } 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 { for _, ug := range userGroups { for _, rg := range requiredGroups { if strings.TrimSpace(ug) == rg { return true } } } return false } // --- Auth middleware (trusts Remote-User from Caddy forward auth) --- func authMiddleware(next http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { user := r.Header.Get("Remote-User") if user == "" { http.Error(w, "Unauthorized", http.StatusUnauthorized) return } r.Header.Set("X-Auth-User", user) next(w, r) } } func adminGroupMiddleware(next http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { groups := r.Header.Get("Remote-Groups") if !strings.Contains(groups, "admins") { http.Error(w, "Forbidden — admins only", http.StatusForbidden) return } next(w, r) } } // --- Handlers --- func healthHandler(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) fmt.Fprint(w, "OK") } func launcherHandler(cfg *Config, apps []AppEntry) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { user := r.Header.Get("Remote-User") groupsHeader := r.Header.Get("Remote-Groups") userGroups := strings.Split(groupsHeader, ",") settings, _ := loadSettings(os.Getenv("CONFIG_DIR")) lang := getUserLang(r) var allowedApps []AppEntry for _, app := range apps { if len(app.Groups) == 0 || userHasAnyGroup(userGroups, app.Groups) { allowedApps = append(allowedApps, app) } } data := struct { AppName string Description string User string IsAdmin bool Apps []AppEntry CompanyLogo string Subtitle string Lang string }{ AppName: cfg.App.Name, Description: cfg.App.Description, User: user, IsAdmin: strings.Contains(groupsHeader, "admins"), Apps: allowedApps, CompanyLogo: settings.Company.Logo, Subtitle: settings.Company.Subtitle, Lang: lang, } w.Header().Set("Content-Type", "text/html; charset=utf-8") launcherTmpl.Execute(w, data) } } func proxyToUpstream(upstream string) http.HandlerFunc { target, err := url.Parse(upstream) if err != nil { log.Fatalf("Invalid upstream URL %q: %v", upstream, err) } proxy := httputil.NewSingleHostReverseProxy(target) return func(w http.ResponseWriter, r *http.Request) { r.Header.Set("Remote-User", r.Header.Get("Remote-User")) proxy.ServeHTTP(w, r) } } func adminHandler(w http.ResponseWriter, r *http.Request) { apiBase := "http://127.0.0.1:8080" apiToken := os.Getenv("AUTHELIA_SECRET") configDir := os.Getenv("CONFIG_DIR") if configDir == "" { configDir = "/opt/nextworkspace/config/nextworkspace" } // Load settings for Global tab settings, _ := loadSettings(configDir) domain := os.Getenv("DOMAIN") adminEmail := os.Getenv("TLS_EMAIL") switch r.Method { case http.MethodGet: // If settings JSON format requested if r.URL.Query().Get("format") == "settings" { w.Header().Set("Content-Type", "application/json") jsonData := fmt.Sprintf(`{"company_name":"%s","language":"%s","timezone":"%s","smtp_host":"%s","smtp_port":"%d","smtp_user":"%s","smtp_sender":"%s"}`, settings.Company.Name, settings.Company.Language, settings.Company.Timezone, settings.SMTP.Host, settings.SMTP.Port, settings.SMTP.User, settings.SMTP.Sender) w.Write([]byte(jsonData)) return } // If JSON format requested, return raw API response if r.URL.Query().Get("format") == "json" { req, _ := http.NewRequest("GET", apiBase+"/api/users", nil) req.Header.Set("Authorization", "Bearer "+apiToken) resp, err := http.DefaultClient.Do(req) if err != nil { http.Error(w, fmt.Sprintf("API error: %v", err), http.StatusBadGateway) return } defer resp.Body.Close() body, _ := io.ReadAll(resp.Body) w.Header().Set("Content-Type", "application/json") w.Write(body) return } w.Header().Set("Content-Type", "text/html; charset=utf-8") // Check service statuses autheliaUp := false if resp, err := http.Get("http://127.0.0.1:9091/api/health"); err == nil { autheliaUp = resp.StatusCode == 200 resp.Body.Close() } caddyUp := false if resp, err := http.Get("http://127.0.0.1:80/"); err == nil { caddyUp = true resp.Body.Close() } launcherUp := false if resp, err := http.Get("http://127.0.0.1:9000/health"); err == nil { launcherUp = resp.StatusCode == 200 resp.Body.Close() } userLang := os.Getenv("LANG") if userLang == "" { userLang = "en" } if settings != nil && settings.Company.Language != "" { userLang = settings.Company.Language } adminTmpl.Execute(w, map[string]interface{}{ "Settings": settings, "Domain": domain, "AdminEmail": adminEmail, "AutheliaUp": autheliaUp, "CaddyUp": caddyUp, "LauncherUp": launcherUp, "Lang": userLang, }) case http.MethodPost: // Create user body, _ := io.ReadAll(r.Body) req, _ := http.NewRequest("POST", apiBase+"/api/users/bulk", bytes.NewReader(body)) req.Header.Set("Authorization", "Bearer "+apiToken) req.Header.Set("Content-Type", "application/json") resp, err := http.DefaultClient.Do(req) if err != nil { http.Error(w, fmt.Sprintf("API error: %v", err), http.StatusBadGateway) return } w.WriteHeader(resp.StatusCode) io.Copy(w, resp.Body) case http.MethodDelete: username := r.URL.Query().Get("username") req, _ := http.NewRequest("DELETE", apiBase+"/api/users/"+username, nil) req.Header.Set("Authorization", "Bearer "+apiToken) resp, err := http.DefaultClient.Do(req) if err != nil { http.Error(w, fmt.Sprintf("API error: %v", err), http.StatusBadGateway) return } w.WriteHeader(resp.StatusCode) io.Copy(w, resp.Body) } } func settingsHandler(w http.ResponseWriter, r *http.Request) { user := r.Header.Get("Remote-User") groups := r.Header.Get("Remote-Groups") isAdmin := strings.Contains(groups, "admins") tmpl := template.Must(template.New("settings").Parse(settingsHTML)) w.Header().Set("Content-Type", "text/html; charset=utf-8") tmpl.Execute(w, map[string]interface{}{ "User": user, "IsAdmin": isAdmin, }) } // --- 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.Subtitle = r.FormValue("company_subtitle") settings.Company.Logo = r.FormValue("company_logo") 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), `"`, `\"`)) } // --- Public settings API --- func publicSettingsHandler(w http.ResponseWriter, r *http.Request) { configDir := os.Getenv("CONFIG_DIR") if configDir == "" { configDir = "/opt/nextworkspace/config/nextworkspace" } settings, _ := loadSettings(configDir) w.Header().Set("Content-Type", "application/json") fmt.Fprintf(w, `{"name":"%s","subtitle":"%s","logo":"%s"}`, settings.Company.Name, settings.Company.Subtitle, settings.Company.Logo) } // --- Translation system --- var translations = make(map[string]map[string]string) var launcherTmpl *template.Template var adminTmpl *template.Template func loadTranslations(lngDir string) { langs := []string{"en", "de"} files := []string{"launcher.yaml", "admin.yaml"} for _, lang := range langs { if translations[lang] == nil { translations[lang] = make(map[string]string) } for _, file := range files { path := filepath.Join(lngDir, lang, file) data, err := os.ReadFile(path) if err != nil { continue } var keys map[string]string if err := yaml.Unmarshal(data, &keys); err != nil { continue } for k, v := range keys { translations[lang][k] = v } } } } func t(lang, key string, args ...string) string { text := translations[lang][key] if text == "" { text = translations["en"][key] } if text == "" { return key } for i := 0; i < len(args); i += 2 { text = strings.ReplaceAll(text, "{"+args[i]+"}", args[i+1]) } return text } func getUserLang(r *http.Request) string { lang := r.Header.Get("Accept-Language") if strings.HasPrefix(lang, "de") { return "de" } settings, _ := loadSettings(os.Getenv("CONFIG_DIR")) if settings != nil && settings.Company.Language == "de" { return "de" } return "en" } // --- Templates --- const landingPageHTML = `
Your Self-Hosted Workspace for Startups
NextWorkspace was developed with assistance from AI coding tools, using DeepSeek as the provider and OpenCode as the development framework.
{{.Subtitle}}
{{end}}{{.Subtitle}}
{{if .Upstream}}{{else}}Logged in as {{.User}}
General system configuration for your workspace.