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 { tmpl := template.Must(template.New("launcher").Parse(launcherHTML)) 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")) 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 }{ 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, } w.Header().Set("Content-Type", "text/html; charset=utf-8") tmpl.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 } funcMap := template.FuncMap{"t": func(key string) string { return T(settings.Company.Language, key) }} tmpl := template.Must(template.New("admin").Funcs(funcMap).Parse(adminHTML)) w.Header().Set("Content-Type", "text/html; charset=utf-8") 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.Execute(w, map[string]interface{}{ "Settings": settings, "Domain": domain, "AdminEmail": adminEmail, "AutheliaUp": autheliaUp, "T": func(key string) string { return T(settings.Company.Language, key) }, }) 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 --- type Translations map[string]string var translationCache map[string]Translations func loadTranslations(lang string) Translations { if translationCache != nil { if cached, ok := translationCache[lang]; ok { return cached } } basePath := filepath.Join("/opt/nextworkspace", "lng", lang) result := Translations{} files := []string{"launcher.yaml", "admin.yaml"} for _, f := range files { data, err := os.ReadFile(filepath.Join(basePath, f)) if err != nil { continue } var m map[string]string yaml.Unmarshal(data, &m) for k, v := range m { result[k] = v } } if translationCache == nil { translationCache = make(map[string]Translations) } translationCache[lang] = result return result } func T(lang, key string, args ...string) string { t := loadTranslations(lang) text, ok := t[key] if !ok { en := loadTranslations("en") text = en[key] if text == "" { return key } } for i := 0; i < len(args); i += 2 { if i+1 < len(args) { text = strings.ReplaceAll(text, "{"+args[i]+"}", args[i+1]) } } return text } func getUserLang(r *http.Request) string { headerLang := r.Header.Get("Remote-Lang") if headerLang == "de" || headerLang == "en" { return headerLang } 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}}
Welcome, {{.User}} Settings Logout
{{range .Apps}} {{if .Upstream}} {{else}}
{{end}}
{{.Icon}}

{{.Name}}

{{.Subtitle}}

{{if .Upstream}}
{{else}}
{{end}} {{end}}
` const settingsHTML = ` Settings — NextWorkspace

Settings

Back to Launcher

Account

Logged in as {{.User}}

{{if .IsAdmin}}

Administration

Manage users, groups, and workspace configuration.

⚖ Admin Panel →
{{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

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

Company

SMTP

System (read-only)

Domain
{{.Domain}}
Admin Email
{{.AdminEmail}}
Authelia
{{if .AutheliaUp}}Running{{else}}Unreachable{{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 } 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/", authMiddleware(settingsHandler)) // 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)) }