package main import ( "bytes" "encoding/json" "fmt" "html/template" "io" "log" "net" "net/http" "net/http/httputil" "net/url" "os" "os/exec" "path/filepath" "strconv" "strings" "time" "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"` TopUser string `yaml:"top_user"` 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"` IMAP struct { Host string `yaml:"host"` Port int `yaml:"port"` } `yaml:"imap"` } // --- User settings --- type UserSettings struct { FirstName string `json:"first_name,omitempty"` LastName string `json:"last_name,omitempty"` ProfilePicture string `json:"profile_picture,omitempty"` Email string `json:"email,omitempty"` EmailPassword string `json:"email_password,omitempty"` Language string `json:"language,omitempty"` Timezone string `json:"timezone,omitempty"` Phone string `json:"phone,omitempty"` Address string `json:"address,omitempty"` Messenger string `json:"messenger,omitempty"` Bio string `json:"bio,omitempty"` Manager string `json:"manager,omitempty"` JobTitle string `json:"job_title,omitempty"` Dept string `json:"dept,omitempty"` } func loadUserSettings(username string) *UserSettings { path := filepath.Join("/opt/nextworkspace/data/users", username+".json") data, err := os.ReadFile(path) if err != nil { return &UserSettings{} } var s UserSettings json.Unmarshal(data, &s) return &s } func saveUserSettings(username string, s *UserSettings) error { path := filepath.Join("/opt/nextworkspace/data/users", username+".json") os.MkdirAll(filepath.Dir(path), 0700) data, _ := json.MarshalIndent(s, "", " ") return os.WriteFile(path, data, 0600) } // --- 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")) us := loadUserSettings(user) lang := us.Language if lang == "" && settings != nil { lang = settings.Company.Language } if lang == "" { lang = "en" } // Use display name if available displayName := user if us.FirstName != "" { displayName = us.FirstName if us.LastName != "" { displayName += " " + us.LastName } } 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 DisplayName string IsAdmin bool Apps []AppEntry CompanyLogo string Subtitle string Lang string }{ AppName: cfg.App.Name, Description: cfg.App.Description, User: user, DisplayName: displayName, 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://authelia: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","imap_host":"%s","imap_port":"%d"}`, settings.Company.Name, settings.Company.Language, settings.Company.Timezone, settings.SMTP.Host, settings.SMTP.Port, settings.SMTP.User, settings.SMTP.Sender, settings.IMAP.Host, settings.IMAP.Port) 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://authelia:9091/api/health"); err == nil { autheliaUp = resp.StatusCode == 200 resp.Body.Close() } caddyUp := false // Check port 80 responds (don't follow HTTPS redirect) if conn, err := net.DialTimeout("tcp", "caddy:80", 3*time.Second); err == nil { caddyUp = true conn.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 } // Fetch all users for TopUser dropdown allUsers, _ := fetchAllUsers() type UserOption struct { Username string DisplayName string } var allUserOptions []UserOption for _, u := range allUsers { profile := loadUserSettings(u.Username) name := u.DisplayName if profile.FirstName != "" { name = profile.FirstName + " " + profile.LastName } allUserOptions = append(allUserOptions, UserOption{Username: u.Username, DisplayName: name}) } adminTmpl.Execute(w, map[string]interface{}{ "Settings": settings, "Domain": domain, "AdminEmail": adminEmail, "AllUsers": allUserOptions, "AutheliaUp": autheliaUp, "CaddyUp": caddyUp, "LauncherUp": launcherUp, "Lang": userLang, "Version": appVersion, }) 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") us := loadUserSettings(user) settings, _ := loadSettings(os.Getenv("CONFIG_DIR")) lang := us.Language if lang == "" && settings != nil { lang = settings.Company.Language } if lang == "" { lang = "en" } // Fetch all users for manager dropdown allUsers, _ := fetchAllUsers() type UserOption struct { Username string DisplayName string } var allUserOptions []UserOption for _, u := range allUsers { profile := loadUserSettings(u.Username) name := u.DisplayName if profile.FirstName != "" { name = profile.FirstName + " " + profile.LastName } allUserOptions = append(allUserOptions, UserOption{Username: u.Username, DisplayName: name}) } tmpl := template.Must(template.New("settings").Funcs(funcMap).Parse(settingsHTML)) w.Header().Set("Content-Type", "text/html; charset=utf-8") tmpl.Execute(w, map[string]interface{}{ "User": user, "IsAdmin": isAdmin, "UserSettings": us, "AllUsers": allUserOptions, "Settings": settings, "Lang": lang, }) } func userSettingsSaveHandler(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } user := r.Header.Get("Remote-User") if user == "" { http.Error(w, "Unauthorized", http.StatusUnauthorized) return } us := &UserSettings{ FirstName: r.FormValue("first_name"), LastName: r.FormValue("last_name"), ProfilePicture: r.FormValue("profile_picture"), Email: r.FormValue("email"), EmailPassword: r.FormValue("email_password"), Language: r.FormValue("language"), Timezone: r.FormValue("timezone"), Phone: r.FormValue("phone"), Address: r.FormValue("address"), Messenger: r.FormValue("messenger"), Bio: r.FormValue("bio"), Manager: r.FormValue("manager"), JobTitle: r.FormValue("job_title"), Dept: r.FormValue("dept"), } if err := saveUserSettings(user, us); err != nil { http.Error(w, "Failed to save", http.StatusInternalServerError) return } w.Write([]byte(`{"status":"ok"}`)) } // --- People directory --- type apiUser struct { Username string `json:"username"` DisplayName string `json:"display_name"` } func fetchAllUsers() ([]apiUser, error) { apiToken := os.Getenv("AUTHELIA_SECRET") req, _ := http.NewRequest("GET", "http://authelia:8080/api/users", nil) req.Header.Set("Authorization", "Bearer "+apiToken) resp, err := http.DefaultClient.Do(req) if err != nil { return nil, err } defer resp.Body.Close() var users []apiUser json.NewDecoder(resp.Body).Decode(&users) return users, nil } func peopleDirectoryHandler(w http.ResponseWriter, r *http.Request) { // If path is /people/{username}, show profile username := strings.TrimPrefix(r.URL.Path, "/people/") if username != "" && username != "/" && !strings.Contains(username, "/") { peopleProfileHandler(w, r, username) return } users, err := fetchAllUsers() if err != nil { http.Error(w, "Failed to fetch users", http.StatusBadGateway) return } type Person struct { Username string DisplayName string JobTitle string Dept string ProfilePic string } var people []Person for _, u := range users { us := loadUserSettings(u.Username) displayName := u.DisplayName if us.FirstName != "" { displayName = us.FirstName + " " + us.LastName } people = append(people, Person{ Username: u.Username, DisplayName: displayName, JobTitle: us.JobTitle, Dept: us.Dept, ProfilePic: us.ProfilePicture, }) } tmpl := template.Must(template.New("people").Funcs(template.FuncMap{"t": t, "initial": initials}).Parse(peopleHTML)) w.Header().Set("Content-Type", "text/html; charset=utf-8") tmpl.Execute(w, map[string]interface{}{"People": people, "Lang": "en"}) } func peopleProfileHandler(w http.ResponseWriter, r *http.Request, username string) { us := loadUserSettings(username) var managerName string if us.Manager != "" { mgr := loadUserSettings(us.Manager) if mgr.FirstName != "" { managerName = mgr.FirstName + " " + mgr.LastName } else { managerName = us.Manager } } tmpl := template.Must(template.New("profile").Funcs(template.FuncMap{"t": t, "initial": initials}).Parse(profileHTML)) w.Header().Set("Content-Type", "text/html; charset=utf-8") tmpl.Execute(w, map[string]interface{}{ "Username": username, "Profile": us, "ManagerName": managerName, "Lang": "en", }) } func initials(s string) string { parts := strings.Fields(s) if len(parts) == 0 { return "?" } var result string for _, p := range parts { if len(p) > 0 { result += strings.ToUpper(string(p[0])) } } if len(result) > 2 { result = result[:2] } return result } // --- API proxy for authelia-api --- func apiProxyHandler(w http.ResponseWriter, r *http.Request) { target, _ := url.Parse("http://authelia:8080") proxy := httputil.NewSingleHostReverseProxy(target) // Inject Bearer token for authelia-api auth token := os.Getenv("AUTHELIA_SECRET") if token != "" { r.Header.Set("Authorization", "Bearer "+token) } // Forward the full path (authelia-api expects /api/... prefix) 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://authelia: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.TopUser = r.FormValue("top_user") 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") settings.IMAP.Host = r.FormValue("imap_host") settings.IMAP.Port, _ = strconv.Atoi(r.FormValue("imap_port")) 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) { // Find nextwks.sh in common locations scriptPaths := []string{ "/home/master/nextwks.sh", "/root/nextwks.sh", "/opt/backup/nextwks.sh", } script := "" for _, p := range scriptPaths { if _, err := os.Stat(p); err == nil { script = p break } } if script == "" { http.Error(w, `{"success":false,"error":"nextwks.sh not found"}`, http.StatusInternalServerError) return } cmd := exec.Command("bash", script, "--update") 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 funcMap template.FuncMap ) var adminTmpl *template.Template var appVersion = "dev" 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 = ` NextWorkspace

NextWorkspace

Your Self-Hosted Workspace for Startups

nextwks.eu

Components

Built with AI

NextWorkspace was developed with assistance from AI coding tools, using DeepSeek as the provider and OpenCode as the development framework.

` const launcherHTML = ` {{.AppName}}
{{if .CompanyLogo}}{{end}}

{{.AppName}}

{{if .Subtitle}}

{{.Subtitle}}

{{end}}
{{t .Lang "welcome" "user" .DisplayName}} People {{t .Lang "settings"}} {{t .Lang "logout"}}
{{range .Apps}} {{if .Upstream}} {{else}}
{{end}}
{{.Icon}}

{{.Name}}

{{.Subtitle}}

{{if .Upstream}}
{{else}}
{{end}} {{end}}
` const settingsHTML = ` {{t .Lang "settings"}} — NextWorkspace

{{t .Lang "settings"}}

{{t .Lang "launcher_title"}}

Profile

Profile Details

Mail

Server: {{.Settings.IMAP.Host}}:{{.Settings.IMAP.Port}}

Preferences

{{t .Lang "saved"}}
{{if .IsAdmin}}

Administration

Manage users, groups, and workspace configuration.

⚖ Admin Panel →
{{end}}
` const peopleHTML = ` People — NextWorkspace

People

Back to Launcher
{{range .People}} {{if .ProfilePic}}{{else}}
{{initial .DisplayName}}
{{end}}
{{.DisplayName}}
{{if .JobTitle}}
{{.JobTitle}}
{{end}} {{if .Dept}}
{{.Dept}}
{{end}}
{{end}}
` const profileHTML = ` Profile — NextWorkspace

Profile

Back to People
← All People
{{if .Profile.ProfilePicture}}{{else}}
{{initial .Profile.FirstName}} {{initial .Profile.LastName}}
{{end}}

{{.Profile.FirstName}} {{.Profile.LastName}}

{{if .Profile.JobTitle}}
{{.Profile.JobTitle}}
{{end}} {{if .Profile.Dept}}
{{.Profile.Dept}}
{{end}}
{{if .Profile.Email}}{{end}} {{if .Profile.Phone}}{{end}} {{if .Profile.Address}}
Address
{{.Profile.Address}}
{{end}} {{if .Profile.Messenger}}
Messenger
{{.Profile.Messenger}}
{{end}} {{if .ManagerName}}{{end}} {{if .Profile.Bio}}
Bio
{{.Profile.Bio}}
{{end}}
` 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 = ` Admin Panel — NextWorkspace

NextWorkspace · Admin v{{.Version}}

Launcher Logout
{{if .ApiError}}
Could not connect to authelia-api on :8080
{{end}}

Company

SMTP

IMAP (Mail Client)

System (read-only)

Domain
{{.Domain}}
Admin Email
{{.AdminEmail}}
Caddy
{{if .CaddyUp}}Running{{else}}Down{{end}}
Authelia
{{if .AutheliaUp}}Running{{else}}Down{{end}}
Launcher
{{if .LauncherUp}}Running{{else}}Down{{end}}
` // --- Main --- func main() { configDir := os.Getenv("CONFIG_DIR") if configDir == "" { configDir = "/opt/nextworkspace/config/nextworkspace" } cfg, err := loadConfig(configDir) if err != nil { log.Fatalf("Failed to load config: %v", err) } apps, err := loadApps(configDir) if err != nil { log.Fatalf("Failed to load apps: %v", err) } settings, _ := loadSettings(configDir) companyName := "NextWorkspace" if settings != nil && settings.Company.Name != "" { companyName = settings.Company.Name } // Load version versionBytes, _ := os.ReadFile("/opt/nextworkspace/VERSION") v := strings.TrimSpace(string(versionBytes)) if v != "" { appVersion = v } // Load translations from lng/ directory loadTranslations("/opt/nextworkspace/lng") // Create template FuncMap for translations funcMap = template.FuncMap{"t": t} // Parse templates with FuncMap launcherTmpl = template.Must(template.New("launcher").Funcs(funcMap).Parse(launcherHTML)) adminTmpl = template.Must(template.New("admin").Funcs(funcMap).Parse(adminHTML)) addr := fmt.Sprintf("%s:%d", cfg.Server.Host, cfg.Server.Port) mux := http.NewServeMux() // Public mux.HandleFunc("/health", healthHandler) mux.HandleFunc("/api/settings/public", publicSettingsHandler) // Protectected: launcher if companyName != "" { cfg.App.Name = companyName } mux.Handle("/home/", authMiddleware(launcherHandler(cfg, apps))) mux.Handle("/home", authMiddleware(launcherHandler(cfg, apps))) // Protected: settings mux.Handle("/settings", authMiddleware(settingsHandler)) mux.Handle("/settings/save", authMiddleware(http.HandlerFunc(userSettingsSaveHandler))) // People directory mux.Handle("/people", authMiddleware(http.HandlerFunc(peopleDirectoryHandler))) mux.Handle("/people/", authMiddleware(http.HandlerFunc(peopleDirectoryHandler))) // Protected: admin — admins only 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" { proxyHandler := authMiddleware(proxyToUpstream(app.Upstream)) mux.Handle(app.Path+"/", proxyHandler) mux.Handle(app.Path, proxyHandler) } } // Default: www landing page or redirect to /home/ domain := os.Getenv("DOMAIN") mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { if strings.HasPrefix(r.Host, "www.") || (domain != "" && r.Host == "www."+domain) { w.Header().Set("Content-Type", "text/html; charset=utf-8") fmt.Fprint(w, landingPageHTML) return } http.Redirect(w, r, "/home/", http.StatusFound) }) log.Printf("NextWorkspace listening on %s", addr) log.Fatal(http.ListenAndServe(addr, mux)) }