package main import ( "bytes" "fmt" "html/template" "io" "log" "net/http" "net/http/httputil" "net/url" "os" "path/filepath" "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"` } // --- 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 } // --- 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, ",") 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 }{ AppName: cfg.App.Name, Description: cfg.App.Description, User: user, IsAdmin: strings.Contains(groupsHeader, "admins"), Apps: allowedApps, } 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") switch r.Method { case http.MethodGet: // List users from authelia-api 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) // If JSON format requested, return raw API response if r.URL.Query().Get("format") == "json" { w.Header().Set("Content-Type", "application/json") w.Write(body) return } tmpl := template.Must(template.New("admin").Parse(adminHTML)) w.Header().Set("Content-Type", "text/html; charset=utf-8") tmpl.Execute(w, map[string]interface{}{ "ApiError": resp.StatusCode != 200, }) 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, }) } // --- 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}}

{{.AppName}}

{{.Description}}

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 adminHTML = ` Admin Panel — NextWorkspace

Admin Panel

Back to Launcher
{{if .ApiError}}
Could not fetch users from authelia-api (is it running on :8080?)
{{end}}

Create User

Users

UsernameDisplay NameEmailGroupsAction
` // --- 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) } addr := fmt.Sprintf("%s:%d", cfg.Server.Host, cfg.Server.Port) mux := http.NewServeMux() // Public mux.HandleFunc("/health", healthHandler) // Protected: launcher 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))) // 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)) }