package main import ( "fmt" "html/template" "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"` } 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 } // --- 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) } } // --- 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") data := struct { AppName string Description string User string Apps []AppEntry }{ AppName: cfg.App.Name, Description: cfg.App.Description, User: user, Apps: apps, } 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) } } // --- Templates --- const landingPageHTML = ` NextWorkspace — Your Self-Hosted Workspace for Startups

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}} · Logout
{{range .Apps}} {{if .Upstream}} {{else}}
{{end}}
{{.Icon}}

{{.Name}}

{{.Subtitle}}

{{if .Upstream}}
{{else}}
{{end}} {{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) } addr := fmt.Sprintf("%s:%d", cfg.Server.Host, cfg.Server.Port) mux := http.NewServeMux() // Public paths mux.HandleFunc("/health", healthHandler) // Protected: launcher mux.Handle("/home/", authMiddleware(launcherHandler(cfg, apps))) mux.Handle("/home", authMiddleware(launcherHandler(cfg, apps))) // Protected: upstream app proxies for _, app := range apps { if app.Path != "" && app.Upstream != "" { proxyHandler := authMiddleware(proxyToUpstream(app.Upstream)) mux.Handle(app.Path+"/", proxyHandler) mux.Handle(app.Path, proxyHandler) } } // Default: serve landing page for www, redirect to launcher otherwise 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)) }