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}