package main import ( "database/sql" "embed" "fmt" "html/template" "log/slog" "net/http" "atlas9.dev/c/core" "atlas9.dev/c/core/dbi" "atlas9.dev/c/core/iam" "atlas9.dev/c/core/routes" ) //go:embed templates/*.html var templateFiles embed.FS type OAuthLink struct { Name string URL string } type Pages struct { DB *sql.DB Users dbi.Factory[iam.UserStore] Passwords dbi.Factory[iam.PasswordStore] Profiles dbi.Factory[*ProfileStore] Sessions iam.SessionStore OAuthLinks []OAuthLink loginTmpl *template.Template registerTmpl *template.Template profileTmpl *template.Template } func NewPages(db *sql.DB, users dbi.Factory[iam.UserStore], passwords dbi.Factory[iam.PasswordStore], profiles dbi.Factory[*ProfileStore], sessions iam.SessionStore, oauthLinks []OAuthLink) *Pages { layout := template.Must(template.ParseFS(templateFiles, "templates/layout.html")) parse := func(name string) *template.Template { return template.Must(template.Must(layout.Clone()).ParseFS(templateFiles, "templates/"+name)) } return &Pages{ DB: db, Users: users, Passwords: passwords, Profiles: profiles, Sessions: sessions, OAuthLinks: oauthLinks, loginTmpl: parse("login.html"), registerTmpl: parse("register.html"), profileTmpl: parse("profile.html"), } } func PageRoutes(p *Pages) []routes.Route { return []routes.Route{ routes.HTTP("GET /{$}", p.handleIndex), routes.HTTP("GET /login", p.handleLoginPage), routes.HTTP("POST /login", p.handleLogin), routes.HTTP("GET /register", p.handleRegisterPage), routes.HTTP("POST /register", p.handleRegister), routes.HTTP("GET /profile", p.handleProfile), routes.HTTP("POST /profile", p.handleProfileUpdate), routes.HTTP("POST /logout", p.handleLogout), } } type loginData struct { Title string Error string Success string OAuthLinks []OAuthLink } type registerData struct { Title string Error string } type profileData struct { Title string Email string Name string Bio string Location string Website string PictureURL string Success string Error string } func (p *Pages) render(w http.ResponseWriter, tmpl *template.Template, data any) { w.Header().Set("Content-Type", "text/html; charset=utf-8") if err := tmpl.ExecuteTemplate(w, "layout", data); err != nil { http.Error(w, "Internal Server Error", http.StatusInternalServerError) slog.Error("template render error", "err", err) } } func (p *Pages) handleIndex(w http.ResponseWriter, r *http.Request) { id, _ := p.Sessions.Get(r.Context()) if id.IsZero() { http.Redirect(w, r, "/login", http.StatusSeeOther) return } http.Redirect(w, r, "/profile", http.StatusSeeOther) } func (p *Pages) handleLoginPage(w http.ResponseWriter, r *http.Request) { data := loginData{Title: "Login", OAuthLinks: p.OAuthLinks} if r.URL.Query().Get("registered") == "1" { data.Success = "Registration successful! Please log in." } p.render(w, p.loginTmpl, data) } func (p *Pages) handleLogin(w http.ResponseWriter, r *http.Request) { email := r.FormValue("email") password := r.FormValue("password") ctx := r.Context() if email == "" || password == "" { p.render(w, p.loginTmpl, loginData{Title: "Login", Error: "Email and password are required", OAuthLinks: p.OAuthLinks}) return } userID, err := dbi.ReadOnly(ctx, p.DB, func(tx dbi.DBI) (core.ID, error) { users := p.Users(tx) passwords := p.Passwords(tx) user, err := users.GetByEmail(ctx, email) if err != nil { return core.ID{}, fmt.Errorf("invalid credentials") } if err := iam.CheckPassword(ctx, passwords, user.ID, password); err != nil { return core.ID{}, fmt.Errorf("invalid credentials") } return user.ID, nil }) if err != nil { p.render(w, p.loginTmpl, loginData{Title: "Login", Error: "Invalid email or password", OAuthLinks: p.OAuthLinks}) return } if err := p.Sessions.Put(ctx, userID); err != nil { p.render(w, p.loginTmpl, loginData{Title: "Login", Error: "Something went wrong", OAuthLinks: p.OAuthLinks}) return } http.Redirect(w, r, "/profile", http.StatusSeeOther) } func (p *Pages) handleRegisterPage(w http.ResponseWriter, r *http.Request) { p.render(w, p.registerTmpl, registerData{Title: "Register"}) } func (p *Pages) handleRegister(w http.ResponseWriter, r *http.Request) { email := r.FormValue("email") password := r.FormValue("password") ctx := r.Context() if email == "" || password == "" { p.render(w, p.registerTmpl, registerData{Title: "Register", Error: "Email and password are required"}) return } _, err := dbi.ReadWrite(ctx, p.DB, func(tx dbi.DBI) (core.ID, error) { users := p.Users(tx) passwords := p.Passwords(tx) user := &iam.User{Email: email} created, err := users.Save(ctx, user) if err != nil { return core.ID{}, fmt.Errorf("saving user: %w", err) } if !created { return core.ID{}, fmt.Errorf("user already exists") } if err := iam.SetPassword(ctx, passwords, user.ID, password); err != nil { return core.ID{}, fmt.Errorf("setting password: %w", err) } return user.ID, nil }) if err != nil { msg := "Something went wrong" if err.Error() == "user already exists" { msg = "An account with that email already exists" } p.render(w, p.registerTmpl, registerData{Title: "Register", Error: msg}) return } http.Redirect(w, r, "/login?registered=1", http.StatusSeeOther) } func (p *Pages) handleProfile(w http.ResponseWriter, r *http.Request) { ctx := r.Context() userID, _ := p.Sessions.Get(ctx) if userID.IsZero() { http.Redirect(w, r, "/login", http.StatusSeeOther) return } type result struct { user *iam.User profile *Profile } res, err := dbi.ReadOnly(ctx, p.DB, func(tx dbi.DBI) (result, error) { user, err := p.Users(tx).Get(ctx, userID) if err != nil { return result{}, err } profile, err := p.Profiles(tx).Get(ctx, userID) if err == core.ErrNotFound { profile = &Profile{UserID: userID} } else if err != nil { return result{}, err } return result{user, profile}, nil }) if err != nil { http.Redirect(w, r, "/login", http.StatusSeeOther) return } data := profileData{ Title: "Profile", Email: res.user.Email, Name: res.profile.Name, Bio: res.profile.Bio, Location: res.profile.Location, Website: res.profile.Website, PictureURL: res.profile.PictureURL, } if r.URL.Query().Get("saved") == "1" { data.Success = "Profile saved." } p.render(w, p.profileTmpl, data) } func (p *Pages) handleProfileUpdate(w http.ResponseWriter, r *http.Request) { ctx := r.Context() userID, _ := p.Sessions.Get(ctx) if userID.IsZero() { http.Redirect(w, r, "/login", http.StatusSeeOther) return } profile := &Profile{ UserID: userID, Name: r.FormValue("name"), Bio: r.FormValue("bio"), Location: r.FormValue("location"), Website: r.FormValue("website"), PictureURL: r.FormValue("picture_url"), } _, err := dbi.ReadWrite(ctx, p.DB, func(tx dbi.DBI) (struct{}, error) { return struct{}{}, p.Profiles(tx).Save(ctx, profile) }) if err != nil { slog.Error("saving profile", "err", err) p.render(w, p.profileTmpl, profileData{ Title: "Profile", Error: "Something went wrong.", Name: profile.Name, Bio: profile.Bio, Location: profile.Location, Website: profile.Website, PictureURL: profile.PictureURL, }) return } http.Redirect(w, r, "/profile?saved=1", http.StatusSeeOther) } func (p *Pages) handleLogout(w http.ResponseWriter, r *http.Request) { p.Sessions.Destroy(r.Context()) http.Redirect(w, r, "/login", http.StatusSeeOther) }