package main import ( "context" "crypto/hmac" "crypto/sha256" "database/sql" _ "embed" "encoding/base64" "encoding/json" "fmt" "html/template" texttemplate "text/template" "log" "net/http" "os" "path/filepath" "strings" _ "github.com/lib/pq" "google.golang.org/api/idtoken" ) //go:embed schema.sql var schema string var ( htmlTemplates *template.Template jsTemplates *texttemplate.Template ) func main() { for _, key := range []string{"PGCONN", "CLIENT_ID", "CLIENT_SECRET"} { if os.Getenv(key) == "" { log.Fatalf("%s environment variable is required", key) } } db, err := sql.Open("postgres", os.Getenv("PGCONN")) if err != nil { log.Fatalf("failed to open database: %v", err) } defer db.Close() if err := db.Ping(); err != nil { log.Fatalf("failed to connect to database: %v", err) } log.Println("connected to database") if _, err := db.Exec(schema); err != nil { log.Fatalf("failed to apply schema: %v", err) } htmlTemplates = template.Must(template.New("").ParseGlob("static/*.html")) jsTemplates = texttemplate.Must(texttemplate.New("").ParseGlob("static/*.js")) http.HandleFunc("/", handleStatic) http.HandleFunc("POST /auth/google/callback", handleGoogleCallback) http.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) { if err := db.Ping(); err != nil { http.Error(w, "db unhealthy", http.StatusServiceUnavailable) return } fmt.Fprintln(w, "ok") }) log.Println("listening on :8080") log.Fatal(http.ListenAndServe(":8080", nil)) } func templateData() map[string]any { return map[string]any{ "env": envMap(), } } func envMap() map[string]string { m := map[string]string{} for _, e := range os.Environ() { if parts := strings.SplitN(e, "=", 2); len(parts) == 2 { m[parts[0]] = parts[1] } } return m } func handleStatic(w http.ResponseWriter, r *http.Request) { w.Header().Set("Cache-Control", "no-cache") path := r.URL.Path if path == "/" { path = "/index.html" } name := strings.TrimPrefix(path, "/") if strings.HasSuffix(name, ".html") { t := htmlTemplates.Lookup(name) if t == nil { http.NotFound(w, r) return } w.Header().Set("Content-Type", "text/html") t.Execute(w, templateData()) return } if strings.HasSuffix(name, ".js") { t := jsTemplates.Lookup(name) if t == nil { http.NotFound(w, r) return } w.Header().Set("Content-Type", "application/javascript") t.Execute(w, templateData()) return } http.ServeFile(w, r, filepath.Join("static", name)) } func handleGoogleCallback(w http.ResponseWriter, r *http.Request) { credential := r.FormValue("credential") if credential == "" { http.Error(w, "missing credential", http.StatusBadRequest) return } payload, err := idtoken.Validate(context.Background(), credential, os.Getenv("CLIENT_ID")) if err != nil { log.Println("failed to validate token:", err) http.Error(w, "invalid token", http.StatusUnauthorized) return } email := payload.Claims["email"].(string) profile := map[string]any{ "email": email, "name": payload.Claims["name"], "picture": payload.Claims["picture"], "token": signEmail(email), } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(profile) } func signEmail(email string) string { h := hmac.New(sha256.New, []byte(os.Getenv("CLIENT_SECRET"))) h.Write([]byte(email)) sig := base64.RawURLEncoding.EncodeToString(h.Sum(nil)) return base64.RawURLEncoding.EncodeToString([]byte(email)) + "." + sig } func authorize(r *http.Request) (string, bool) { token := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ") parts := strings.SplitN(token, ".", 2) if len(parts) != 2 { return "", false } emailBytes, err := base64.RawURLEncoding.DecodeString(parts[0]) if err != nil { return "", false } email := string(emailBytes) if signEmail(email) != token { return "", false } return email, true }