586 lines
19 KiB
Go
586 lines
19 KiB
Go
package customerauth
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"net/mail"
|
|
"net/url"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"git.k6n.net/mats/go-cart-actor/pkg/actor"
|
|
"git.k6n.net/mats/go-cart-actor/pkg/profile"
|
|
messages "git.k6n.net/mats/go-cart-actor/proto/profile"
|
|
"google.golang.org/protobuf/proto"
|
|
)
|
|
|
|
// minPasswordLen is the minimum accepted password length at registration and
|
|
// password reset.
|
|
const minPasswordLen = 8
|
|
|
|
// Token lifetimes for the email-verification and password-reset links.
|
|
const (
|
|
verifyTokenTTL = 24 * time.Hour
|
|
resetTokenTTL = 1 * time.Hour
|
|
)
|
|
|
|
// Storefront paths (appended to Options.BaseURL) that the verification and reset
|
|
// links point at. The page reads the token from the query string and POSTs it
|
|
// back to /verify or /reset.
|
|
const (
|
|
verifyLinkPath = "/account/verify"
|
|
resetLinkPath = "/account/reset"
|
|
)
|
|
|
|
// ProfileApplier is the subset of the profile grain pool the auth server needs.
|
|
// The pool (and the UCP adapter's ProfileApplier) already satisfies it.
|
|
type ProfileApplier interface {
|
|
Get(ctx context.Context, id uint64) (*profile.ProfileGrain, error)
|
|
Apply(ctx context.Context, id uint64, mutation ...proto.Message) (*actor.MutationResult[profile.ProfileGrain], error)
|
|
}
|
|
|
|
// Server exposes password signup/login, email verification, password reset and
|
|
// identity-linking over HTTP, backed by the credential store (email→id+hash) and
|
|
// the profile grain pool.
|
|
type Server struct {
|
|
store Credentials
|
|
applier ProfileApplier
|
|
signer *Signer
|
|
ttl time.Duration
|
|
limiter Limiter
|
|
notifier Notifier
|
|
baseURL string
|
|
requireVerified bool
|
|
}
|
|
|
|
// Options configures optional auth-server behavior. The zero value is valid: an
|
|
// in-memory login limiter (5 failures / 15m), a logging notifier, and no
|
|
// verified-email requirement for login.
|
|
type Options struct {
|
|
// Limiter throttles failed logins and reset requests. Nil installs a default
|
|
// in-memory limiter (5 failures / 15m). Inject a RedisLoginLimiter for a
|
|
// horizontally-scaled deployment.
|
|
Limiter Limiter
|
|
// Notifier delivers verification and reset links. Nil installs LogNotifier.
|
|
Notifier Notifier
|
|
// BaseURL is the public origin used to build links in messages, e.g.
|
|
// "https://shop.tornberg.me". The verify/reset paths are appended to it.
|
|
BaseURL string
|
|
// RequireVerifiedEmail, when true, blocks login until the email is verified.
|
|
// Off by default so pre-existing accounts (which carry no verification
|
|
// record) keep working.
|
|
RequireVerifiedEmail bool
|
|
}
|
|
|
|
// NewServer builds an auth server. ttl<=0 falls back to DefaultSessionTTL.
|
|
func NewServer(store Credentials, applier ProfileApplier, signer *Signer, ttl time.Duration, opts Options) *Server {
|
|
if ttl <= 0 {
|
|
ttl = DefaultSessionTTL
|
|
}
|
|
if opts.Limiter == nil {
|
|
opts.Limiter = NewLoginLimiter(0, 0)
|
|
}
|
|
if opts.Notifier == nil {
|
|
opts.Notifier = LogNotifier{}
|
|
}
|
|
return &Server{
|
|
store: store,
|
|
applier: applier,
|
|
signer: signer,
|
|
ttl: ttl,
|
|
limiter: opts.Limiter,
|
|
notifier: opts.Notifier,
|
|
baseURL: strings.TrimRight(opts.BaseURL, "/"),
|
|
requireVerified: opts.RequireVerifiedEmail,
|
|
}
|
|
}
|
|
|
|
// Handler returns the router for the auth endpoints. Mount it under /ucp/v1/auth
|
|
// (the prefix is stripped by the caller).
|
|
func (s *Server) Handler() http.Handler {
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("POST /register", s.handleRegister)
|
|
mux.HandleFunc("POST /login", s.handleLogin)
|
|
mux.HandleFunc("POST /logout", s.handleLogout)
|
|
mux.HandleFunc("GET /me", s.handleMe)
|
|
mux.HandleFunc("POST /verify-request", s.handleVerifyRequest)
|
|
mux.HandleFunc("POST /verify", s.handleVerify)
|
|
mux.HandleFunc("POST /reset-request", s.handleResetRequest)
|
|
mux.HandleFunc("POST /reset", s.handleResetComplete)
|
|
mux.HandleFunc("POST /link-cart", s.handleLinkCart)
|
|
mux.HandleFunc("POST /link-checkout", s.handleLinkCheckout)
|
|
mux.HandleFunc("POST /link-order", s.handleLinkOrder)
|
|
return mux
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Request/response payloads
|
|
// ---------------------------------------------------------------------------
|
|
|
|
type registerRequest struct {
|
|
Email string `json:"email"`
|
|
Password string `json:"password"`
|
|
Name string `json:"name,omitempty"`
|
|
Phone string `json:"phone,omitempty"`
|
|
}
|
|
|
|
type loginRequest struct {
|
|
Email string `json:"email"`
|
|
Password string `json:"password"`
|
|
}
|
|
|
|
type linkCartRequest struct {
|
|
CartID string `json:"cartId"`
|
|
}
|
|
|
|
type linkCheckoutRequest struct {
|
|
CheckoutID string `json:"checkoutId"`
|
|
CartID string `json:"cartId"`
|
|
}
|
|
|
|
type linkOrderRequest struct {
|
|
OrderReference string `json:"orderReference"`
|
|
CartID string `json:"cartId"`
|
|
Status string `json:"status,omitempty"`
|
|
}
|
|
|
|
// tokenRequest carries a verification token.
|
|
type tokenRequest struct {
|
|
Token string `json:"token"`
|
|
}
|
|
|
|
// resetRequest asks for a password-reset link to be sent to an email.
|
|
type resetRequest struct {
|
|
Email string `json:"email"`
|
|
}
|
|
|
|
// resetCompleteRequest carries a reset token and the new password.
|
|
type resetCompleteRequest struct {
|
|
Token string `json:"token"`
|
|
Password string `json:"password"`
|
|
}
|
|
|
|
// CustomerResponse mirrors the UCP customer projection (kept local to avoid an
|
|
// import cycle with internal/ucp). The password hash is never included.
|
|
type CustomerResponse struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name,omitempty"`
|
|
Email string `json:"email,omitempty"`
|
|
Phone string `json:"phone,omitempty"`
|
|
Language string `json:"language,omitempty"`
|
|
Currency string `json:"currency,omitempty"`
|
|
AvatarURL string `json:"avatarUrl,omitempty"`
|
|
Addresses []AddressResponse `json:"addresses"`
|
|
Orders []OrderRef `json:"orders"`
|
|
|
|
// EmailVerified reflects whether the email-verification flow has completed.
|
|
EmailVerified bool `json:"emailVerified"`
|
|
}
|
|
|
|
type AddressResponse struct {
|
|
ID uint32 `json:"id"`
|
|
Label string `json:"label,omitempty"`
|
|
FullName string `json:"fullName,omitempty"`
|
|
AddressLine1 string `json:"addressLine1"`
|
|
AddressLine2 string `json:"addressLine2,omitempty"`
|
|
City string `json:"city"`
|
|
Zip string `json:"zip"`
|
|
Country string `json:"country"`
|
|
IsDefaultShipping bool `json:"isDefaultShipping,omitempty"`
|
|
IsDefaultBilling bool `json:"isDefaultBilling,omitempty"`
|
|
}
|
|
|
|
type OrderRef struct {
|
|
OrderReference string `json:"orderReference"`
|
|
Status string `json:"status,omitempty"`
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Handlers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) {
|
|
var req registerRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeErr(w, http.StatusBadRequest, "invalid request body")
|
|
return
|
|
}
|
|
email := NormalizeEmail(req.Email)
|
|
if !validEmail(email) {
|
|
writeErr(w, http.StatusBadRequest, "a valid email is required")
|
|
return
|
|
}
|
|
if len(req.Password) < minPasswordLen {
|
|
writeErr(w, http.StatusBadRequest, "password must be at least 8 characters")
|
|
return
|
|
}
|
|
if _, exists := s.store.Get(r.Context(), email); exists {
|
|
writeErr(w, http.StatusConflict, "an account with this email already exists")
|
|
return
|
|
}
|
|
|
|
hash, err := HashPassword(req.Password)
|
|
if err != nil {
|
|
writeErr(w, http.StatusInternalServerError, "could not hash password")
|
|
return
|
|
}
|
|
|
|
rawID, err := profile.NewProfileId()
|
|
if err != nil {
|
|
writeErr(w, http.StatusInternalServerError, "could not generate id")
|
|
return
|
|
}
|
|
id := uint64(rawID)
|
|
|
|
set := &messages.SetProfile{Email: &email}
|
|
if req.Name != "" {
|
|
set.Name = &req.Name
|
|
}
|
|
if req.Phone != "" {
|
|
set.Phone = &req.Phone
|
|
}
|
|
if _, err := s.applier.Apply(r.Context(), id, set); err != nil {
|
|
writeErr(w, http.StatusInternalServerError, "could not create profile")
|
|
return
|
|
}
|
|
|
|
// Persist the credential only after the profile is created. If this fails
|
|
// the profile exists but is unreachable by login — acceptable and rare.
|
|
if err := s.store.Register(r.Context(), email, id, hash, time.Now().UTC().Format(time.RFC3339)); err != nil {
|
|
if err == ErrEmailExists {
|
|
writeErr(w, http.StatusConflict, "an account with this email already exists")
|
|
return
|
|
}
|
|
writeErr(w, http.StatusInternalServerError, "could not store credentials")
|
|
return
|
|
}
|
|
|
|
// Send the verification link (best-effort, via the configured notifier).
|
|
s.sendVerification(email)
|
|
|
|
s.issueSession(w, r, id)
|
|
s.writeCustomer(w, r, http.StatusCreated, rawID.String(), id)
|
|
}
|
|
|
|
func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
|
|
var req loginRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeErr(w, http.StatusBadRequest, "invalid request body")
|
|
return
|
|
}
|
|
key := NormalizeEmail(req.Email)
|
|
if ok, retry := s.limiter.Allowed(r.Context(), key); !ok {
|
|
w.Header().Set("Retry-After", strconv.Itoa(int(retry.Seconds())+1))
|
|
writeErr(w, http.StatusTooManyRequests, "too many attempts, try again later")
|
|
return
|
|
}
|
|
rec, ok := s.store.Get(r.Context(), req.Email)
|
|
// Always run a verify (even on miss, against the stored hash if present) and
|
|
// return a single generic error so the response does not reveal whether the
|
|
// email exists.
|
|
if !ok || !VerifyPassword(req.Password, rec.Hash) {
|
|
s.limiter.RecordFailure(r.Context(), key)
|
|
writeErr(w, http.StatusUnauthorized, "invalid email or password")
|
|
return
|
|
}
|
|
if s.requireVerified && rec.VerifiedAt == "" {
|
|
writeErr(w, http.StatusForbidden, "email not verified")
|
|
return
|
|
}
|
|
s.limiter.Reset(r.Context(), key)
|
|
s.issueSession(w, r, rec.ProfileID)
|
|
s.writeCustomer(w, r, http.StatusOK, profile.ProfileId(rec.ProfileID).String(), rec.ProfileID)
|
|
}
|
|
|
|
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
|
|
ClearSession(w, r)
|
|
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
|
}
|
|
|
|
func (s *Server) handleMe(w http.ResponseWriter, r *http.Request) {
|
|
id, ok := s.requireSession(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
s.writeCustomer(w, r, http.StatusOK, profile.ProfileId(id).String(), id)
|
|
}
|
|
|
|
// handleVerifyRequest re-sends the email-verification link for the logged-in
|
|
// customer. Requires a session so it can't be used to enumerate addresses.
|
|
func (s *Server) handleVerifyRequest(w http.ResponseWriter, r *http.Request) {
|
|
id, ok := s.requireSession(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
g, err := s.applier.Get(r.Context(), id)
|
|
if err != nil || g.Email == "" {
|
|
writeErr(w, http.StatusInternalServerError, "could not read profile")
|
|
return
|
|
}
|
|
s.sendVerification(NormalizeEmail(g.Email))
|
|
writeJSON(w, http.StatusOK, map[string]string{"status": "sent"})
|
|
}
|
|
|
|
// handleVerify consumes a verification token and marks the email verified. An
|
|
// unknown subject still returns success so the endpoint reveals nothing.
|
|
func (s *Server) handleVerify(w http.ResponseWriter, r *http.Request) {
|
|
var req tokenRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeErr(w, http.StatusBadRequest, "invalid request body")
|
|
return
|
|
}
|
|
email, err := s.signer.ParsePurpose(purposeVerifyEmail, req.Token)
|
|
if err != nil {
|
|
writeErr(w, http.StatusBadRequest, "invalid or expired token")
|
|
return
|
|
}
|
|
if _, err := s.store.MarkVerified(r.Context(), email, time.Now().UTC().Format(time.RFC3339)); err != nil {
|
|
writeErr(w, http.StatusInternalServerError, "could not record verification")
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]string{"status": "verified"})
|
|
}
|
|
|
|
// handleResetRequest issues a password-reset link for a registered email. It
|
|
// always returns 200 (whether or not the email exists) so it never reveals
|
|
// account existence, and is rate-limited per email to prevent mail-bombing.
|
|
func (s *Server) handleResetRequest(w http.ResponseWriter, r *http.Request) {
|
|
var req resetRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeErr(w, http.StatusBadRequest, "invalid request body")
|
|
return
|
|
}
|
|
email := NormalizeEmail(req.Email)
|
|
limiterKey := "reset:" + email
|
|
if ok, retry := s.limiter.Allowed(r.Context(), limiterKey); !ok {
|
|
w.Header().Set("Retry-After", strconv.Itoa(int(retry.Seconds())+1))
|
|
writeErr(w, http.StatusTooManyRequests, "too many requests, try again later")
|
|
return
|
|
}
|
|
if _, exists := s.store.Get(r.Context(), email); exists {
|
|
token := s.signer.IssuePurpose(purposePasswordReset, email, resetTokenTTL)
|
|
s.notifier.SendPasswordReset(email, s.link(resetLinkPath, token))
|
|
}
|
|
s.limiter.RecordFailure(r.Context(), limiterKey)
|
|
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
|
}
|
|
|
|
// handleResetComplete consumes a reset token and replaces the password hash.
|
|
func (s *Server) handleResetComplete(w http.ResponseWriter, r *http.Request) {
|
|
var req resetCompleteRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeErr(w, http.StatusBadRequest, "invalid request body")
|
|
return
|
|
}
|
|
if len(req.Password) < minPasswordLen {
|
|
writeErr(w, http.StatusBadRequest, "password must be at least 8 characters")
|
|
return
|
|
}
|
|
email, err := s.signer.ParsePurpose(purposePasswordReset, req.Token)
|
|
if err != nil {
|
|
writeErr(w, http.StatusBadRequest, "invalid or expired token")
|
|
return
|
|
}
|
|
hash, err := HashPassword(req.Password)
|
|
if err != nil {
|
|
writeErr(w, http.StatusInternalServerError, "could not hash password")
|
|
return
|
|
}
|
|
found, err := s.store.UpdateHash(r.Context(), email, hash)
|
|
if err != nil {
|
|
writeErr(w, http.StatusInternalServerError, "could not update password")
|
|
return
|
|
}
|
|
if !found {
|
|
// Token signed for an email no longer present.
|
|
writeErr(w, http.StatusBadRequest, "invalid or expired token")
|
|
return
|
|
}
|
|
s.limiter.Reset(r.Context(), NormalizeEmail(email))
|
|
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
|
}
|
|
|
|
func (s *Server) handleLinkCart(w http.ResponseWriter, r *http.Request) {
|
|
id, ok := s.requireSession(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
var req linkCartRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeErr(w, http.StatusBadRequest, "invalid request body")
|
|
return
|
|
}
|
|
cartID, ok := parseBase62(req.CartID)
|
|
if !ok {
|
|
writeErr(w, http.StatusBadRequest, "invalid cartId")
|
|
return
|
|
}
|
|
if _, err := s.applier.Apply(r.Context(), id, &messages.LinkCart{CartId: cartID, Label: "current cart"}); err != nil {
|
|
writeErr(w, http.StatusInternalServerError, "could not link cart")
|
|
return
|
|
}
|
|
s.writeCustomer(w, r, http.StatusOK, profile.ProfileId(id).String(), id)
|
|
}
|
|
|
|
func (s *Server) handleLinkCheckout(w http.ResponseWriter, r *http.Request) {
|
|
id, ok := s.requireSession(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
var req linkCheckoutRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeErr(w, http.StatusBadRequest, "invalid request body")
|
|
return
|
|
}
|
|
checkoutID, ok := parseBase62(req.CheckoutID)
|
|
if !ok {
|
|
writeErr(w, http.StatusBadRequest, "invalid checkoutId")
|
|
return
|
|
}
|
|
cartID, _ := parseBase62(req.CartID)
|
|
if _, err := s.applier.Apply(r.Context(), id, &messages.LinkCheckout{CheckoutId: checkoutID, CartId: cartID}); err != nil {
|
|
writeErr(w, http.StatusInternalServerError, "could not link checkout")
|
|
return
|
|
}
|
|
s.writeCustomer(w, r, http.StatusOK, profile.ProfileId(id).String(), id)
|
|
}
|
|
|
|
func (s *Server) handleLinkOrder(w http.ResponseWriter, r *http.Request) {
|
|
id, ok := s.requireSession(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
var req linkOrderRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeErr(w, http.StatusBadRequest, "invalid request body")
|
|
return
|
|
}
|
|
if strings.TrimSpace(req.OrderReference) == "" {
|
|
writeErr(w, http.StatusBadRequest, "orderReference is required")
|
|
return
|
|
}
|
|
cartID, _ := parseBase62(req.CartID)
|
|
if _, err := s.applier.Apply(r.Context(), id, &messages.LinkOrder{
|
|
OrderReference: req.OrderReference,
|
|
CartId: cartID,
|
|
Status: req.Status,
|
|
}); err != nil {
|
|
writeErr(w, http.StatusInternalServerError, "could not link order")
|
|
return
|
|
}
|
|
s.writeCustomer(w, r, http.StatusOK, profile.ProfileId(id).String(), id)
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func (s *Server) issueSession(w http.ResponseWriter, r *http.Request, id uint64) {
|
|
SetSession(w, r, s.signer.Issue(id, s.ttl), s.ttl)
|
|
}
|
|
|
|
// sendVerification mints a verification token for email and hands the link to
|
|
// the notifier. email must already be normalized.
|
|
func (s *Server) sendVerification(email string) {
|
|
token := s.signer.IssuePurpose(purposeVerifyEmail, email, verifyTokenTTL)
|
|
s.notifier.SendEmailVerification(email, s.link(verifyLinkPath, token))
|
|
}
|
|
|
|
// link builds an absolute link to a storefront path carrying the token as a
|
|
// query parameter. With no configured BaseURL it returns a relative link.
|
|
func (s *Server) link(path, token string) string {
|
|
return fmt.Sprintf("%s%s?token=%s", s.baseURL, path, url.QueryEscape(token))
|
|
}
|
|
|
|
// requireSession reads and validates the session cookie, writing 401 on
|
|
// failure. It returns the profile id and whether a valid session was present.
|
|
func (s *Server) requireSession(w http.ResponseWriter, r *http.Request) (uint64, bool) {
|
|
c, err := r.Cookie(SessionCookieName)
|
|
if err != nil || c.Value == "" {
|
|
writeErr(w, http.StatusUnauthorized, "not authenticated")
|
|
return 0, false
|
|
}
|
|
id, err := s.signer.Parse(c.Value)
|
|
if err != nil {
|
|
writeErr(w, http.StatusUnauthorized, "not authenticated")
|
|
return 0, false
|
|
}
|
|
return id, true
|
|
}
|
|
|
|
// writeCustomer loads the profile grain and writes it as a CustomerResponse.
|
|
func (s *Server) writeCustomer(w http.ResponseWriter, r *http.Request, status int, idStr string, id uint64) {
|
|
g, err := s.applier.Get(r.Context(), id)
|
|
if err != nil {
|
|
writeErr(w, http.StatusInternalServerError, "could not read profile")
|
|
return
|
|
}
|
|
resp := grainToCustomer(idStr, g)
|
|
if rec, ok := s.store.Get(r.Context(), g.Email); ok {
|
|
resp.EmailVerified = rec.VerifiedAt != ""
|
|
}
|
|
writeJSON(w, status, resp)
|
|
}
|
|
|
|
func grainToCustomer(id string, g *profile.ProfileGrain) CustomerResponse {
|
|
resp := CustomerResponse{
|
|
ID: id,
|
|
Name: g.Name,
|
|
Email: g.Email,
|
|
Phone: g.Phone,
|
|
Language: g.Language,
|
|
Currency: g.Currency,
|
|
AvatarURL: g.AvatarUrl,
|
|
Addresses: make([]AddressResponse, 0, len(g.Addresses)),
|
|
Orders: make([]OrderRef, 0, len(g.Orders)),
|
|
}
|
|
for _, a := range g.Addresses {
|
|
resp.Addresses = append(resp.Addresses, AddressResponse{
|
|
ID: a.Id,
|
|
Label: a.Label,
|
|
FullName: a.FullName,
|
|
AddressLine1: a.AddressLine1,
|
|
AddressLine2: a.AddressLine2,
|
|
City: a.City,
|
|
Zip: a.Zip,
|
|
Country: a.Country,
|
|
IsDefaultShipping: a.IsDefaultShipping,
|
|
IsDefaultBilling: a.IsDefaultBilling,
|
|
})
|
|
}
|
|
for _, o := range g.Orders {
|
|
resp.Orders = append(resp.Orders, OrderRef{OrderReference: o.OrderReference, Status: o.Status})
|
|
}
|
|
return resp
|
|
}
|
|
|
|
// parseBase62 decodes a base62 id (cart/checkout/profile share the scheme).
|
|
func parseBase62(s string) (uint64, bool) {
|
|
if s == "" {
|
|
return 0, false
|
|
}
|
|
id, ok := profile.ParseProfileId(s)
|
|
return uint64(id), ok
|
|
}
|
|
|
|
func validEmail(email string) bool {
|
|
if email == "" {
|
|
return false
|
|
}
|
|
_, err := mail.ParseAddress(email)
|
|
return err == nil
|
|
}
|
|
|
|
func writeJSON(w http.ResponseWriter, status int, v any) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(status)
|
|
_ = json.NewEncoder(w).Encode(v)
|
|
}
|
|
|
|
func writeErr(w http.ResponseWriter, status int, msg string) {
|
|
writeJSON(w, status, map[string]string{"error": msg})
|
|
}
|