server.go

  1package srv
  2
  3import (
  4	"database/sql"
  5	"fmt"
  6	"html/template"
  7	"log/slog"
  8	"net"
  9	"net/http"
 10	"net/url"
 11	"path/filepath"
 12	"runtime"
 13	"sort"
 14	"strings"
 15	"time"
 16
 17	"srv.exe.dev/db"
 18	"srv.exe.dev/db/dbgen"
 19)
 20
 21type Server struct {
 22	DB           *sql.DB
 23	Hostname     string
 24	TemplatesDir string
 25	StaticDir    string
 26}
 27
 28type pageData struct {
 29	Hostname   string
 30	Now        string
 31	UserEmail  string
 32	VisitCount int64
 33	LoginURL   string
 34	LogoutURL  string
 35	Headers    []headerEntry
 36}
 37
 38type headerEntry struct {
 39	Name       string
 40	Values     []string
 41	AddedByExe bool
 42}
 43
 44func New(dbPath, hostname string) (*Server, error) {
 45	_, thisFile, _, _ := runtime.Caller(0)
 46	baseDir := filepath.Dir(thisFile)
 47	srv := &Server{
 48		Hostname:     hostname,
 49		TemplatesDir: filepath.Join(baseDir, "templates"),
 50		StaticDir:    filepath.Join(baseDir, "static"),
 51	}
 52	if err := srv.setUpDatabase(dbPath); err != nil {
 53		return nil, err
 54	}
 55	return srv, nil
 56}
 57
 58func (s *Server) HandleRoot(w http.ResponseWriter, r *http.Request) {
 59	// Identity from proxy headers (if present)
 60	// UserID is stable; email is useful.
 61	userID := strings.TrimSpace(r.Header.Get("X-ExeDev-UserID"))
 62	userEmail := strings.TrimSpace(r.Header.Get("X-ExeDev-Email"))
 63	now := time.Now()
 64
 65	var count int64
 66	if userID != "" && s.DB != nil {
 67		q := dbgen.New(s.DB)
 68		shouldRecordView := r.Method == http.MethodGet
 69		if shouldRecordView {
 70			// Best effort
 71			err := q.UpsertVisitor(r.Context(), dbgen.UpsertVisitorParams{
 72				ID:        userID,
 73				CreatedAt: now,
 74				LastSeen:  now,
 75			})
 76			if err != nil {
 77				slog.Warn("upsert visitor", "error", err, "user_id", userID)
 78			}
 79		}
 80		if v, err := q.VisitorWithID(r.Context(), userID); err == nil {
 81			count = v.ViewCount
 82		}
 83	}
 84
 85	data := pageData{
 86		Hostname:   s.Hostname,
 87		Now:        now.Format(time.RFC3339),
 88		UserEmail:  userEmail,
 89		VisitCount: count,
 90		LoginURL:   loginURLForRequest(r),
 91		LogoutURL:  "/__exe.dev/logout",
 92		Headers:    buildHeaderEntries(r),
 93	}
 94
 95	w.Header().Set("Content-Type", "text/html; charset=utf-8")
 96	if err := s.renderTemplate(w, "welcome.html", data); err != nil {
 97		slog.Warn("render template", "url", r.URL.Path, "error", err)
 98	}
 99}
100
101func loginURLForRequest(r *http.Request) string {
102	path := r.URL.RequestURI()
103	v := url.Values{}
104	v.Set("redirect", path)
105	return "/__exe.dev/login?" + v.Encode()
106}
107
108func (s *Server) renderTemplate(w http.ResponseWriter, name string, data any) error {
109	path := filepath.Join(s.TemplatesDir, name)
110	tmpl, err := template.ParseFiles(path)
111	if err != nil {
112		return fmt.Errorf("parse template %q: %w", name, err)
113	}
114	if err := tmpl.Execute(w, data); err != nil {
115		return fmt.Errorf("execute template %q: %w", name, err)
116	}
117	return nil
118}
119
120func mainDomainFromHost(h string) string {
121	host, port, err := net.SplitHostPort(h)
122	if err != nil {
123		host = strings.TrimSpace(h)
124	}
125	if port != "" {
126		port = ":" + port
127	}
128	// Check for exe.cloud-based domains (dev mode)
129	if strings.HasSuffix(host, ".exe.cloud") || host == "exe.cloud" {
130		return "exe.cloud" + port
131	}
132	// Check for exe.dev-based domains (production)
133	if strings.HasSuffix(host, ".exe.dev") || host == "exe.dev" {
134		return "exe.dev"
135	}
136	// Return as-is for custom domains
137	return host
138}
139
140// SetupDatabase initializes the database connection and runs migrations
141func (s *Server) setUpDatabase(dbPath string) error {
142	wdb, err := db.Open(dbPath)
143	if err != nil {
144		return fmt.Errorf("failed to open db: %w", err)
145	}
146	s.DB = wdb
147	if err := db.RunMigrations(wdb); err != nil {
148		return fmt.Errorf("failed to run migrations: %w", err)
149	}
150	return nil
151}
152
153// Serve starts the HTTP server with the configured routes
154func (s *Server) Serve(addr string) error {
155	mux := http.NewServeMux()
156	mux.HandleFunc("GET /{$}", s.HandleRoot)
157	mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir(s.StaticDir))))
158	slog.Info("starting server", "addr", addr)
159	return http.ListenAndServe(addr, mux)
160}
161
162func buildHeaderEntries(r *http.Request) []headerEntry {
163	if r == nil {
164		return nil
165	}
166
167	headers := make([]headerEntry, 0, len(r.Header)+1)
168	for name, values := range r.Header {
169		lower := strings.ToLower(name)
170		headers = append(headers, headerEntry{
171			Name:       name,
172			Values:     values,
173			AddedByExe: strings.HasPrefix(lower, "x-exedev-") || strings.HasPrefix(lower, "x-forwarded-"),
174		})
175	}
176	if r.Host != "" {
177		headers = append(headers, headerEntry{
178			Name:   "Host",
179			Values: []string{r.Host},
180		})
181	}
182
183	sort.Slice(headers, func(i, j int) bool {
184		return strings.ToLower(headers[i].Name) < strings.ToLower(headers[j].Name)
185	})
186	return headers
187}